Skip to content

vinext SSR provides a partial window global, breaking typeof window === 'undefined' checks #1222

@eashish93

Description

@eashish93

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)

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions