Skip to content
Draft
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
42 changes: 28 additions & 14 deletions packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,22 +899,36 @@ export async function renderPage(request, url, manifest) {
}
});

// Cache the rendered HTML for ISR (needs the full string — re-render synchronously)
// Cache the rendered HTML for ISR.
// Tee the composite stream so one branch is returned to the caller
// (streamed to the client) while the other is collected in the
// background for the ISR cache. No second render is required.
var responseStream = compositeStream;
if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) {
// Tee the stream so we can cache and respond simultaneously would be ideal,
// but ISR responses are rare on first hit. Re-render to get complete HTML for cache.
var isrElement;
if (AppComponent) {
isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps });
} else {
isrElement = React.createElement(PageComponent, pageProps);
}
isrElement = wrapWithRouterContext(isrElement);
var isrHtml = await renderToStringAsync(isrElement);
var fullHtml = shellPrefix + isrHtml + shellSuffix;
var [responseStream, cacheStream] = compositeStream.tee();
var isrPathname = url.split("?")[0];
var isrCacheKey = "pages:" + (isrPathname === "/" ? "/" : isrPathname.replace(/\\/$/, ""));
await isrSet(isrCacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds);
// Collect the cache branch and write to ISR after the stream drains.
// This runs in the background and does NOT block the response.
(async () => {
var decoder = new TextDecoder();
var cacheChunks = [];
var cacheReader = cacheStream.getReader();
try {
for (;;) {
var chunk = await cacheReader.read();
if (chunk.done) break;
cacheChunks.push(decoder.decode(chunk.value, { stream: true }));
}
cacheChunks.push(decoder.decode());
} finally {
cacheReader.releaseLock();
}
var fullHtml = cacheChunks.join("");
await isrSet(isrCacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds);
})().catch((err) => {
console.error("[vinext] ISR cache write failed:", err);
});
}

