Under vinext dev, the SSR module runner exposes a window global, but it's a partial polyfill — typeof window returns 'object' and typeof document returns 'object', while most DOM APIs (e.g. window.getComputedStyle, window.history) are undefined.
This breaks every place that uses the canonical SSR-detection idiom:
if (typeof window === 'undefined') return /* SSR fallback */;
The check passes, the code proceeds as if it were on the client, then crashes on the next missing DOM call.
Two concrete crashes already observed in a single app, one in a third-party library, one in vinext's own shims.
Versions
- vinext: 0.0.50
- @cloudflare/vite-plugin: 1.37.0
- @vitejs/plugin-rsc: 0.5.26
- vite: 8.0.13
- react / react-dom: 19.2.6
Crash #1 — third-party library (sonner)
[vite] Internal server error: window.getComputedStyle is not a function
at getDocumentDirection (.../sonner/dist/index.mjs?v=8096fa8a:831:23)
at Toaster (.../sonner/dist/index.mjs?v=8096fa8a:920:167)
at Object.react_stack_bottom_frame (.../react-dom-server.edge.development...)
at renderWithHooks
at renderElement
...
Sonner's source (sonner/dist/index.mjs:826):
function getDocumentDirection() {
if (typeof window === 'undefined') return 'ltr';
if (typeof document === 'undefined') return 'ltr';
const dirAttribute = document.documentElement.getAttribute('dir');
if (dirAttribute === 'auto' || !dirAttribute) {
return window.getComputedStyle(document.documentElement).direction;
}
return dirAttribute;
}
Both typeof guards pass under vinext+workerd SSR. The getComputedStyle call then throws.
Minimal repro
// app/providers.tsx
'use client';
import { Toaster } from 'sonner';
export default function Providers({ children }: { children: React.ReactNode }) {
return (<><Toaster />{children}</>);
}
// app/layout.tsx
import Providers from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en"><body><Providers>{children}</Providers></body></html>
);
}
Run vinext dev, hit / → 500. (Intermittent — triggers when a toast is queued during render.)
Crash #2 — vinext's own shims
Same root cause, hits user code without any third-party dep involved:
[vite] Internal server error: Cannot read properties of undefined (reading 'pushState')
at runInRunnerObject (workers/runner-worker/index.js:107:3)
at Object.createHtmlResponse (.../vinext/src/server/app-page-boundary-render.ts:254:26)
at buildAppPageSpecialErrorResponse (.../vinext/src/server/app-page-execution.ts:221:30)
at probeAppPageLayouts (.../vinext/src/server/app-page-execution.ts:305:12)
at probeAppPageBeforeRender
at renderAppPageLifecycle
at dispatchAppPage
at route (.../vinext/src/server/app-rsc-handler.ts:582:24)
at handleRequest (.../vinext/src/server/app-router-entry.ts:85:18)
The unguarded window.history.pushState calls are in vinext's own shims:
Other paths in those same files do have typeof window === 'undefined' guards (link.js:52, 63, 83, 129, 203) — these specific lines were missed.
Symptom: intermittent 500 on app-router pages. Clean dev restart clears it because HMR cache state can push the failing code path into the SSR pre-render lifecycle.
Root cause (both crashes)
The SSR module runner provides a window global that's not genuinely absent. I grep'd vinext, @cloudflare/vite-plugin, and @vitejs/plugin-rsc for explicit globalThis.window = ... assignments — found none. The partial window is most likely coming from workerd's web-compat globals (workerd exposes WebSocket, EventTarget, fetch, etc., and very likely aliases window = globalThis somewhere in its compat layer).
Net effect: the typeof window === 'undefined' short-circuit that's load-bearing across the React ecosystem doesn't fire.
Why this matters
typeof window === 'undefined' is the dominant SSR-detection idiom in the React ecosystem. Libraries that use it include:
Under vinext, any of these can hit the same class of crash. We hit two in one app within an hour — the next user will hit a third.
The React team has spent years convincing libraries to use this pattern over feature-detection alternatives. The runtime should respect it.
Asks (ordered by leverage)
-
Make typeof window === 'undefined' truthy in the SSR module runner. At runner-worker boot, delete globalThis.window (or whatever specific path produces the partial polyfill). One change, fixes every existing and future occurrence of this class of bug — third-party libs and the missed shims in vinext itself.
-
As a secondary safety net, add the missing guards to router.js:404, 415, 607, 617 and link.js:284. 5-line change. Worth doing regardless of (1) since other vinext code might add similar lines later.
-
If a full window is intentional, ensure it provides the full DOM API surface (effectively a jsdom-level polyfill). Heavy and almost certainly not the right tradeoff. Listed only for completeness.
Workaround for affected users today
Each affected library needs to be told not to do the unsafe DOM call:
- sonner: pass
dir="ltr" to Toaster → skips getDocumentDirection()
- vinext router/link: clean restart clears it, but the underlying code path is still unsafe
- Other libs: generally
dynamic(() => import(...), { ssr: false }) or a mounted-flag wrapper
Each workaround is per-library and doesn't scale. (1) above is the only fix that does.
Under
vinext dev, the SSR module runner exposes awindowglobal, but it's a partial polyfill —typeof windowreturns'object'andtypeof documentreturns'object', while most DOM APIs (e.g.window.getComputedStyle,window.history) are undefined.This breaks every place that uses the canonical SSR-detection idiom:
The check passes, the code proceeds as if it were on the client, then crashes on the next missing DOM call.
Two concrete crashes already observed in a single app, one in a third-party library, one in vinext's own shims.
Versions
Crash #1 — third-party library (
sonner)Sonner's source (sonner/dist/index.mjs:826):
Both
typeofguards pass under vinext+workerd SSR. ThegetComputedStylecall then throws.Minimal repro
Run
vinext dev, hit/→ 500. (Intermittent — triggers when a toast is queued during render.)Crash #2 — vinext's own shims
Same root cause, hits user code without any third-party dep involved:
The unguarded
window.history.pushStatecalls are in vinext's own shims:Other paths in those same files do have
typeof window === 'undefined'guards (link.js:52, 63, 83, 129, 203) — these specific lines were missed.Symptom: intermittent 500 on app-router pages. Clean dev restart clears it because HMR cache state can push the failing code path into the SSR pre-render lifecycle.
Root cause (both crashes)
The SSR module runner provides a
windowglobal that's not genuinely absent. I grep'd vinext,@cloudflare/vite-plugin, and@vitejs/plugin-rscfor explicitglobalThis.window = ...assignments — found none. The partialwindowis most likely coming from workerd's web-compat globals (workerd exposesWebSocket,EventTarget,fetch, etc., and very likely aliaseswindow = globalThissomewhere in its compat layer).Net effect: the
typeof window === 'undefined'short-circuit that's load-bearing across the React ecosystem doesn't fire.Why this matters
typeof window === 'undefined'is the dominant SSR-detection idiom in the React ecosystem. Libraries that use it include:next/router/next/linkshims (Crash Published npm package ships sourcemaps pointing to missing source files #2 — partially, hence the missed-guard bug)Under vinext, any of these can hit the same class of crash. We hit two in one app within an hour — the next user will hit a third.
The React team has spent years convincing libraries to use this pattern over feature-detection alternatives. The runtime should respect it.
Asks (ordered by leverage)
Make
typeof window === 'undefined'truthy in the SSR module runner. At runner-worker boot,delete globalThis.window(or whatever specific path produces the partial polyfill). One change, fixes every existing and future occurrence of this class of bug — third-party libs and the missed shims in vinext itself.As a secondary safety net, add the missing guards to router.js:404, 415, 607, 617 and link.js:284. 5-line change. Worth doing regardless of (1) since other vinext code might add similar lines later.
If a full
windowis intentional, ensure it provides the full DOM API surface (effectively a jsdom-level polyfill). Heavy and almost certainly not the right tradeoff. Listed only for completeness.Workaround for affected users today
Each affected library needs to be told not to do the unsafe DOM call:
dir="ltr"toToaster→ skipsgetDocumentDirection()dynamic(() => import(...), { ssr: false })or a mounted-flag wrapperEach workaround is per-library and doesn't scale. (1) above is the only fix that does.