Skip to content

fix: expose window.next global for Next.js parity#1201

Merged
james-elicx merged 2 commits into
mainfrom
fix/window-next-global
May 14, 2026
Merged

fix: expose window.next global for Next.js parity#1201
james-elicx merged 2 commits into
mainfrom
fix/window-next-global

Conversation

@james-elicx
Copy link
Copy Markdown
Collaborator

@james-elicx james-elicx commented May 14, 2026

Summary

Adds the window.next global that Next.js publishes on the client from both the Pages Router bootstrap and the App Router bootstrap. vinext did not install this global, which broke any caller that reads window.next.router directly — including the Next.js deploy test suite, Pages Router tests in userland, and several third-party libraries.

Deploy suite impact

Before this PR the Next.js deploy suite reported:

  • ~422 console errors mentioning window.next.*
  • 30+ runtime test failures
  • Most cited error: TypeError: Cannot read properties of undefined (reading 'router'). __internal_src_page and __pendingUrl ranked next.

After this PR those callers find the expected shape on window.next and the errors clear.

What was added

A typed installWindowNext() helper lives in packages/vinext/src/client/window-next.ts. Both router bootstraps invoke it, so the field shape stays in one place per AGENTS.md's guidance to keep generated entries thin and put real logic in client/ / server/ typed modules.

