diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 793cf719d1..27f5c392fa 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -1027,16 +1027,17 @@ export async function renderAppPageLifecycle( // Runs after the RSC embed drains, so this peek observes the // completed render's minimum. Peek, not consume — the cache-write // closure owns the consuming read. - // Prerendering drains the captured RSC stream and consumes the - // completed request cache life before the HTML stream reaches this - // done-script callback. Reuse that captured value here; peeking the - // now-cleared request state would omit the client stale claim from - // the prerendered HTML even though the seeded cache entry retains it. + // During prerendering the done-script callback can run immediately + // after the RSC capture drains, before the outer lifecycle performs + // its consuming cacheLife read. Use the live non-destructive peek in + // that window, then reuse the captured value after the outer read; + // peeking only after consumption would omit the client stale claim + // even though the seeded cache entry retains it. // Runtime renders have not consumed the state yet and still use the // non-destructive peek so the cache-write closure remains its owner. const requestCacheLife = options.isPrerender === true - ? requestCacheLifeForPrerender + ? (requestCacheLifeForPrerender ?? options.peekRequestCacheLife?.()) : options.peekRequestCacheLife?.(); const staleTimeSeconds = resolveClientStaleTimeSeconds(requestCacheLife); return { diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index 0b0f40a7f5..cada1789f1 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -1264,6 +1264,75 @@ describe("app page render lifecycle", () => { expect(common.isrSet).not.toHaveBeenCalled(); }); + it("preserves prerender cacheLife metadata when the HTML footer finalizes before the outer read", async () => { + const common = createCommonOptions(); + let requestCacheLife: { stale: number; revalidate: number; expire: number } | null = null; + let initialNavigationCacheMetadata: InitialNavigationCacheMetadata | undefined; + + const response = await renderAppPageLifecycle({ + ...common.options, + getRequestCacheLife() { + const value = requestCacheLife; + requestCacheLife = null; + return value; + }, + isPrerender: true, + isProduction: false, + loadSsrHandler: async () => ({ + async handleSsr( + rscStream: ReadableStream, + _navContext: unknown, + _fontData: unknown, + options?: { + sideStream?: ReadableStream; + capturedRscDataRef?: { value: Promise | null }; + getInitialNavigationCacheMetadata?: () => InitialNavigationCacheMetadata; + }, + ) { + const stream = options?.sideStream ?? rscStream; + const capturedRscData = new Response(stream).arrayBuffer(); + if (options?.capturedRscDataRef) { + options.capturedRscDataRef.value = capturedRscData; + } + + // The real RSC embed transform finalizes its navigation footer as + // soon as this capture drains. That can happen before handleSsr() + // returns and before renderAppPageLifecycle performs its consuming + // cacheLife read. + await capturedRscData; + initialNavigationCacheMetadata = options?.getInitialNavigationCacheMetadata?.(); + + return { + htmlStream: createStream(["page"]), + metadataReady: Promise.resolve(), + capturedRscData, + }; + }, + }), + peekRequestCacheLife() { + return requestCacheLife; + }, + renderToReadableStream() { + let sent = false; + return new ReadableStream({ + pull(controller) { + if (sent) { + controller.close(); + return; + } + requestCacheLife = { stale: 30, revalidate: 1, expire: 60 }; + controller.enqueue(new TextEncoder().encode("flight-data")); + sent = true; + }, + }); + }, + revalidateSeconds: null, + }); + + expect(initialNavigationCacheMetadata).toEqual({ kind: "static", staleTimeSeconds: 30 }); + await expect(response.text()).resolves.toBe("page"); + }); + it("preserves prerender cache metadata for the manifest writer after shaping headers", async () => { const common = createCommonOptions(); let requestCacheLife: { revalidate: number; expire: number } | null = null; diff --git a/tests/app-ssr-stream.test.ts b/tests/app-ssr-stream.test.ts index 4b78696818..48abe9d703 100644 --- a/tests/app-ssr-stream.test.ts +++ b/tests/app-ssr-stream.test.ts @@ -430,8 +430,13 @@ async function readSingleTransformChunk( await writer.write(new TextEncoder().encode(chunk)); const result = await reader.read(); - await writer.close(); - await reader.cancel(); + const close = writer.close(); + while (!(await reader.read()).done) { + // Drain the transform so its asynchronous flush can finish before the + // helper releases the reader. Cancelling here races flush() under Node + // 24.20 and can close the controller before it emits the document suffix. + } + await close; if (result.done) { throw new Error("Expected transform to emit a chunk");