Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions packages/vinext/src/server/app-page-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
69 changes: 69 additions & 0 deletions tests/app-page-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>,
_navContext: unknown,
_fontData: unknown,
options?: {
sideStream?: ReadableStream<Uint8Array>;
capturedRscDataRef?: { value: Promise<ArrayBuffer> | 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(["<html>page</html>"]),
metadataReady: Promise.resolve(),
capturedRscData,
};
},
}),
peekRequestCacheLife() {
return requestCacheLife;
},
renderToReadableStream() {
let sent = false;
return new ReadableStream<Uint8Array>({
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("<html>page</html>");
});

it("preserves prerender cache metadata for the manifest writer after shaping headers", async () => {
const common = createCommonOptions();
let requestCacheLife: { revalidate: number; expire: number } | null = null;
Expand Down
9 changes: 7 additions & 2 deletions tests/app-ssr-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading