fix(env): restore beforeInteractive on the hosted public env script - #6214
Conversation
The hosted `<PublicEnvScript>` rendered a plain `<script>`, which 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 could execute (and hydration begin) before the parser reached the env tag, leaving `window.__ENV` undefined for the first render. That surfaced as "Something went wrong" on the workflow page, since `getBaseUrl()` throws during the deploy modal's render, and as the socket falling back to the page origin instead of NEXT_PUBLIC_SOCKET_URL. Regressed in #5522, which replaced next-runtime-env's PublicEnvScript (to avoid its unstable_noStore forcing dynamic rendering) with a static equivalent that dropped the beforeInteractive strategy. - render the library's own `<EnvScript>`, which defaults to beforeInteractive and does not call unstable_noStore — hosted and self-hosted now share one implementation and one loading strategy - drop the hand-rolled serialization and `<` escaping; Next's beforeInteractive path already runs the payload through htmlEscapeJsonString, which escapes `& > < U+2028 U+2029` - fall back to the browser origin in getBaseUrl() rather than throwing, so a missing injected env can never tear down a page through the error boundary; server-side callers still fail loudly - read NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED via getEnv() in the SSO form, matching login/signup/auth-modal — `env.X` returns the build-time placeholder, not the runtime value
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview
The SSO form reads Reviewed by Cursor Bugbot for commit 599254b. Configure here. |
Greptile SummaryThe PR restores pre-hydration runtime environment injection for hosted deployments and makes browser URL resolution resilient when injected configuration is unavailable.
Confidence Score: 5/5The PR appears safe to merge, with no concrete changed-code defect identified. The hosted environment script is restored before hydration, runtime feature-flag access follows the established authentication pattern, and the URL fallback remains limited to browser contexts while server callers still fail on missing configuration.
|
| Filename | Overview |
|---|---|
| apps/sim/app/_shell/public-env-script.tsx | Delegates hosted environment injection to EnvScript, preserving the public-variable filter and restoring the pre-hydration loading strategy. |
| apps/sim/ee/sso/components/sso-form.tsx | Uses the runtime environment accessor for the email/password feature gate, matching sibling authentication components. |
| apps/sim/lib/core/utils/urls.ts | Returns the configured application URL when present, otherwise uses the browser origin while retaining the server-side failure path. |
| packages/testing/src/mocks/urls.mock.ts | Updates the shared URL mock to mirror the new browser-origin fallback. |
| apps/sim/app/_shell/public-env-script.test.tsx | Adds focused assertions for EnvScript delegation, loading strategy, and filtering to public environment variables. |
| apps/sim/lib/core/utils/urls.test.ts | Covers configured, missing, and whitespace-only application URL behavior. |
Sequence Diagram
sequenceDiagram
participant Server as Next.js Server
participant Document as HTML Document
participant Bootstrap as Next appBootstrap
participant Browser as Client Components
Server->>Document: Render EnvScript with runtime NEXT_PUBLIC values
Document->>Bootstrap: Queue beforeInteractive script in self.__next_s
Bootstrap->>Document: Execute environment script
Document->>Document: Populate window.__ENV
Bootstrap->>Browser: Begin hydration
Browser->>Document: Read runtime values through getEnv()
alt NEXT_PUBLIC_APP_URL exists
Browser->>Browser: Normalize configured URL
else Injected URL unavailable
Browser->>Browser: Use window.location.origin
end
Reviews (1): Last reviewed commit: "fix(env): restore beforeInteractive on t..." | Re-trigger Greptile
The fallback was added in #6214 as a safety net while the real cause — the hosted env script losing its `beforeInteractive` strategy — was fixed in the same PR. With the injection ordering restored, `window.__ENV` is populated before hydration, so the fallback is unreachable in any correctly configured deployment. Guessing the origin was also unsafe in the one case it could still fire. An opaque origin — a sandboxed iframe, and `/chat/*` is deliberately embeddable — serializes to the string `'null'`, which is truthy, so `getBaseUrl()` would have returned `'null'` and every call site would have silently built `null/api/...`. A throw surfaces the misconfiguration instead of encoding it into request URLs. - restore the unconditional throw when NEXT_PUBLIC_APP_URL is unset or blank - mirror it back in the shared testing mock - flip the two fallback tests to assert the throw, keeping whitespace-only coverage Server-side behavior is unchanged: there was never a `window` to fall back to, so callers that already guard `getBaseUrl()` (`getBaseDomain`, `validateCallbackUrl`) keep their existing fail-closed paths.
…6217) * fix(sso): derive the SSO callback URL during render `callbackUrl` was seeded into `useState('/workspace')` and overwritten from a `useEffect` that read `searchParams`, so the first painted frame always carried the default. On any deep link with `?callbackUrl=`, the "Sign in with email" and "Sign up" links briefly pointed at `/workspace` instead of the requested destination, and a click landing in that window navigated to the wrong place. - derive `callbackUrl` from `searchParams` during render; the validation gate is unchanged, so an off-origin or malformed value still falls back to `/workspace` - keep the warning for a rejected value in an effect, now keyed on the param itself rather than the `searchParams` object, so it fires once per actual change instead of once per identity change - add first-frame tests via `renderToString`, which runs no effects and so pins exactly the window the old code got wrong * fix(auth): resolve callback URLs against the app origin server-side `validateCallbackUrl` compared against a sentinel base (`https://callback-url-validator.invalid`) when `window` was undefined, so the server rejected every absolute URL — including the same-origin ones the function documents as valid. A component deriving a callback URL during render therefore produced one destination in the SSR markup and a different one after hydration. The exposure was not new to the SSO form: `login-form.tsx` and `signup-form.tsx` already derive their callback URL during render on `force-dynamic` pages, so both carried the same divergence. - resolve against the deployment's own origin server-side, so the server reaches the same verdict the browser will after hydration - fall back to the sentinel when the app URL is unset or unparseable, which keeps the server fail-closed: absolute URLs are rejected, as before - cover the absolute same-origin case and the unset-app-URL fallback in the existing suite; all 15 open-redirect rejection cases are unchanged * fix(env): drop the getBaseUrl browser-origin fallback The fallback was added in #6214 as a safety net while the real cause — the hosted env script losing its `beforeInteractive` strategy — was fixed in the same PR. With the injection ordering restored, `window.__ENV` is populated before hydration, so the fallback is unreachable in any correctly configured deployment. Guessing the origin was also unsafe in the one case it could still fire. An opaque origin — a sandboxed iframe, and `/chat/*` is deliberately embeddable — serializes to the string `'null'`, which is truthy, so `getBaseUrl()` would have returned `'null'` and every call site would have silently built `null/api/...`. A throw surfaces the misconfiguration instead of encoding it into request URLs. - restore the unconditional throw when NEXT_PUBLIC_APP_URL is unset or blank - mirror it back in the shared testing mock - flip the two fallback tests to assert the throw, keeping whitespace-only coverage Server-side behavior is unchanged: there was never a `window` to fall back to, so callers that already guard `getBaseUrl()` (`getBaseDomain`, `validateCallbackUrl`) keep their existing fail-closed paths.
Summary
<PublicEnvScript>rendered a plain<script>, which lands at the end of<head>— after the ~40<script async>chunk tags Next emits at the top of the document. Anasyncscript runs as soon as its fetch resolves, so on a warm cache a Next chunk could execute (and hydration begin) before the parser reached the env tag, leavingwindow.__ENVundefined for the first render.getBaseUrl()throws during the deploy modal's render) and as the socket falling back to the page origin instead ofNEXT_PUBLIC_SOCKET_URL— two symptoms, one cause.PublicEnvScript(to avoid itsunstable_noStoreforcing dynamic rendering) with a static equivalent that dropped thebeforeInteractivestrategy.<EnvScript>, which defaults tobeforeInteractiveand does not callunstable_noStore. Hosted and self-hosted share one implementation and one loading strategy, so the two paths can't drift again. Next'sappBootstrapdrainsself.__next_sto completion before callinghydrate().<escaping — Next'sbeforeInteractivepath already runs the payload throughhtmlEscapeJsonString, which escapes& > < U+2028 U+2029(strictly more than we did).getBaseUrl()now falls back to the browser origin instead of throwing, so a missing injected env can never tear down a page through the error boundary. Server-side callers (webhooks, callbacks, emails) still fail loudly. Note there is no safe static fallback: the image is built with placeholderNEXT_PUBLIC_*values, so the inlinedNEXT_PUBLIC_APP_URLin the prod client bundle ishttp://localhost:3000—window.__ENVis the only source of truth in the browser.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLEDviagetEnv()in the SSO form, matching login/signup/auth-modal.env.Xreturns the build-time placeholder, not the runtime value.Type of Change
Testing
Verified the emitted markup by running the app with
NEXT_PUBLIC_FORCE_HOSTED=true: the plain<script id="public-env">is gone and the env is queued intoself.__next_s. Confirmed the escaping I removed is genuinely covered by settingNEXT_PUBLIC_BRAND_NAME='</script><img src=x onerror=alert(1)>'— output is</script>..., no breakout.New
public-env-script.test.tsxpins the loading strategy (delegates toEnvScript, no raw script tag, no strategy override); confirmed it fails on the pre-fix shape. NewgetBaseUrltests confirmed red without the fallback.tscclean,bun run lint:checkclean (only pre-existing warnings in untouched files), 45 app tests + 39 testing-package tests pass.Checklist