window.next.* field Owner Ported from
version Pages + App packages/next/src/client/next.ts line 13 (Pages); packages/next/src/client/app-bootstrap.ts line 13 (App). Reports the vinext package version, injected via Vite define for process.env.__NEXT_VERSION (mirroring Next.js's own DefinePlugin).
appDir App only packages/next/src/client/app-bootstrap.ts line 15 (appDir: true)
router (App Router) App only packages/next/src/client/components/app-router-instance.ts line 510 (window.next.router = publicAppRouterInstance). Wired to vinext's appRouterInstance (the same singleton useRouter() returns).
router (Pages Router) Pages only packages/next/src/client/next.ts line 16-18 (live-binded getter). Wired to vinext's Pages Router singleton from next/router.
router.events Pages only packages/next/src/shared/lib/router/router.ts line 372 (NextRouter type). Tests like test/development/pages-dir/client-navigation/index.test.ts:457 rely on window.next.router.events.on(...).
__pendingUrl App Router (set during navigation) packages/next/src/client/components/app-router-instance.ts line 296; read by packages/next/src/client/components/nav-failure-handler.ts
__internal_src_page App Router (set during navigation) packages/next/src/client/components/app-router.tsx line 204

Version wiring

The Vite plugin reads packages/vinext/package.json#version at plugin load (getVinextVersion() in index.ts) and exposes it via define as process.env.__NEXT_VERSION. The window-next.ts module reads that value and uses it as the default for installWindowNext({ version }). Mirrors how Next.js substitutes the same constant via its webpack DefinePlugin. Library code that reads process.env.__NEXT_VERSION directly now also sees a real value instead of undefined.

The standalone "vinext" literal remains as a safety fallback for any code path that imports client/window-next.ts without going through the Vite plugin (unit tests that don't exercise the build).

Fields intentionally omitted

  • emitter (Pages, packages/next/src/client/next.ts line 19) — this is the page-load mitt() emitter, distinct from router.events. vinext does not currently fire those events anywhere, so exposing it would be a fake surface. Left as a follow-up if it shows up in the deploy suite.
  • router.hmrRefresh and router.experimental_gesturePush (App Router) — vinext does not implement either, so they remain undefined. Library callers that probe typeof router.hmrRefresh === "function" skip the branch, matching production Next.js behavior.
  • turbopack flag from packages/next/src/client/app-next-turbopack.ts — vinext doesn't use Turbopack, so this flag is not meaningful.

Open questions / follow-ups

  1. __pendingUrl and __internal_src_page are populated only when the App Router's per-navigation code paths get wired up to write them. Initial install leaves both undefined (matching Next.js's initial-load state), but full parity requires hooking the writes inside server/app-browser-entry.ts's navigation controller. If the deploy suite still complains about these specific fields after this PR, that follow-up should land next.

Tests

Added 6 focused tests under tests/shims.test.ts in a new window.next debug global describe block:

  • installWindowNext populates version, router, and appDir
  • repeat calls merge fields (App Router bootstrap wins for router when both routers are present)
  • Pages Router shim attaches the expected NextRouter surface at module load
  • window.next.router.events forwards Pages Router events
  • appRouterInstance named export has the App Router public surface
  • installWindowNext is a no-op when typeof window === "undefined"

Full tests/shims.test.ts (866 tests) and tests/link.test.ts, plus tests/app-browser-entry.test.ts, tests/app-router.test.ts, and tests/pages-router.test.ts (605 tests) all pass. tests/build-optimization.test.ts (73 tests) also passes.

vp run knip is clean. vp check is clean for code in this PR. There is one pre-existing lint error in tests/e2e/fixtures.ts (also present on origin/main) that is unrelated.

Next.js publishes window.next from both the Pages Router client bootstrap
(packages/next/src/client/next.ts) and the App Router bootstrap
(packages/next/src/client/app-bootstrap.ts). Userland code and the Next.js
deploy test suite read window.next.router directly (push, replace,
events.on, etc.), and Next.js's own client code reads
window.next.__pendingUrl and window.next.__internal_src_page for App
Router navigation bookkeeping.

vinext did not install this global, which surfaced as ~422 console errors
and 30+ runtime failures in the Next.js deploy test suite, with
"TypeError: Cannot read properties of undefined (reading 'router')" the
most cited.

Adds an installWindowNext helper in client/window-next.ts that both router
bootstraps invoke:

- Pages Router (shims/router.ts) installs window.next.router pointing at
  the Router singleton at module load.
- App Router (server/app-browser-entry.ts) installs
  window.next = { appDir: true, router: appRouterInstance } before
  hydration starts, mirroring app-bootstrap.ts.

The helper merges fields on repeat calls so hybrid Pages+App setups
behave like Next.js (the App Router bootstrap wins for router/appDir,
matching Next.js's load order). __pendingUrl and __internal_src_page
remain undefined until the navigation paths populate them; that matches
Next.js's behavior on initial load.
@james-elicx
Copy link
Copy Markdown
Collaborator Author

/bigbonk review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 14, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1201

commit: 8781f60

@github-actions
Copy link
Copy Markdown

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

Comment on lines +83 to +93
// Declare the `next` property on Window here, alongside the type, so this
// module type-checks standalone without depending on the global.d.ts
// augmentation (which itself imports WindowNext from this file). Matches the
// pattern Next.js uses in `packages/next/src/client/next.ts` lines 7-11:
// declare global { interface Window { next: any } }
declare global {
// oxlint-disable-next-line typescript/consistent-type-definitions
interface Window {
next?: WindowNext;
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be moved to the global type decls file potentially?

Comment on lines +48 to +80
export type AppRouterPublicInstance = {
push: (href: string, options?: { scroll?: boolean }) => void;
replace: (href: string, options?: { scroll?: boolean }) => void;
back: () => void;
forward: () => void;
refresh: () => void;
prefetch: (href: string) => void;
/** Default placeholder, matches Next.js. */
bfcacheId?: string;
};

/**
* Pages Router singleton surface — matches `NextRouter` from
* `packages/next/src/shared/lib/router/router.ts` (line 372).
*
* Typed loosely here because the helper is consumed by both the pages
* router shim (which exports the strict `NextRouter` type) and tests that
* only care about the runtime shape. The structural fields below are the
* subset that downstream callers (Next.js tests, third-party libs) read
* synchronously off `window.next.router`.
*/
export type PagesRouterPublicInstance = {
push: (...args: unknown[]) => unknown;
replace: (...args: unknown[]) => unknown;
back: () => void;
reload: () => void;
prefetch: (...args: unknown[]) => unknown;
beforePopState: (cb: (...args: unknown[]) => boolean) => void;
events: {
on: (event: string, handler: (...args: unknown[]) => void) => void;
off: (event: string, handler: (...args: unknown[]) => void) => void;
emit: (event: string, ...args: unknown[]) => void;
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do these types not already exist elsewhere?

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Good approach — single installWindowNext() helper with merge semantics, one source of truth for the field shape, both routers calling in. Architecture matches AGENTS.md guidance (thin entries, real logic in typed modules). Tests are thorough.

Two things to address before merge:

1. CI failure: knip unused exported types (blocking)

The Check job fails because knip flags AppRouterPublicInstance and WindowNext as unused exported types. They're used inside the file's own declare global block and type definitions, but nothing outside window-next.ts imports them. Either drop the export keyword (they can stay as file-local types) or add a knip ignore comment if you want them available for future consumers.

2. Version field discrepancy (non-blocking)

The PR description says the version field reports the vinext package version (0.0.5), but neither call site passes versionrouter.ts:1042 and app-browser-entry.ts:1361 both omit it. The fallback is the literal string "vinext" (FALLBACK_VERSION). This is fine for unblocking the deploy suite (callers get a truthy string), but if the intent was to match Next.js's process.env.__NEXT_VERSION pattern, the actual package version should be injected via a build-time define or package.json import.

Minor notes

  • getWindowNext() is currently only consumed in tests. Fine as forward-looking API for the __pendingUrl/__internal_src_page follow-up, but knip may flag it too if it stays test-only.
  • The as unknown as PagesRouterPublicInstance cast in router.ts:1042 is well-commented and structurally correct at runtime.
  • Module-level installedNext (line 137) introduces a subtle secondary tracking path alongside window.next. The window.next ?? installedNext fallback on line 158 means if someone deletes window.next but the module isn't re-imported, installedNext would resurrect a stale reference. Not a practical issue given the usage pattern, just worth being aware of.

* presence (`typeof router.hmrRefresh === "function"`) will skip the
* branch, matching what they would do on a production Next.js build.
*/
export type AppRouterPublicInstance = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

knip flags both AppRouterPublicInstance (here) and WindowNext (line 100) as unused exports — this is the Check CI failure. Neither type is imported outside this file. The declare global block on line 88 uses WindowNext but knip doesn't count intra-file usage as an external reference.

Simplest fix: drop the export keyword from both. They're only used within this file's own type definitions and ambient declarations. If you want them importable for future callers (e.g. the __pendingUrl follow-up), add a knip ignore directive instead.

__internal_src_page?: string;
};

const FALLBACK_VERSION = "vinext";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says version reports 0.0.5 (the vinext package version), but neither router.ts nor app-browser-entry.ts passes a version field to installWindowNext(), so this fallback string "vinext" is what every user actually gets.

Not blocking — any truthy string unblocks the deploy suite — but if you want the real package version, consider either:

  • A build-time define replacement (like process.env.__VINEXT_VERSION)
  • Having the call sites pass it explicitly

Or update the PR description to match the actual behavior.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 14, 2026

Review posted. Here's a summary:

Review posted on PR #1201 with 1 summary comment and 2 inline comments.

Key findings:

  1. CI failure (blocking): The Check job fails because knip flags AppRouterPublicInstance and WindowNext as unused exported types in window-next.ts. Neither is imported outside the file. Fix: drop the export keyword from both types, or add a knip ignore directive.

  2. Version field discrepancy (non-blocking): The PR description claims version reports the vinext package version (0.0.5), but neither call site passes version to installWindowNext(). The actual fallback is the literal string "vinext". Not blocking for the deploy suite (callers just need a truthy string), but the PR description should match the code.

  3. Overall assessment: The architecture is solid — single installer helper with merge semantics, clean appRouterInstance export alias, thorough tests (6 new tests covering install, merge, Pages Router side-effect, events forwarding, App Router export, and SSR no-op). Matches AGENTS.md guidance for keeping entries thin.

github run

return { version: fields.version ?? FALLBACK_VERSION, ...fields };
}

const existing = window.next ?? installedNext;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would installedNext ever actually have a value when window.next doesn't?

…sion, remove module fallback

- Drop `export` from `AppRouterPublicInstance` and `WindowNext` types
  (knip flagged them as unused; they're only used within window-next.ts).
- Drop `getWindowNext()` and its export — tests read `window.next`
  directly, which is the same surface the runtime callers use.
- Wire up `process.env.__NEXT_VERSION` via Vite `define` in index.ts,
  reading the vinext package version from packages/vinext/package.json
  at plugin load. This mirrors Next.js's own DefinePlugin substitution
  (packages/next/src/client/next.ts line 5) so library code that branches
  on `process.env.__NEXT_VERSION` sees a real value and
  `window.next.version` lands the package version instead of the
  `"vinext"` fallback.
- Drop the module-level `installedNext` fallback. `installWindowNext`
  now reads and writes through `window.next` directly so deleting
  `window.next` from tests/userland fully resets state — no stale
  reference can be resurrected on the next call.
@james-elicx
Copy link
Copy Markdown
Collaborator Author

/bigbonk

Addressed all review points:

  1. Dropped export from AppRouterPublicInstance and WindowNext (knip flagged them). PagesRouterPublicInstance stays exported because shims/router.ts imports it.
  2. Removed getWindowNext() and its export — tests now read window.next directly, same surface runtime callers use.
  3. Wired up the real version: packages/vinext/package.json#version is read once at plugin load and injected as process.env.__NEXT_VERSION via Vite define. This mirrors Next.js's own DefinePlugin substitution, so library code that branches on process.env.__NEXT_VERSION also gets a real value.
  4. Removed the installedNext module-level fallback. installWindowNext now reads and writes through window.next directly so deleting window.next cleanly resets state.

vp run knip is clean. vp check is clean for code in this PR (the pre-existing lint error in tests/e2e/fixtures.ts is on origin/main and unrelated). All targeted vitest suites pass (shims/link/app-router/pages-router/app-browser-entry/build-optimization).

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four review points cleanly addressed:

  1. export dropped from AppRouterPublicInstance and WindowNext — confirmed at window-next.ts:48 and window-next.ts:104. PagesRouterPublicInstance stays exported (line 68) and is consumed by router.ts:12. knip should be happy.

  2. getWindowNext() removed — gone from the module, tests now read win.next directly (e.g. shims.test.ts:678, :733, :738). Cleaner surface.

  3. Real version wiredgetVinextVersion() in index.ts:356 reads package.json#version once (with cache), index.ts:1003 injects it via define["process.env.__NEXT_VERSION"], window-next.ts:150 reads it with "vinext" safety fallback, and global.d.ts:396 declares the ProcessEnv shape. Mirrors Next.js's own DefinePlugin substitution. The PR description now matches the code.

  4. installedNext module-level fallback removedinstallWindowNext reads and writes through window.next directly (window-next.ts:170-185). Deleting window.next cleanly resets state, no stale reference resurrection possible.

The declare global staying in window-next.ts (not moved to global.d.ts) is the right call — global.d.ts:23-25 and :147-148 explain the co-location rationale, and it avoids a circular dependency (the WindowNext type would need to be imported into global.d.ts from the module that owns it).

Ship it.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 14, 2026

Review posted with approval on PR #1201. All four review points are cleanly addressed:

  1. export dropped from AppRouterPublicInstance and WindowNextPagesRouterPublicInstance stays exported for router.ts.
  2. getWindowNext() removed — tests read window.next directly.
  3. Real version wired via getVinextVersion()process.env.__NEXT_VERSION define → VINEXT_VERSION constant in window-next.ts.
  4. installedNext module-level fallback removed — reads/writes go through window.next directly.

The declare global co-location in window-next.ts (rather than global.d.ts) is the right design choice to avoid circular imports. No remaining issues found.

github run

@james-elicx james-elicx merged commit a390474 into main May 14, 2026
54 of 56 checks passed
@james-elicx james-elicx deleted the fix/window-next-global branch May 14, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant