fix(env): assign window.__ENV directly instead of queueing it (#6494)

`<EnvScript>` defaults to Next's `<Script strategy='beforeInteractive'>`, which
does not assign `window.__ENV` — it pushes the assignment onto `self.__next_s`.
That queue has one consumer, `appBootstrap`, which reads it once and
short-circuits to `hydrate()` when empty. The bootstrap chunk's `<script async>`
tag sits ~13KB earlier in the document than the env tag, so when that chunk runs
first the queue drains empty, nothing drains it again, and `window.__ENV` stays
undefined for the life of the document — every `getEnv` read empty until a
reload wins the race.

`disableNextScript` emits a plain inline `<script>` that assigns
unconditionally, so a lost race costs a few milliseconds instead of the session.

Also records whether the assignment was still missing at module-init so the
residual (module-scope reads in `env-flags`, which freeze what they see) is
measured rather than assumed.
This commit is contained in:
Waleed
2026-08-10 11:01:40 -07:00
committed by GitHub
parent 5dbc342d7c
commit 5da48d015a
5 changed files with 59 additions and 30 deletions
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import type { PostHog } from 'posthog-js'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { getEnv, isTruthy, publicEnvMissingAtModuleInit } from '@/lib/core/config/env'
const logger = createLogger('PostHogProvider')
@@ -49,6 +49,9 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
persistence: 'localStorage+cookie',
})
}
if (publicEnvMissingAtModuleInit) {
posthog.capture('runtime_env_missing_at_module_init')
}
clientRef.current = posthog
setProvider(() => PHProvider)
})
+13 -14
View File
@@ -1,30 +1,29 @@
/**
* @vitest-environment node
*/
import { EnvScript } from 'next-runtime-env'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { PublicEnvScript } from '@/app/_shell/public-env-script'
/**
* Guards the loading strategy, not the markup. A plain `<script>` rendered from
* the root layout lands after the `<script async>` chunk tags Next emits at the
* top of the document, so a chunk can execute - and hydration can begin - before
* `window.__ENV` is populated. Delegating to `<EnvScript>` keeps the
* `beforeInteractive` guarantee that `next-runtime-env` applies by default.
* Guards the one property that matters: the emitted tag assigns `window.__ENV`
* itself. Next's `beforeInteractive` strategy instead pushes the assignment onto
* `self.__next_s`, a queue `appBootstrap` reads exactly once and abandons when it
* is empty - so whenever the bootstrap chunk runs before the parser reaches this
* tag, the assignment is discarded and `window.__ENV` is never defined for that
* document. See the component's TSDoc for the full ordering argument.
*/
describe('PublicEnvScript', () => {
it('delegates to next-runtime-env EnvScript rather than emitting a raw script tag', () => {
const element = PublicEnvScript()
it('emits a script that assigns window.__ENV directly', () => {
const markup = renderToStaticMarkup(<PublicEnvScript />)
expect(element.type).toBe(EnvScript)
expect(element.type).not.toBe('script')
expect(markup).toContain("window['__ENV'] =")
})
it('does not opt out of the beforeInteractive strategy', () => {
const { disableNextScript, nextScriptProps } = PublicEnvScript().props
it('does not defer the assignment into the __next_s queue', () => {
const markup = renderToStaticMarkup(<PublicEnvScript />)
expect(disableNextScript).toBeUndefined()
expect(nextScriptProps?.strategy ?? 'beforeInteractive').toBe('beforeInteractive')
expect(markup).not.toContain('__next_s')
})
it('passes only NEXT_PUBLIC_ variables through to the browser', () => {
+24 -14
View File
@@ -18,21 +18,31 @@ const HOSTED_PUBLIC_ENV = Object.fromEntries(
/**
* Static equivalent of `next-runtime-env`'s `<PublicEnvScript>` for the hosted
* deployment. It renders the library's own `<EnvScript>`, so the emitted markup
* and its `beforeInteractive` loading strategy are identical to the self-hosted
* path - only the env read differs. `<PublicEnvScript>` additionally calls
* `unstable_noStore()`, which opts the entire app into dynamic rendering; that
* only pays off for self-hosted Docker images that re-inject env per deploy
* without a rebuild, so hosted reads the env once here and stays static.
* is identical to the self-hosted path - only the env read differs.
* `<PublicEnvScript>` additionally calls `unstable_noStore()`, which opts the
* entire app into dynamic rendering; that only pays off for self-hosted Docker
* images that re-inject env per deploy without a rebuild, so hosted reads the
* env once here and stays static.
*
* `beforeInteractive` is load-bearing, not an optimization. A plain `<script>`
* rendered from the root layout lands at the end of `<head>`, after the ~40
* `<script async>` chunk tags Next emits at the top of the document; an `async`
* script runs as soon as its fetch resolves, so on a warm cache a Next chunk
* can execute - and hydration can begin - before the parser reaches the env
* tag, leaving `window.__ENV` undefined for the first render.
* `beforeInteractive` instead queues the script into `self.__next_s`, which
* Next's `appBootstrap` drains to completion before calling `hydrate()`.
* `disableNextScript` is load-bearing. Without it, `<EnvScript>` defaults to
* Next's `<Script strategy='beforeInteractive'>`, which does not assign
* `window.__ENV` at all - it emits a tag that pushes the assignment onto
* `self.__next_s`. That queue has exactly one consumer, `appBootstrap`, which
* reads it once and short-circuits to `hydrate()` when it is empty. The
* bootstrap chunk's `<script async>` tag sits ~13KB earlier in the document
* than this tag, so whenever that chunk executes before the parser arrives
* here, the queue is drained empty, nothing ever drains it again, and
* `window.__ENV` stays undefined for the entire lifetime of the document -
* every `getEnv` read empty, until a reload happens to win the race.
*
* A plain `<script>` assigns unconditionally when the parser reaches it. When
* it is reached before the bootstrap chunk runs it lands strictly earlier than
* the queue drain would have; when it is not, the value still arrives a few
* milliseconds late instead of never. There is no supported way to place an
* inline script ahead of the framework's own bootstrap tags - React emits those
* in the preamble, before any content from the component tree - so the goal is
* to make losing that race harmless rather than to try to win it.
*/
export function PublicEnvScript() {
return <EnvScript env={HOSTED_PUBLIC_ENV} />
return <EnvScript env={HOSTED_PUBLIC_ENV} disableNextScript />
}
+1 -1
View File
@@ -258,7 +258,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
</>
)}
{isHosted ? <PublicEnvScript /> : <RuntimePublicEnvScript />}
{isHosted ? <PublicEnvScript /> : <RuntimePublicEnvScript disableNextScript />}
</head>
<body className={`${season.variable} font-season`} suppressHydrationWarning>
{/* Google Tag Manager (noscript) — hosted only */}
+17
View File
@@ -21,6 +21,23 @@ const getEnv = (variable: string): string | undefined => {
return window.__ENV?.[variable] ?? process.env[variable]
}
/**
* Whether `window.__ENV` was still unset when this module first evaluated in the
* browser. Always `false` on the server.
*
* Module bodies run inside the framework bootstrap, which an `async` chunk can
* start before the parser has reached the inline assignment at the end of
* `<head>`. Reads made during render are unaffected: nothing renders until the
* RSC payload arrives, and that streams from `<body>` — after the assignment.
* Module-scope reads have no such ordering, and the values they derive
* (`isHosted` and the other flags in `env-flags`) stay frozen for the session.
*
* Reported once per load so the rate is measurable rather than assumed; it is
* what decides whether those flags need to become lazy.
*/
export const publicEnvMissingAtModuleInit =
typeof window !== 'undefined' && window.__ENV === undefined
// biome-ignore format: keep alignment for readability
export const env = createEnv({
skipValidation: true,