// Merge headers/status/cookies set by getServerSideProps on the res object.
Expand Down Expand Up @@ -943,7 +957,7 @@ export async function renderPage(request, url, manifest) {
if (_fontLinkHeader) {
responseHeaders.set("Link", _fontLinkHeader);
}
return new Response(compositeStream, { status: finalStatus, headers: responseHeaders });
return new Response(responseStream, { status: finalStatus, headers: responseHeaders });
} catch (e) {
console.error("[vinext] SSR error:", e);
return new Response("Internal Server Error", { status: 500 });
Expand Down
89 changes: 69 additions & 20 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ const STREAM_BODY_MARKER = "<!--VINEXT_STREAM_BODY-->";
* stream completes (the data is known before rendering starts, but
* deferring them reduces TTFB and lets the browser start parsing the
* shell sooner).
*
* When `collectHtml` is true, the body stream is teed so one branch is
* piped to the client while the other is collected in memory. The
* function returns the full HTML string (prefix + body + suffix) so the
* caller can use it for ISR caching without a second render.
*/
async function streamPageToResponse(
res: ServerResponse,
Expand All @@ -72,8 +77,10 @@ async function streamPageToResponse(
extraHeaders?: Record<string, string | string[]>;
/** Called after renderToReadableStream resolves (shell ready) to collect head HTML */
getHeadHTML: () => string;
/** When true, tee the body stream and return the full collected HTML for ISR caching. */
collectHtml?: boolean;
},
): Promise<void> {
): Promise<string | undefined> {
const {
url,
server,
Expand All @@ -83,6 +90,7 @@ async function streamPageToResponse(
statusCode = 200,
extraHeaders,
getHeadHTML,
collectHtml = false,
} = options;

// Start the React body stream FIRST — the promise resolves when the
Expand Down Expand Up @@ -155,6 +163,53 @@ async function streamPageToResponse(
// Write the document prefix (head, opening body)
res.write(prefix);

if (collectHtml) {
// Tee the body stream: one branch goes to the client, the other is
// collected for ISR caching. Both consumers see identical bytes, so
// there is no second render — this eliminates the double-render cost.
const [clientBranch, cacheBranch] = bodyStream.tee();
const decoder = new TextDecoder();
const cacheChunks: string[] = [];

// Collect the cache branch concurrently while the client branch is
// being piped to the response below. We don't await here so that
// collection runs in parallel with the client write loop.
const collectPromise = (async () => {
const cacheReader = cacheBranch.getReader();
try {
for (;;) {
const { done, value } = await cacheReader.read();
if (done) break;
cacheChunks.push(decoder.decode(value, { stream: true }));
}
// Flush any remaining bytes in the decoder
cacheChunks.push(decoder.decode());
} finally {
cacheReader.releaseLock();
}
})();

// Pipe the client branch to the response
const clientReader = clientBranch.getReader();
try {
for (;;) {
const { done, value } = await clientReader.read();
if (done) break;
res.write(value);
}
} finally {
clientReader.releaseLock();
}

// Wait for the cache collector to finish (it should already be done
// since the client branch is exhausted)
await collectPromise;

res.end(suffix);

return prefix + cacheChunks.join("") + suffix;
}

// Pipe the React body stream through (Suspense content streams progressively)
const reader = bodyStream.getReader();
try {
Expand Down Expand Up @@ -860,7 +915,11 @@ hydrate();
// Stream the page using progressive SSR.
// The shell (layouts, non-suspended content) arrives immediately.
// Suspense content streams in as it resolves.
await streamPageToResponse(res, element, {
// When ISR is active, ask streamPageToResponse to tee the body
// stream so we get the full HTML for caching without a second render.
const needsIsrCache =
isrRevalidateSeconds !== null && isrRevalidateSeconds > 0;
const collectedHtml = await streamPageToResponse(res, element, {
url,
server,
fontHeadHTML,
Expand All @@ -875,6 +934,7 @@ hydrate();
typeof headShim.getSSRHeadHTML === "function"
? headShim.getSSRHeadHTML()
: "",
collectHtml: needsIsrCache,
});
_renderEnd = now();

Expand All @@ -883,28 +943,17 @@ hydrate();
routerShim.setSSRContext(null);
}

// If ISR is enabled, we need the full HTML for caching.
// For ISR, re-render synchronously to get the complete HTML string.
// This runs after the stream is already sent, so it doesn't affect TTFB.
if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) {
let isrElement = AppComponent
? createElement(AppComponent, {
Component: pageModule.default,
pageProps,
})
: createElement(pageModule.default, pageProps);
if (wrapWithRouterContext) {
isrElement = wrapWithRouterContext(isrElement);
}
const isrBodyHtml = await renderToStringAsync(isrElement);
const isrHtml = `<!DOCTYPE html><html><head></head><body><div id="__next">${isrBodyHtml}</div>${allScripts}</body></html>`;
// If ISR is enabled, store the collected HTML in the cache.
// The body stream was teed during the first render so there is
// no second renderToStringAsync — collectedHtml is already built.
if (needsIsrCache && collectedHtml !== undefined) {
const cacheKey = isrCacheKey("pages", url.split("?")[0]);
await isrSet(
cacheKey,
buildPagesCacheValue(isrHtml, pageProps),
isrRevalidateSeconds,
buildPagesCacheValue(collectedHtml, pageProps),
isrRevalidateSeconds!,
);
setRevalidateDuration(cacheKey, isrRevalidateSeconds);
setRevalidateDuration(cacheKey, isrRevalidateSeconds!);
}
} catch (e) {
// Let Vite fix the stack trace for better dev experience
Expand Down
42 changes: 28 additions & 14 deletions tests/__snapshots__/entry-templates.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -15578,22 +15578,36 @@ export async function renderPage(request, url, manifest) {
}
});

// Cache the rendered HTML for ISR (needs the full string — re-render synchronously)
// Cache the rendered HTML for ISR.
// Tee the composite stream so one branch is returned to the caller
// (streamed to the client) while the other is collected in the
// background for the ISR cache. No second render is required.
var responseStream = compositeStream;
if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) {
// Tee the stream so we can cache and respond simultaneously would be ideal,
// but ISR responses are rare on first hit. Re-render to get complete HTML for cache.
var isrElement;
if (AppComponent) {
isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps });
} else {
isrElement = React.createElement(PageComponent, pageProps);
}
isrElement = wrapWithRouterContext(isrElement);
var isrHtml = await renderToStringAsync(isrElement);
var fullHtml = shellPrefix + isrHtml + shellSuffix;
var [responseStream, cacheStream] = compositeStream.tee();
var isrPathname = url.split("?")[0];
var isrCacheKey = "pages:" + (isrPathname === "/" ? "/" : isrPathname.replace(/\\/$/, ""));
await isrSet(isrCacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds);
// Collect the cache branch and write to ISR after the stream drains.
// This runs in the background and does NOT block the response.
(async () => {
var decoder = new TextDecoder();
var cacheChunks = [];
var cacheReader = cacheStream.getReader();
try {
for (;;) {
var chunk = await cacheReader.read();
if (chunk.done) break;
cacheChunks.push(decoder.decode(chunk.value, { stream: true }));
}
cacheChunks.push(decoder.decode());
} finally {
cacheReader.releaseLock();
}
var fullHtml = cacheChunks.join("");
await isrSet(isrCacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds);
})().catch((err) => {
console.error("[vinext] ISR cache write failed:", err);
});
}

// Merge headers/status/cookies set by getServerSideProps on the res object.
Expand Down Expand Up @@ -15622,7 +15636,7 @@ export async function renderPage(request, url, manifest) {
if (_fontLinkHeader) {
responseHeaders.set("Link", _fontLinkHeader);
}
return new Response(compositeStream, { status: finalStatus, headers: responseHeaders });
return new Response(responseStream, { status: finalStatus, headers: responseHeaders });
} catch (e) {
console.error("[vinext] SSR error:", e);
return new Response("Internal Server Error", { status: 500 });
Expand Down
Loading