diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1c40bcd21..80c12a8ca 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -75,6 +75,7 @@ import { type AppRouterState, type OperationLane, } from "./app-browser-state.js"; +import { createPopstateRestoreHandler } from "./app-browser-popstate.js"; import { DevRecoveryBoundary, RedirectBoundary } from "vinext/shims/error-boundary"; import { AppRouterContext } from "vinext/shims/internal/app-router-context"; import { ElementsContext, Slot } from "vinext/shims/slot"; @@ -1329,19 +1330,26 @@ function bootstrapHydration(rscStream: ReadableStream): void { // Pages Router scroll restoration is handled in shims/navigation.ts:1289 with // microtask-based deferral for compatibility with non-RSC navigation. // See: https://github.com/vercel/next.js/discussions/41934#discussioncomment-4602607 - window.addEventListener("popstate", (event) => { - notifyAppRouterTransitionStart(window.location.href, "traverse"); - const pendingNavigation = - window.__VINEXT_RSC_NAVIGATE__?.(window.location.href, 0, "traverse") ?? Promise.resolve(); - window.__VINEXT_RSC_PENDING__ = pendingNavigation; - void pendingNavigation.finally(() => { - restorePopstateScrollPosition(event.state); - if (window.__VINEXT_RSC_PENDING__ === pendingNavigation) { - window.__VINEXT_RSC_PENDING__ = null; - } - }); + const handlePopstate = createPopstateRestoreHandler({ + getActiveNavigationId: browserNavigationController.getActiveNavigationId.bind( + browserNavigationController, + ), + getPendingNavigation: () => window.__VINEXT_RSC_PENDING__, + getNavigate: () => window.__VINEXT_RSC_NAVIGATE__, + isCurrentNavigation: browserNavigationController.isCurrentNavigation.bind( + browserNavigationController, + ), + notifyAppRouterTransitionStart: (href) => { + notifyAppRouterTransitionStart(href, "traverse"); + }, + restorePopstateScrollPosition, + setPendingNavigation: (pendingNavigation) => { + window.__VINEXT_RSC_PENDING__ = pendingNavigation; + }, }); + window.addEventListener("popstate", handlePopstate); + if (import.meta.hot) { const handleRscUpdate = async (): Promise => { try { diff --git a/packages/vinext/src/server/app-browser-popstate.ts b/packages/vinext/src/server/app-browser-popstate.ts new file mode 100644 index 000000000..d0751c043 --- /dev/null +++ b/packages/vinext/src/server/app-browser-popstate.ts @@ -0,0 +1,39 @@ +type RestoreScrollPosition = (state: unknown) => void; +type NavigateRsc = ( + href: string, + redirectDepth?: number, + navigationKind?: "navigate" | "traverse" | "refresh", +) => Promise; + +type BrowserPopstateRestoreDeps = { + getActiveNavigationId: () => number; + getPendingNavigation: () => Promise | null | undefined; + getNavigate: () => NavigateRsc | undefined; + isCurrentNavigation: (navId: number) => boolean; + notifyAppRouterTransitionStart: (href: string) => void; + restorePopstateScrollPosition: RestoreScrollPosition; + setPendingNavigation: (pendingNavigation: Promise | null) => void; +}; + +export function createPopstateRestoreHandler( + deps: BrowserPopstateRestoreDeps, +): (event: PopStateEvent) => void { + return (event) => { + deps.notifyAppRouterTransitionStart(window.location.href); + const navigate = deps.getNavigate(); + const pendingNavigation = navigate?.(window.location.href, 0, "traverse") ?? Promise.resolve(); + const popstateNavId = deps.getActiveNavigationId(); + + deps.setPendingNavigation(pendingNavigation); + + void pendingNavigation.finally(() => { + if (deps.isCurrentNavigation(popstateNavId)) { + deps.restorePopstateScrollPosition(event.state); + } + + if (deps.getPendingNavigation() === pendingNavigation) { + deps.setPendingNavigation(null); + } + }); + }; +} diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 6366446ee..8045f32b5 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -15,6 +15,7 @@ import { hydrateRootInTransition, } from "../packages/vinext/src/server/app-browser-hydration.js"; import { createAppBrowserNavigationController } from "../packages/vinext/src/server/app-browser-navigation-controller.js"; +import { createPopstateRestoreHandler } from "../packages/vinext/src/server/app-browser-popstate.js"; import { VINEXT_RSC_COMPATIBILITY_ID_HEADER, resolveRscCompatibilityNavigationDecision, @@ -251,6 +252,16 @@ function stubWindow(href: string) { return { assign, replace, storage }; } +function createDeferred(): { resolve: () => void; promise: Promise } { + let resolve: () => void = () => { + throw new Error("Promise was not initialized"); + }; + const promise = new Promise((resolveInner) => { + resolve = resolveInner; + }); + return { promise, resolve }; +} + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -2993,6 +3004,99 @@ describe("app browser entry previousNextUrl helpers", () => { }); }); +describe("createPopstateRestoreHandler", () => { + it("restores scroll only after the latest popstate navigation commits", async () => { + const restoreCalls: unknown[] = []; + const firstNavigation = createDeferred(); + const secondNavigation = createDeferred(); + let popstateCalls = 0; + const popstate = vi.fn(() => { + popstateCalls += 1; + if (popstateCalls === 1) { + return firstNavigation.promise; + } + return secondNavigation.promise; + }); + let activeNavigationId = 0; + + stubWindow("https://example.com/feed"); + window.__VINEXT_RSC_PENDING__ = null; + + const handler = createPopstateRestoreHandler({ + getActiveNavigationId: () => activeNavigationId, + getNavigate: () => { + activeNavigationId += 1; + return () => popstate(); + }, + getPendingNavigation: () => window.__VINEXT_RSC_PENDING__, + isCurrentNavigation: (navId) => navId === activeNavigationId, + notifyAppRouterTransitionStart: () => {}, + restorePopstateScrollPosition: (scrollState) => { + restoreCalls.push(scrollState); + }, + setPendingNavigation: (pendingNavigation) => { + window.__VINEXT_RSC_PENDING__ = pendingNavigation; + }, + }); + + handler({ state: { __vinext_scrollY: 10 } } as PopStateEvent); + handler({ state: { __vinext_scrollY: 20 } } as PopStateEvent); + + expect(window.__VINEXT_RSC_PENDING__).toBe(secondNavigation.promise); + + secondNavigation.resolve(); + await secondNavigation.promise; + await Promise.resolve(); + + expect(restoreCalls).toEqual([{ __vinext_scrollY: 20 }]); + expect(window.__VINEXT_RSC_PENDING__).toBeNull(); + + firstNavigation.resolve(); + await firstNavigation.promise; + await Promise.resolve(); + + expect(restoreCalls).toEqual([{ __vinext_scrollY: 20 }]); + expect(window.__VINEXT_RSC_PENDING__).toBeNull(); + }); + + it("clears __VINEXT_RSC_PENDING__ when a stale popstate navigation settles", async () => { + const restoreCalls: unknown[] = []; + const navigation = createDeferred(); + let activeNavigationId = 1; + + stubWindow("https://example.com/feed"); + window.__VINEXT_RSC_PENDING__ = null; + + const handler = createPopstateRestoreHandler({ + getActiveNavigationId: () => activeNavigationId, + getNavigate: () => { + activeNavigationId = 1; + return () => navigation.promise; + }, + getPendingNavigation: () => window.__VINEXT_RSC_PENDING__, + isCurrentNavigation: (navId) => navId === activeNavigationId, + notifyAppRouterTransitionStart: () => {}, + restorePopstateScrollPosition: (scrollState) => { + restoreCalls.push(scrollState); + }, + setPendingNavigation: (pendingNavigation) => { + window.__VINEXT_RSC_PENDING__ = pendingNavigation; + }, + }); + + handler({ state: { __vinext_scrollY: 10 } } as PopStateEvent); + expect(window.__VINEXT_RSC_PENDING__).toBe(navigation.promise); + + activeNavigationId = 2; + navigation.resolve(); + await navigation.promise; + await Promise.resolve(); + + expect(restoreCalls).toEqual([]); + expect(window.__VINEXT_RSC_PENDING__).toBeNull(); + }); +}); + describe("devOnCaughtError (hydrateRoot dev handler)", () => { it("ignores redirect sentinels handled by RedirectBoundary", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/tests/e2e/app-router/build-id-navigation.spec.ts b/tests/e2e/app-router/build-id-navigation.spec.ts index dd4872701..3916e1b37 100644 --- a/tests/e2e/app-router/build-id-navigation.spec.ts +++ b/tests/e2e/app-router/build-id-navigation.spec.ts @@ -3,6 +3,7 @@ import { waitForAppRouterHydration } from "../helpers"; const BASE = "http://localhost:4174"; const VISITED_CACHE_MARKER = "__VINEXT_VISITED_CACHE_MARKER__"; +const RSC_NAVIGATION_PROMISE_MARKER = "__VINEXT_TEST_RSC_NAVIGATION_PROMISE__"; async function pushAppRoute(page: Page, pathname: string): Promise { await page.evaluate((target) => { @@ -14,6 +15,47 @@ async function pushAppRoute(page: Page, pathname: string): Promise { }, pathname); } +async function captureRscNavigationPromises(page: Page): Promise { + await page.evaluate((marker) => { + const navigate = window.__VINEXT_RSC_NAVIGATE__; + if (typeof navigate !== "function") { + throw new Error("window.__VINEXT_RSC_NAVIGATE__ is not installed"); + } + + const wrappedNavigate: typeof navigate = ( + href, + redirectDepth, + navigationKind, + historyUpdateMode, + previousNextUrlOverride, + programmaticTransition, + ) => { + const pendingNavigation = navigate( + href, + redirectDepth, + navigationKind, + historyUpdateMode, + previousNextUrlOverride, + programmaticTransition, + ); + Reflect.set(window, marker, pendingNavigation); + return pendingNavigation; + }; + + window.__VINEXT_RSC_NAVIGATE__ = wrappedNavigate; + }, RSC_NAVIGATION_PROMISE_MARKER); +} + +async function waitForLastRscNavigation(page: Page): Promise { + await page.waitForFunction( + (marker) => Reflect.get(window, marker), + RSC_NAVIGATION_PROMISE_MARKER, + ); + await page.evaluate(async (marker) => { + await Reflect.get(window, marker); + }, RSC_NAVIGATION_PROMISE_MARKER); +} + test.describe("App Router RSC compatibility navigation", () => { test("replays same-build visited RSC payloads instead of refetching or reloading", async ({ page, @@ -28,9 +70,13 @@ test.describe("App Router RSC compatibility navigation", () => { await page.goto(`${BASE}/`); await waitForAppRouterHydration(page); + await captureRscNavigationPromises(page); await pushAppRoute(page, "/about"); await expect(page.locator("h1")).toHaveText("About"); + // router.push commits visible UI before the RSC navigation promise has + // finished seeding the visited-response cache this test asserts on. + await waitForLastRscNavigation(page); expect(aboutRscRequests).toHaveLength(1); await page.evaluate((marker) => { @@ -42,6 +88,7 @@ test.describe("App Router RSC compatibility navigation", () => { router.push("/"); }, VISITED_CACHE_MARKER); await expect(page.locator("h1")).toHaveText("Welcome to App Router"); + await waitForLastRscNavigation(page); await pushAppRoute(page, "/about"); await expect(page.locator("h1")).toHaveText("About");