diff --git a/packages/vinext/src/client/window-next.ts b/packages/vinext/src/client/window-next.ts index 44b55e1d7..8db060b19 100644 --- a/packages/vinext/src/client/window-next.ts +++ b/packages/vinext/src/client/window-next.ts @@ -64,10 +64,18 @@ type AppRouterPublicInstance = { * 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). + * + * `push` and `replace` return `Promise` to match Next.js's + * documented contract (`packages/next/src/shared/lib/router/router.ts:1025-1068` + * — push/replace delegate to `change()` which returns `Promise`, + * resolving to `true` on a successful navigation and `false` when blocked + * — e.g. hard-navigation fallback). The Next.js deploy test suite reads + * the resolved value via `browser.eval('await window.next.router.push(...)')` + * to assert success. */ export type PagesRouterPublicInstance = { - push: (...args: unknown[]) => unknown; - replace: (...args: unknown[]) => unknown; + push: (...args: unknown[]) => Promise; + replace: (...args: unknown[]) => Promise; back: () => void; reload: () => void; prefetch: (...args: unknown[]) => unknown; diff --git a/tests/e2e/app-router/build-id-navigation.spec.ts b/tests/e2e/app-router/build-id-navigation.spec.ts index 58356a3a4..7512b2632 100644 --- a/tests/e2e/app-router/build-id-navigation.spec.ts +++ b/tests/e2e/app-router/build-id-navigation.spec.ts @@ -11,7 +11,10 @@ async function pushAppRoute(page: Page, pathname: string): Promise { if (!router) { throw new Error("window.next.router is not installed"); } - router.push(target); + // App Router push returns void; Pages Router push returns Promise. + // The union surface flags this as a possibly-floating promise — we don't + // need the resolution here so explicitly void it. + void router.push(target); }, pathname); } @@ -99,7 +102,10 @@ test.describe("App Router RSC compatibility navigation", () => { if (!router) { throw new Error("window.next.router is not installed"); } - router.push("/"); + // App Router push returns void; Pages Router push returns Promise. + // The union surface flags this as a possibly-floating promise — we don't + // need the resolution here so explicitly void it. + void router.push("/"); }, VISITED_CACHE_MARKER); await expect(page.locator("h1")).toHaveText("Welcome to App Router"); await waitForLastRscNavigation(page); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 1c5f0706b..145bf6b41 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -1501,6 +1501,75 @@ describe("window.next debug global", () => { } }); + // Issue #1467 — `window.next.router.push(...)` must resolve to a boolean + // (true on success, false if blocked), matching Next.js's contract in + // `packages/next/src/shared/lib/router/router.ts:1025-1048` where push/replace + // delegate to `change()` which returns `Promise`. The Next.js deploy + // test suite (prerender, use-router-with-rewrites, etc.) reads the resolved + // value via `browser.eval('await window.next.router.push("/foo")')` and + // asserts truthiness; resolving to `undefined` is observable as a regression + // even when the navigation otherwise succeeds. + it("window.next.router.push resolves to true on shallow success", async () => { + const previousWindow = (globalThis as any).window; + const win: any = { + location: { + pathname: "/", + search: "", + hash: "", + href: "http://localhost/", + origin: "http://localhost", + }, + history: { state: null, pushState() {}, replaceState() {} }, + addEventListener() {}, + dispatchEvent() {}, + scrollTo() {}, + }; + (globalThis as any).window = win; + + try { + vi.resetModules(); + await import("../packages/vinext/src/shims/router.js"); + + // Shallow push avoids the HTML fetch + render path so this unit test + // does not need a navigateClient stub. The boolean-return contract + // applies equally to shallow and non-shallow navigations. + const result = await win.next.router.push("/foo", undefined, { shallow: true }); + expect(result).toBe(true); + } finally { + (globalThis as any).window = previousWindow; + vi.resetModules(); + } + }); + + it("window.next.router.replace resolves to true on shallow success", async () => { + const previousWindow = (globalThis as any).window; + const win: any = { + location: { + pathname: "/", + search: "", + hash: "", + href: "http://localhost/", + origin: "http://localhost", + }, + history: { state: null, pushState() {}, replaceState() {} }, + addEventListener() {}, + dispatchEvent() {}, + scrollTo() {}, + }; + (globalThis as any).window = win; + + try { + vi.resetModules(); + await import("../packages/vinext/src/shims/router.js"); + + const result = await win.next.router.replace("/foo", undefined, { shallow: true }); + expect(result).toBe(true); + } 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");