diff --git a/packages/vinext/src/client/window-next.ts b/packages/vinext/src/client/window-next.ts new file mode 100644 index 000000000..9461f9418 --- /dev/null +++ b/packages/vinext/src/client/window-next.ts @@ -0,0 +1,186 @@ +/** + * Install the `window.next` debug/diagnostic global that Next.js exposes + * on the client. + * + * Next.js publishes a small per-app object on `window.next` from its + * client bootstraps and uses it for two distinct purposes: + * + * 1. An external debugging / test-automation surface. Pages Router tests + * and userland code routinely call `window.next.router.push(...)` and + * `window.next.router.events.on(...)` directly, and the App Router + * bootstrap sets `appDir: true` so consumers can branch on which + * router is active. + * - Pages Router: `packages/next/src/client/next.ts` + * - App Router: `packages/next/src/client/app-bootstrap.ts` + * - App Router public surface: + * `packages/next/src/client/components/app-router-instance.ts` + * (`window.next.router = publicAppRouterInstance` at line 510) + * + * 2. Internal navigation bookkeeping read by Next.js itself. The App + * Router's component writes `window.next.__internal_src_page` + * whenever the active source-page changes, and the router instance + * writes `window.next.__pendingUrl` at the start of a programmatic + * navigation so nav-failure-handler.ts can fall back to a hard + * navigation if a render fails. + * - `packages/next/src/client/components/app-router.tsx` (line ~204) + * - `packages/next/src/client/components/app-router-instance.ts` + * (line ~296) + * - `packages/next/src/client/components/nav-failure-handler.ts` + * + * Without this global, third-party libraries and a large fraction of the + * Next.js deploy test suite crash with + * `TypeError: Cannot read properties of undefined (reading 'router')`. + * + * Both routers in vinext share this installer so the field shape stays in + * sync and only one source of truth describes the supported keys. + */ + +/** + * The minimum App Router public router surface that Next.js exposes on + * `window.next.router`. Mirrors the `publicAppRouterInstance` shape from + * `packages/next/src/client/components/app-router-instance.ts`. + * + * `hmrRefresh` and `experimental_gesturePush` are intentionally omitted — + * vinext does not implement them. Library callers that branch on their + * presence (`typeof router.hmrRefresh === "function"`) will skip the + * branch, matching what they would do on a production Next.js build. + */ +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). + * + * Exported because `shims/router.ts` casts its strict `NextRouter` value + * to this looser type at the install call site (Pages Router methods take + * narrow `UrlObject | string` arguments, which are not contravariantly + * assignable to the `unknown[]` surface this global exposes). + */ +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; + }; +}; + +// 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 would have to import WindowNext from here). +// 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; + } +} + +/** + * The shape of `window.next`. Only includes fields vinext actually + * implements. App Router additionally writes `__internal_src_page` and + * `__pendingUrl` at runtime; they start undefined. + * + * Not exported because all use is internal to this module — callers read + * the shape off `window.next` directly, which inherits the augmentation + * above without a named type import. + */ +type WindowNext = { + /** + * Version string, mirroring Next.js's `process.env.__NEXT_VERSION` set + * from `packages/next/src/client/next.ts` (line 5). vinext substitutes + * the vinext package version because there is no underlying Next.js + * runtime to report. + */ + version: string; + /** + * `true` when the App Router bootstrap has run on this page. Matches + * Next.js `app-bootstrap.ts` (line 15: `appDir: true`). Pages Router + * leaves this undefined. + */ + appDir?: boolean; + /** + * The active router instance. App Router writes the publicAppRouterInstance + * here; Pages Router writes its Router singleton. Same property name in + * both Next.js and vinext. + */ + router?: AppRouterPublicInstance | PagesRouterPublicInstance; + /** + * App Router only. The URL of the current in-flight navigation (set when + * a navigation begins, cleared on commit). Read by + * `nav-failure-handler.ts` to fall back to a hard navigation when a + * render fails. Pages Router does not write this. + */ + __pendingUrl?: URL; + /** + * App Router only. The source page extracted from the current Flight + * router state. Read by external tooling and Next.js's own dev hot + * reloader. Pages Router does not write this. + */ + __internal_src_page?: string; +}; + +/** + * Build-time replacement for the vinext package version, injected by the + * Vite plugin via `define` (see `index.ts` — `process.env.__NEXT_VERSION` + * is mirrored from `packages/vinext/package.json#version` so library + * callers that read `process.env.__NEXT_VERSION` see a real value). + * + * In environments where the define did not run (standalone unit tests + * that import this module without going through the plugin), the + * `?? "vinext"` fallback prevents a literal `undefined` from landing on + * `window.next.version`. + */ +const VINEXT_VERSION: string = process.env.__NEXT_VERSION ?? "vinext"; + +/** + * Install `window.next` if it has not already been installed in this + * document. Subsequent calls update fields in place so both the Pages + * Router and the App Router bootstraps can call this without clobbering + * each other (e.g. for hybrid `pages/` + `app/` setups). + * + * When called a second time, `router` and `appDir` overwrite the previous + * values. This mirrors Next.js's load order: in a hybrid app the App + * Router's `app-bootstrap.ts` runs after Pages Router's `next.ts` and the + * App Router instance wins. + * + * No module-level cache: we read and write through `window.next` directly + * so that a test (or userland code) that deletes `window.next` cleanly + * resets state. + */ +export function installWindowNext(fields: Partial): void { + if (typeof window === "undefined") return; + + const existing = window.next; + if (existing) { + if (fields.version !== undefined) existing.version = fields.version; + if (fields.appDir !== undefined) existing.appDir = fields.appDir; + if (fields.router !== undefined) existing.router = fields.router; + if (fields.__pendingUrl !== undefined) existing.__pendingUrl = fields.__pendingUrl; + if (fields.__internal_src_page !== undefined) { + existing.__internal_src_page = fields.__internal_src_page; + } + return; + } + + window.next = { + version: fields.version ?? VINEXT_VERSION, + ...fields, + }; +} diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index 27a064121..6849c2091 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -20,6 +20,10 @@ import type { Root } from "react-dom/client"; import type { OnRequestErrorHandler } from "./server/instrumentation"; import type { CachedRscResponse, PrefetchCacheEntry } from "vinext/shims/navigation"; +// `window.next` is declared inline in `./client/window-next.ts` (mirroring +// Next.js's own pattern in `packages/next/src/client/next.ts`), not here, so +// the type is co-located with the installer that owns the runtime shape. + // --------------------------------------------------------------------------- // Window globals — browser-side state shared across module boundaries // --------------------------------------------------------------------------- @@ -139,6 +143,9 @@ declare global { // re-declare it here to avoid type conflicts. vinext-specific extensions // (__vinext) are accessed via the `VinextNextData` type in // `client/vinext-next-data.ts`. + // + // `window.next` is declared in `./client/window-next.ts` so its type + // (`WindowNext`) lives next to the installer that owns the runtime shape. } // ── self globals used inside server-injected inline scripts ─────────────── @@ -378,6 +385,15 @@ declare global { * are allowed (`next.config.js` → `images.dangerouslyAllowLocalIP`). */ __VINEXT_IMAGE_DANGEROUSLY_ALLOW_LOCAL_IP?: string; + + /** + * Next.js-compatible version string. vinext mirrors Next.js's + * `process.env.__NEXT_VERSION` define (from + * `packages/next/src/client/next.ts` line 5) so library code that + * reads it works unmodified. Value is the vinext package version, + * injected by the plugin at build time. + */ + __NEXT_VERSION?: string; } } } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 91272a455..e75714115 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -343,6 +343,28 @@ function getViteMajorVersion(): number { } } +/** + * Read the vinext package version once at plugin load. Surfaced via + * `process.env.__NEXT_VERSION` define so `window.next.version` lands a + * real string instead of the `"vinext"` fallback. Resolved relative to + * this module's own `package.json`, not the project root. + * + * Defaults to `"vinext"` on read failure so a malformed install never + * breaks the build — only the diagnostic global loses fidelity. + */ +let _vinextVersionCache: string | null = null; +function getVinextVersion(): string { + if (_vinextVersionCache !== null) return _vinextVersionCache; + try { + const pkgUrl = new URL("../package.json", import.meta.url); + const pkg = JSON.parse(fs.readFileSync(pkgUrl, "utf-8")) as { version?: unknown }; + _vinextVersionCache = typeof pkg.version === "string" ? pkg.version : "vinext"; + } catch { + _vinextVersionCache = "vinext"; + } + return _vinextVersionCache; +} + type UserResolveConfigWithTsconfigPaths = NonNullable & { tsconfigPaths?: boolean; }; @@ -969,6 +991,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { defines["process.env.__VINEXT_DEPLOYMENT_ID"] = JSON.stringify( nextConfig.deploymentId ?? "", ); + // Next.js version compat — mirrors Next.js' `process.env.__NEXT_VERSION`, + // which is substituted by their webpack DefinePlugin at build time + // (see `packages/next/src/client/next.ts` line 5 and + // `packages/next/src/client/app-bootstrap.ts` line 11). Userland code + // and third-party libraries occasionally branch on this value, and + // it's the source for `window.next.version` (set in + // `client/window-next.ts`). We report the vinext package version + // because vinext is the runtime — there is no underlying Next.js + // version to surface. + defines["process.env.__NEXT_VERSION"] = JSON.stringify(getVinextVersion()); // Build the shim alias map. Exact `.js` variants are included for the // public Next entrypoints that are file-backed in `next/package.json`. diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 9cc7e580c..659937246 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -13,6 +13,7 @@ import "../client/instrumentation-client.js"; import { notifyAppRouterTransitionStart } from "../client/instrumentation-client-state.js"; import { __basePath, + appRouterInstance, commitClientNavigationState, consumePrefetchResponse, createClientNavigationRenderSnapshot, @@ -32,6 +33,7 @@ import { type CachedRscResponse, type ClientNavigationRenderSnapshot, } from "vinext/shims/navigation"; +import { installWindowNext } from "../client/window-next.js"; import { chunksToReadableStream, createProgressiveRscStream, @@ -1349,6 +1351,15 @@ function bootstrapHydration(rscStream: ReadableStream): void { } if (typeof document !== "undefined") { + // Install `window.next` as early as possible so any client component that + // synchronously dereferences it during hydration (or any third-party + // library script tag that loads before the React tree mounts) sees the + // expected shape. Mirrors Next.js's app-bootstrap.ts (line 13) which sets + // `window.next = { version, appDir: true }` before the React runtime + // initializes, and `app-router-instance.ts` (line 510) which assigns + // `router: publicAppRouterInstance` at module load. + installWindowNext({ appDir: true, router: appRouterInstance }); + window.addEventListener("pagehide", () => { isPageUnloading = true; }); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 54f06b558..217c147ef 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -1234,6 +1234,15 @@ export async function navigateClientSide( // useEffect dependency arrays, React.memo bailouts). // --------------------------------------------------------------------------- +/** + * App Router public router instance. Mirrors Next.js's + * `publicAppRouterInstance` from + * `packages/next/src/client/components/app-router-instance.ts`. + * + * Exported so the App Router browser entry can install it on + * `window.next.router` for Next.js parity (see `client/window-next.ts`). + * Internal callers in this file continue to use `_appRouter` for brevity. + */ const _appRouter = { bfcacheId: "0", push(href: string, options?: { scroll?: boolean }): void { @@ -1324,6 +1333,15 @@ const _appRouter = { }, }; +/** + * Public App Router instance, exposed for the browser entry so it can wire + * `window.next.router` to the same singleton returned from `useRouter()`. + * + * Mirrors `publicAppRouterInstance` from Next.js's + * `packages/next/src/client/components/app-router-instance.ts` (line 392). + */ +export const appRouterInstance = _appRouter; + /** * App Router's useRouter — returns push/replace/back/forward/refresh. * Different from Pages Router's useRouter (next/router). diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index e382aaaff..7cc1f4323 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -9,6 +9,7 @@ import { useState, useEffect, useCallback, useMemo, createElement, type ReactEle import { RouterContext } from "./internal/router-context.js"; import type { VinextNextData } from "../client/vinext-next-data.js"; import { isValidModulePath } from "../client/validate-module-path.js"; +import { installWindowNext, type PagesRouterPublicInstance } from "../client/window-next.js"; import { isHashOnlyBrowserUrlChange, toBrowserNavigationHref, @@ -1021,4 +1022,24 @@ const Router = { events: routerEvents, }; +// Expose `window.next.router` for Next.js parity. Pages Router test suites, +// userland scripts, and third-party libraries reach for this global directly +// (e.g. `window.next.router.push(...)`, `window.next.router.events.on(...)`). +// Without this assignment, those callers crash with +// `TypeError: Cannot read properties of undefined (reading 'router')`. +// +// Ported from Next.js: `packages/next/src/client/next.ts` (line 13). We do +// NOT use a live-binding getter like Next.js does because vinext's Router +// singleton is constructed synchronously here, so by the time this module +// finishes loading the value is final. +if (typeof window !== "undefined") { + // Cast: `NextRouter.push`/`replace` are typed with narrow parameters + // (UrlObject | string) while `PagesRouterPublicInstance` accepts unknown + // args. The two are structurally compatible at runtime; TypeScript flags + // the narrowing of contravariant function params, which is benign here + // because callers reading off `window.next.router` are tests/userland + // and treat the surface as opaque. + installWindowNext({ router: Router as unknown as PagesRouterPublicInstance }); +} + export default Router; diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 3b0fb0aa1..8867b0332 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -636,6 +636,188 @@ describe("next/navigation shim", () => { }); }); +// --------------------------------------------------------------------------- +// window.next debug/diagnostic global +// +// Next.js exposes a `window.next` object 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). Pages Router test suites, +// userland code, and third-party libraries reach into it directly — most +// commonly `window.next.router.push(...)` and +// `window.next.router.events.on(...)`. Without this global, the Next.js +// deploy test suite reports ~422 console errors and 30+ runtime failures +// against vinext, with `TypeError: Cannot read properties of undefined +// (reading 'router')` the most cited. +// +// The installer helper lives in packages/vinext/src/client/window-next.ts +// and is invoked from both router bootstraps. The Pages Router shim +// (shims/router.ts) installs it as a top-level side effect, and the App +// Router browser entry (server/app-browser-entry.ts) installs it before +// hydration starts. +// --------------------------------------------------------------------------- +describe("window.next debug global", () => { + it("installWindowNext sets version, router, and appDir fields on window.next", async () => { + const previousWindow = (globalThis as any).window; + const win: any = {}; + (globalThis as any).window = win; + + try { + vi.resetModules(); + const { installWindowNext } = await import("../packages/vinext/src/client/window-next.js"); + + const routerStub = { + push() {}, + replace() {}, + back() {}, + forward() {}, + refresh() {}, + prefetch() {}, + }; + installWindowNext({ version: "test-version", appDir: true, router: routerStub }); + + expect(win.next).toBeDefined(); + expect(win.next.version).toBe("test-version"); + expect(win.next.appDir).toBe(true); + expect(win.next.router).toBe(routerStub); + } finally { + (globalThis as any).window = previousWindow; + vi.resetModules(); + } + }); + + it("installWindowNext merges subsequent calls without clobbering existing fields", async () => { + const previousWindow = (globalThis as any).window; + const win: any = {}; + (globalThis as any).window = win; + + try { + vi.resetModules(); + const { installWindowNext } = await import("../packages/vinext/src/client/window-next.js"); + + const pagesRouter = { kind: "pages" } as any; + const appRouter = { kind: "app" } as any; + + installWindowNext({ version: "v1", router: pagesRouter }); + installWindowNext({ appDir: true, router: appRouter }); + + // Whichever installer ran last (in real-world hybrid setups, App + // Router) wins for `router` — mirrors Next.js's load order where + // app-bootstrap.ts runs after next.ts. + expect(win.next.router).toBe(appRouter); + expect(win.next.appDir).toBe(true); + // Fields not overridden are preserved. + expect(win.next.version).toBe("v1"); + } finally { + (globalThis as any).window = previousWindow; + vi.resetModules(); + } + }); + + it("Pages Router shim installs window.next.router with the expected NextRouter surface", async () => { + // Build a minimal fake window so importing shims/router.ts (which + // touches window at module load to attach popstate) does not crash. + const previousWindow = (globalThis as any).window; + const win: any = { + location: { pathname: "/", search: "", hash: "", href: "http://localhost/" }, + history: { state: null, pushState() {}, replaceState() {} }, + addEventListener() {}, + }; + (globalThis as any).window = win; + + try { + vi.resetModules(); + // Side-effecting import: installs window.next.router at module load. + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + expect(win.next).toBeDefined(); + expect(win.next.router).toBe(Router); + + // Ported from Next.js: NextRouter type in + // packages/next/src/shared/lib/router/router.ts (line 372). + const router = win.next.router; + expect(typeof router.push).toBe("function"); + expect(typeof router.replace).toBe("function"); + expect(typeof router.back).toBe("function"); + expect(typeof router.reload).toBe("function"); + expect(typeof router.prefetch).toBe("function"); + expect(typeof router.beforePopState).toBe("function"); + expect(router.events).toBeDefined(); + expect(typeof router.events.on).toBe("function"); + expect(typeof router.events.off).toBe("function"); + expect(typeof router.events.emit).toBe("function"); + } finally { + (globalThis as any).window = previousWindow; + vi.resetModules(); + } + }); + + // Ported from Next.js: tests that rely on `window.next.router.events.on(...)` + // — e.g. test/development/pages-dir/client-navigation/index.test.ts:457 + // (`window.next.router.events.on('routeChangeError', ...)`). + it("window.next.router.events forwards Pages Router events", async () => { + const previousWindow = (globalThis as any).window; + const win: any = { + location: { pathname: "/", search: "", hash: "", href: "http://localhost/" }, + history: { state: null, pushState() {}, replaceState() {} }, + addEventListener() {}, + }; + (globalThis as any).window = win; + + try { + vi.resetModules(); + await import("../packages/vinext/src/shims/router.js"); + + const fired: unknown[] = []; + win.next.router.events.on("routeChangeStart", (url: unknown) => { + fired.push(url); + }); + win.next.router.events.emit("routeChangeStart", "/next-page"); + expect(fired).toEqual(["/next-page"]); + } finally { + (globalThis as any).window = previousWindow; + vi.resetModules(); + } + }); + + it("appRouterInstance exported from the navigation shim has the public router surface", async () => { + vi.resetModules(); + const { appRouterInstance } = await import("../packages/vinext/src/shims/navigation.js"); + + // Ported from Next.js: publicAppRouterInstance shape in + // packages/next/src/client/components/app-router-instance.ts (line 392). + expect(typeof appRouterInstance.push).toBe("function"); + expect(typeof appRouterInstance.replace).toBe("function"); + expect(typeof appRouterInstance.back).toBe("function"); + expect(typeof appRouterInstance.forward).toBe("function"); + expect(typeof appRouterInstance.refresh).toBe("function"); + expect(typeof appRouterInstance.prefetch).toBe("function"); + expect(appRouterInstance.bfcacheId).toBe("0"); + }); + + it("installWindowNext is a no-op on the server (typeof window === 'undefined')", async () => { + const previousWindow = (globalThis as any).window; + delete (globalThis as any).window; + + try { + vi.resetModules(); + const { installWindowNext } = await import("../packages/vinext/src/client/window-next.js"); + + // Does not throw and does not attempt to create a global. We cannot + // observe a non-existent window, so the assertion is structural: the + // call returns without error and there is no global to inspect. + expect(() => installWindowNext({ version: "x", appDir: true })).not.toThrow(); + } finally { + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + vi.resetModules(); + } + }); +}); + describe("next/headers shim", () => { it("exports cookies, headers, draftMode", async () => { const mod = await import("../packages/vinext/src/shims/headers.js");