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
3 changes: 3 additions & 0 deletions packages/vinext/src/server/app-page-boundary-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ async function renderAppPageBoundaryElementResponse<TModule extends AppPageModul
initialDevServerError?: unknown;
layoutModules: readonly (TModule | null | undefined)[];
navigationParams?: AppPageParams;
mirrorNextFlight?: boolean;
route?: AppPageBoundaryRoute<TModule> | null;
routePattern?: string;
status: number;
Expand Down Expand Up @@ -312,6 +313,7 @@ async function renderAppPageBoundaryElementResponse<TModule extends AppPageModul
searchParams: requestUrl.searchParams,
params: options.navigationParams ?? options.route?.params ?? {},
},
mirrorNextFlight: options.mirrorNextFlight,
rscStream,
scriptNonce: options.scriptNonce,
ssrHandler,
Expand Down Expand Up @@ -410,6 +412,7 @@ export async function renderAppPageHttpAccessFallback<TModule extends AppPageMod
// would expect a root-layout tree path that doesn't exist in the markup.
element,
layoutModules: skipLayoutWrapping ? [] : layoutModules,
mirrorNextFlight: true,
navigationParams: options.matchedParams,
route: skipLayoutWrapping ? null : options.route,
routePattern: options.route?.pattern,
Expand Down
5 changes: 5 additions & 0 deletions packages/vinext/src/server/app-page-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ export type AppPageSsrHandler = {
waitForAllReady?: boolean;
/** Dev-only: original server error to surface in the browser overlay. */
initialDevServerError?: unknown;
/** Mirror inline Flight chunks into Next.js's `self.__next_f` transport. */
mirrorNextFlight?: boolean;
/** When true, an SSR-phase-only shell render error resolves to the
* default `__next_error__` error-document shell (with the original
* flight payload and bootstrap) instead of rejecting. See handleSsr. */
Expand Down Expand Up @@ -185,6 +187,8 @@ type RenderAppPageHtmlStreamOptions = {
waitForAllReady?: boolean;
/** Dev-only: original server error to surface in the browser overlay. */
initialDevServerError?: unknown;
/** Mirror inline Flight chunks into Next.js's `self.__next_f` transport. */
mirrorNextFlight?: boolean;
/** True when the app supplies a custom global-error.tsx. Disables the
* default error-document shell fallback so SSR shell errors keep driving
* the server-rendered global-error boundary re-render. */
Expand Down Expand Up @@ -252,6 +256,7 @@ export async function renderAppPageHtmlStream(
pprFallbackShellSignal: options.pprFallbackShellSignal,
waitForAllReady: options.waitForAllReady,
initialDevServerError: options.initialDevServerError,
mirrorNextFlight: options.mirrorNextFlight,
// Only when the caller affirmatively knows there is no custom
// global-error.tsx; undefined (unknown) keeps reject semantics.
fallbackToErrorDocumentOnShellError:
Expand Down
22 changes: 12 additions & 10 deletions packages/vinext/src/server/app-ssr-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,8 @@ export async function handleSsr(
rootParams?: RootParams;
/** Dev-only: original server error to surface in the browser overlay. */
initialDevServerError?: unknown;
/** Mirror inline Flight chunks into Next.js's `self.__next_f` transport. */
mirrorNextFlight?: boolean;
/** When true, wait for the full React tree (including Suspense boundaries)
* to resolve before returning the HTML stream. Used for static prerender
* and ISR cache writes to avoid caching fallback content. */
Expand Down Expand Up @@ -416,22 +418,22 @@ export async function handleSsr(

if (options?.sideStream) {
ssrStream = rscStream;
rscEmbed = createRscEmbedTransform(
options.sideStream,
options?.scriptNonce,
options?.getInitialNavigationCacheMetadata,
);
rscEmbed = createRscEmbedTransform(options.sideStream, {
mirrorNextFlight: options?.mirrorNextFlight,
scriptNonce: options?.scriptNonce,
getInitialNavigationCacheMetadata: options?.getInitialNavigationCacheMetadata,
});
if (options.capturedRscDataRef) {
options.capturedRscDataRef.value = rscEmbed.getRawBuffer();
}
} else {
const [s1, s2] = rscStream.tee();
ssrStream = s1;
rscEmbed = createRscEmbedTransform(
s2,
options?.scriptNonce,
options?.getInitialNavigationCacheMetadata,
);
rscEmbed = createRscEmbedTransform(s2, {
mirrorNextFlight: options?.mirrorNextFlight,
scriptNonce: options?.scriptNonce,
getInitialNavigationCacheMetadata: options?.getInitialNavigationCacheMetadata,
});
}

let flightRoot: PromiseLike<AppWireElements> | null = null;
Expand Down
45 changes: 40 additions & 5 deletions packages/vinext/src/server/app-ssr-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ type RscEmbedTransform = {
getRawBuffer(): Promise<ArrayBuffer>;
};

type RscEmbedTransformOptions = {
mirrorNextFlight?: boolean;
scriptNonce?: string;
getInitialNavigationCacheMetadata?: () => InitialNavigationCacheMetadata;
};

type HtmlInsertion = string | (() => string);
type InlineCssManifest = Record<string, string>;
export type InitialNavigationCacheMetadata = {
Expand Down Expand Up @@ -81,6 +87,19 @@ function createNavigationRuntimeRscDoneScript(metadata?: InitialNavigationCacheM
);
}

function createNextFlightBootstrapScript(): string {
return "(self.__next_f=self.__next_f||[]).push([0])";
}

function createNextFlightChunkScript(chunk: RscEmbeddedChunk): string {
const nextChunk = typeof chunk === "string" ? [1, chunk] : chunk;
return "self.__next_f.push(" + safeJsonStringify(nextChunk) + ")";
}

function createNextFlightCleanupScript(): string {
return 'self.addEventListener("DOMContentLoaded",()=>{if(self.__next_f?.push===Array.prototype.push)self.__next_f.length=0},{once:true})';
}

/**
* Fix invalid preload "as" values in RSC Flight hint lines before they reach
* the client. React Flight emits HL hints with as="stylesheet" for CSS, but
Expand All @@ -96,13 +115,15 @@ export function fixFlightHints(text: string): string {
*/
export function createRscEmbedTransform(
embedStream: ReadableStream<Uint8Array>,
scriptNonce?: string,
getInitialNavigationCacheMetadata?: () => InitialNavigationCacheMetadata,
optionsOrNonce: RscEmbedTransformOptions | string = {},
): RscEmbedTransform {
const options =
typeof optionsOrNonce === "string" ? { scriptNonce: optionsOrNonce } : optionsOrNonce;
const reader = embedStream.getReader();
let pendingChunks: RscEmbeddedChunk[] = [];
const rawChunks: Uint8Array[] = [];
let reading = false;
let mirroredNextFlightBootstrap = false;

async function pumpReader(): Promise<void> {
if (reading) return;
Expand Down Expand Up @@ -146,7 +167,21 @@ export function createRscEmbedTransform(

let scripts = "";
for (const chunk of chunks) {
scripts += createInlineScriptTag(createNavigationRuntimeRscChunkScript(chunk), scriptNonce);
scripts += createInlineScriptTag(
createNavigationRuntimeRscChunkScript(chunk),
options.scriptNonce,
);
if (options.mirrorNextFlight) {
if (!mirroredNextFlightBootstrap) {
scripts += createInlineScriptTag(
createNextFlightBootstrapScript(),
options.scriptNonce,
);
scripts += createInlineScriptTag(createNextFlightCleanupScript(), options.scriptNonce);
mirroredNextFlightBootstrap = true;
}
scripts += createInlineScriptTag(createNextFlightChunkScript(chunk), options.scriptNonce);
}
}
return scripts;
},
Expand All @@ -155,8 +190,8 @@ export function createRscEmbedTransform(
await pumpPromise;
let scripts = this.flush();
scripts += createInlineScriptTag(
createNavigationRuntimeRscDoneScript(getInitialNavigationCacheMetadata?.()),
scriptNonce,
createNavigationRuntimeRscDoneScript(options.getInitialNavigationCacheMetadata?.()),
options.scriptNonce,
);
return scripts;
},
Expand Down
24 changes: 24 additions & 0 deletions tests/app-router-dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1499,6 +1499,7 @@ describe("App Router integration", () => {
// Title from generateMetadata should use the dynamic slug
expect(html).toContain("<title>Blog: my-post</title>");
expect(html).toMatch(/name="description".*content="Read about my-post"/);
expect(html).not.toContain("self.__next_f");
});

it("layout generateMetadata() does not receive searchParams (Next.js parity)", async () => {
Expand All @@ -1515,6 +1516,29 @@ describe("App Router integration", () => {
expect(html).toContain("<title>Layout Section: home</title>");
});

// Ported from Next.js: test/e2e/app-dir/metadata-navigation/metadata-navigation.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/metadata-navigation/metadata-navigation.test.ts
it("renders the local not-found boundary when generateMetadata() calls notFound()", async () => {
const res = await fetch(`${baseUrl}/nextjs-compat/generate-metadata-not-found`, {
headers: { "User-Agent": "Mozilla/5.0" },
});

expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Local found boundary");
expect(html).not.toContain("not-found-text");
const flightText = [
...html.matchAll(/<script[^>]*>self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)<\/script>/g),
]
.map((match) => match[1])
.join("");
expect(flightText).toContain("Local found boundary");
expect(html).toContain('<meta name="robots" content="noindex"');
expect(html).toContain("<title>Local not found</title>");
expect(html).toContain('<meta name="description" content="Local not found description"');
expect(html).toContain('<meta name="keywords" content="parent"');
});

it("renders catch-all routes with multiple segments", async () => {
const res = await fetch(`${baseUrl}/docs/getting-started/install`);
expect(res.status).toBe(200);
Expand Down
119 changes: 112 additions & 7 deletions tests/app-ssr-stream.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createContext, runInContext } from "node:vm";
import { describe, it, expect } from "vite-plus/test";
import {
createNavigationRuntimeRscMetadataScript,
Expand Down Expand Up @@ -133,12 +134,112 @@ describe("createRscEmbedTransform raw buffer (#981)", () => {
expect(finalScripts).toContain('.rsc.push("chunk2")');
});

it("optionally mirrors text chunks into the Next.js inline Flight transport", async () => {
const transform = createRscEmbedTransform(createTextStream(["chunk1", "chunk2"]), {
mirrorNextFlight: true,
scriptNonce: "test-nonce",
});

const finalScripts = await transform.finalize();

expect(finalScripts).toContain(
'<script nonce="test-nonce">(self.__next_f=self.__next_f||[]).push([0])</script>',
);
expect(finalScripts).toContain(
'<script nonce="test-nonce">self.__next_f.push([1,"chunk1"])</script>',
);
expect(finalScripts).toContain(
'<script nonce="test-nonce">self.__next_f.push([1,"chunk2"])</script>',
);
expect(finalScripts).toContain(
'<script nonce="test-nonce">self.addEventListener("DOMContentLoaded",()=>{if(self.__next_f?.push===Array.prototype.push)self.__next_f.length=0},{once:true})</script>',
);
expect(finalScripts.match(/self\.__next_f=self\.__next_f\|\|\[\]/g)).toHaveLength(1);
});

it("does not mirror Next.js inline Flight chunks by default", async () => {
const transform = createRscEmbedTransform(createTextStream(["chunk"]));

const finalScripts = await transform.finalize();

expect(finalScripts).not.toContain("self.__next_f");
});

it("schedules cleanup for an unclaimed progressive buffer before a later stream error", async () => {
let streamController!: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
controller.enqueue(new TextEncoder().encode("partial"));
},
});
const transform = createRscEmbedTransform(stream, { mirrorNextFlight: true });

await new Promise((resolve) => setTimeout(resolve, 0));
const progressiveScripts = transform.flush();
expect(progressiveScripts).toContain('self.__next_f.push([1,"partial"])');
expect(progressiveScripts).toContain('self.addEventListener("DOMContentLoaded"');

streamController.error(new Error("stream broke"));
await expect(transform.finalize()).rejects.toThrow("stream broke");
});

it("preserves progressive chunks until a deferred Next.js consumer claims the buffer", async () => {
let streamController!: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
controller.enqueue(new TextEncoder().encode("first"));
},
});
const transform = createRscEmbedTransform(stream, { mirrorNextFlight: true });

await new Promise((resolve) => setTimeout(resolve, 0));
const firstScripts = transform.flush();
streamController.enqueue(new TextEncoder().encode("second"));
streamController.close();
const finalScripts = await transform.finalize();

const listeners = new Map<string, () => void>();
const self = {
__next_f: undefined as undefined | Array<unknown>,
addEventListener(name: string, listener: () => void) {
listeners.set(name, listener);
},
};
const context = createContext({ self });
const runScripts = (html: string) => {
const scriptSources = html
.slice("<script>".length, -"</script>".length)
.split("</script><script>");
for (const scriptSource of scriptSources) {
runInContext(scriptSource, context);
}
};

runScripts(firstScripts);
const received = [...self.__next_f!];
self.__next_f!.length = 0;
self.__next_f!.push = (segment) => {
received.push(segment);
return received.length;
};
runScripts(finalScripts);
listeners.get("DOMContentLoaded")!();

expect(received).toEqual([[0], [1, "first"], [1, "second"]]);
expect(self.__next_f).toHaveLength(0);
expect(self.__next_f!.push).not.toBe(Array.prototype.push);
});

it("finalizes initial cache metadata after the RSC stream settles", async () => {
let initialCacheKind: "dynamic" | "static" = "static";
const transform = createRscEmbedTransform(createTextStream(["chunk"]), undefined, () => ({
kind: initialCacheKind,
...(initialCacheKind === "dynamic" ? { dynamicStaleTimeSeconds: 30 } : {}),
}));
const transform = createRscEmbedTransform(createTextStream(["chunk"]), {
getInitialNavigationCacheMetadata: () => ({
kind: initialCacheKind,
...(initialCacheKind === "dynamic" ? { dynamicStaleTimeSeconds: 30 } : {}),
}),
});

initialCacheKind = "dynamic";
const finalScripts = await transform.finalize();
Expand All @@ -151,9 +252,11 @@ describe("createRscEmbedTransform raw buffer (#981)", () => {
});

it("omits dynamic stale time from finalized static payload metadata", async () => {
const transform = createRscEmbedTransform(createTextStream(["chunk"]), undefined, () => ({
kind: "static",
}));
const transform = createRscEmbedTransform(createTextStream(["chunk"]), {
getInitialNavigationCacheMetadata: () => ({
kind: "static",
}),
});

const finalScripts = await transform.finalize();

Expand Down Expand Up @@ -196,11 +299,13 @@ describe("createRscEmbedTransform raw buffer (#981)", () => {
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/binary/rsc-binary.test.ts
const transform = createRscEmbedTransform(
createByteStream([new Uint8Array([0xff, 0, 1, 2, 3])]),
{ mirrorNextFlight: true },
);

const finalScripts = await transform.finalize();

expect(finalScripts).toContain('.rsc.push([3,"/wABAgM="])');
expect(finalScripts).toContain('self.__next_f.push([3,"/wABAgM="])');
});

it("does not lose incomplete UTF-8 bytes before a binary chunk", async () => {
Expand Down
20 changes: 20 additions & 0 deletions tests/e2e/app-router/metadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,26 @@ test.describe("Dynamic Metadata (generateMetadata)", () => {
const ogDescription = page.locator('meta[property="og:description"]');
await expect(ogDescription).toHaveAttribute("content", "Dynamic OG Description");
});

test("generateMetadata notFound hydrates the local boundary without retaining mirrored Flight", async ({
page,
}) => {
await page.goto(`${BASE}/nextjs-compat/generate-metadata-not-found`);

await expect(page.locator("h2")).toHaveText("Local found boundary");
await expect(page).toHaveTitle("Local not found");
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
"content",
"Local not found description",
);
await expect(page.locator('meta[name="keywords"]')).toHaveAttribute("content", "parent");
await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex");
await expect
.poll(() =>
page.evaluate(() => (window as Window & { __next_f?: unknown[] }).__next_f?.length ?? 0),
)
.toBe(0);
});
});

test.describe("Metadata Routes", () => {
Expand Down
Loading
Loading