From 81cc68d4e874277a58fd054e56145f5aec13682b Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 23:03:54 +1000 Subject: [PATCH] fix(app-router): keep refresh transitions pending router.refresh() currently skips the pending browser router state even when it is dispatched from a React transition. That lets useTransition return to idle while the refresh RSC request is still in flight. The refresh path should follow the same App Router invariant as other programmatic navigations: publish a deferred router-state promise before the request and resolve it when the refreshed tree commits. Allow programmatic refresh navigations to create pending router state, and cover the behaviour with a browser regression that observes isPending during a delayed same-route refresh. --- .../vinext/src/server/app-browser-entry.ts | 2 +- .../nextjs-compat/router-push-pending.spec.ts | 30 +++++++++++++++++-- .../router-refresh-pending/page.tsx | 19 ++++++++++++ .../refresh-pending-client.tsx | 25 ++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/router-refresh-pending/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/router-refresh-pending/refresh-pending-client.tsx diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1c40bcd21..34ba34822 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -1036,7 +1036,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { let redirectCount = redirectDepth; try { - const shouldUsePendingRouterState = programmaticTransition && navigationKind !== "refresh"; + const shouldUsePendingRouterState = programmaticTransition; if (shouldUsePendingRouterState && hasBrowserRouterState()) { pendingRouterState = beginPendingBrowserRouterState(); } else { diff --git a/tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts b/tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts index 2d3e010e4..d9634d920 100644 --- a/tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts @@ -1,11 +1,13 @@ /** - * Next.js Compat E2E: router push pending state + * Next.js Compat E2E: router pending state * * Next.js references: * - https://github.com/vercel/next.js/blob/canary/test/e2e/use-link-status/index.test.ts * - https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts * - https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts#L482 * (redirect-with-loading: verifies redirect only triggers once and does not flicker) + * - https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/app-router-instance.ts + * - https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/use-action-queue.ts * * The contract we care about here is that a programmatic App Router navigation * started inside useTransition should flip isPending immediately and keep it @@ -17,7 +19,7 @@ import { waitForAppRouterHydration } from "../../helpers"; const BASE = "http://localhost:4174"; -test.describe("Next.js compat: router.push pending state (browser)", () => { +test.describe("Next.js compat: router pending state (browser)", () => { test("same-route search param push keeps useTransition pending until commit", async ({ page, }) => { @@ -136,4 +138,28 @@ test.describe("Next.js compat: router.push pending state (browser)", () => { // Final state: URL is at destination expect(page.url()).toContain("/nextjs-compat/router-push-pending-destination"); }); + + test("router.refresh keeps useTransition pending until the refreshed route commits", async ({ + page, + }) => { + await page.goto(`${BASE}/nextjs-compat/router-refresh-pending`); + await waitForAppRouterHydration(page); + + await expect(page.locator("#refresh-pending-state")).toHaveText("idle"); + const initialStamp = await page.locator("#refresh-server-stamp").textContent(); + expect(initialStamp).toBeTruthy(); + + const clickPromise = page.click("#refresh-current-route", { noWaitAfter: true }); + + await expect(page.locator("#refresh-pending-state")).toHaveText("pending", { + timeout: 1_000, + }); + await clickPromise; + await expect(page.locator("#refresh-server-stamp")).not.toHaveText(initialStamp ?? "", { + timeout: 10_000, + }); + await expect(page.locator("#refresh-pending-state")).toHaveText("idle", { + timeout: 10_000, + }); + }); }); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/router-refresh-pending/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/router-refresh-pending/page.tsx new file mode 100644 index 000000000..36332acc4 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/router-refresh-pending/page.tsx @@ -0,0 +1,19 @@ +import { RefreshPendingClient } from "./refresh-pending-client"; + +async function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export default async function RouterRefreshPendingPage() { + // Keep the refresh RSC request in flight long enough for the browser test to + // observe the transition's pending state before the refreshed tree commits. + await delay(1_000); + + return ( +
+

Router refresh pending

+ +

server stamp: {Date.now()}

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/router-refresh-pending/refresh-pending-client.tsx b/tests/fixtures/app-basic/app/nextjs-compat/router-refresh-pending/refresh-pending-client.tsx new file mode 100644 index 000000000..6f43add5b --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/router-refresh-pending/refresh-pending-client.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; + +export function RefreshPendingClient() { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + return ( +
+

{isPending ? "pending" : "idle"}

+ +
+ ); +}