mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user