From 4ee8920a8f7bd9f6a467259b56af9da20e9cf077 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sat, 15 Aug 2026 01:59:40 +1000 Subject: [PATCH 1/2] fix(isr): ignore interception context on HTML renders --- packages/vinext/src/server/app-rsc-handler.ts | 2 +- tests/app-rsc-handler.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 4dcea3346..63bd5125a 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -1535,7 +1535,7 @@ async function handleAppRscRequest( actionError: normalizedProgressiveActionError, actionFailed, handlerStart, - interceptionContext: interceptionContextHeader, + interceptionContext: isRscRequest ? interceptionContextHeader : null, interceptionPathname: cleanPathnameIsRequestPathname ? requestCleanPathname : cleanPathname, isProgressiveActionRender, isRscRequest, diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index ecd8ab915..9103635c5 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -664,6 +664,25 @@ describe("createAppRscHandler", () => { ); }); + // Next.js renders the full page rather than an intercepted tree on hard refresh: + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/dynamic-interception-route-revalidate/dynamic-interception-route-revalidate.test.ts + it("ignores client-supplied interception context on HTML page dispatch", async () => { + const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 })); + const handler = createHandler({ configHeaders: [], dispatchMatchedPage }); + + const response = await handler( + new Request("https://example.test/docs/about", { + headers: { "X-Vinext-Interception-Context": "/feed" }, + }), + null, + ); + + expect(response.status).toBe(200); + expect(dispatchMatchedPage).toHaveBeenCalledWith( + expect.objectContaining({ interceptionContext: null, isRscRequest: false }), + ); + }); + // Interception renders the source route's tree, so that route must clear the // same middleware boundary a direct request to it would. Next.js never renders // the source for this request (its rewrite targets the intercepting route and From 070bfb0998f767e351059ec8285d98bf38c87667 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sat, 15 Aug 2026 03:46:03 +1000 Subject: [PATCH 2/2] test(isr): cover interception cache writes --- tests/app-page-dispatch.test.ts | 90 +++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index 61a39628b..b7e348637 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -794,6 +794,49 @@ describe("app page dispatch", () => { expect(cachePolicy.cacheControl.expire).toBeUndefined(); }); + it("writes HTML-captured RSC data under the plain key when interception context is absent", async () => { + const isrSet = vi.fn(async () => {}); + const waitUntilPromises: Promise[] = []; + const executionContext = { + waitUntil(promise) { + waitUntilPromises.push(promise); + }, + } satisfies ExecutionContextLike; + const { options } = createDispatchOptions({ + cleanPathname: "/photos/123", + interceptionContext: null, + isProduction: true, + isrRscKey(pathname, mountedSlotsHeader, _renderMode, interceptionContext) { + return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}:${interceptionContext ?? "none"}`; + }, + isrSet, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { + if (captureOptions?.capturedRscDataRef) { + captureOptions.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("direct-flight").buffer, + ); + } + void captureOptions?.sideStream?.cancel().catch(() => {}); + return createStream(["direct"]); + }, + }), + revalidateSeconds: 60, + }); + + const response = await runWithExecutionContext(executionContext, () => + dispatchAppPage(options), + ); + await response.text(); + await Promise.all(waitUntilPromises.splice(0)); + + const writtenKeys = isrSet.mock.calls.map(([key]) => key); + expect(writtenKeys).toHaveLength(2); + expect(writtenKeys).toEqual( + expect.arrayContaining(["html:/photos/123", "rsc:/photos/123:none:none"]), + ); + }); + it("does not reuse queryless HTML when the page reads searchParams", async () => { let pageExecutions = 0; async function Page(props: Record): Promise { @@ -2806,6 +2849,53 @@ describe("app page dispatch", () => { expect(afterRan).toBe(true); }); + it("regenerates stale HTML-captured RSC data under the plain key without interception context", async () => { + let scheduledRender: unknown = null; + const isrSet = vi.fn(async () => {}); + const { options } = createDispatchOptions({ + cleanPathname: "/photos/123", + interceptionContext: null, + isProduction: true, + isrGet: vi.fn(async () => + buildISRCacheEntry(buildCachedAppPageValue("stale direct"), true), + ), + isrRscKey(pathname, mountedSlotsHeader, _renderMode, interceptionContext) { + return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}:${interceptionContext ?? "none"}`; + }, + isrSet, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { + if (captureOptions?.capturedRscDataRef) { + captureOptions.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("regenerated-direct-flight").buffer, + ); + } + void captureOptions?.sideStream?.cancel().catch(() => {}); + return createStream(["regenerated direct"]); + }, + }), + revalidateSeconds: 60, + scheduleBackgroundRegeneration(_key, renderFn) { + scheduledRender = renderFn; + }, + }); + + const response = await dispatchAppPage(options); + await expect(response.text()).resolves.toBe("stale direct"); + expect(typeof scheduledRender).toBe("function"); + if (typeof scheduledRender !== "function") { + throw new Error("expected stale HTML response to schedule regeneration"); + } + + await scheduledRender(); + + const writtenKeys = isrSet.mock.calls.map(([key]) => key); + expect(writtenKeys).toHaveLength(2); + expect(writtenKeys).toEqual( + expect.arrayContaining(["html:/photos/123", "rsc:/photos/123:none:none"]), + ); + }); + it.each(["page", "metadata"] as const)( "records searchParams access when stale regeneration reads them in %s", async (reader) => {