diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index a10fe7f34e..5beba2d5ef 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -808,6 +808,7 @@ export default createAppRscHandler({ renderMode, observeMetadataSearchParamsAccess: buildOptions?.observeMetadataSearchParamsAccess === true, observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess === true, + serveStreamingMetadata: buildOptions?.serveStreamingMetadata, }, layoutParamAccess, displayPathname); }, clientReuseManifest, diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index ba403ff5cc..dfdd61a712 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -244,6 +244,7 @@ export type DispatchAppPageOptions = { options?: { observeMetadataSearchParamsAccess?: boolean; observePageSearchParamsAccess?: boolean; + serveStreamingMetadata?: boolean; }, ) => Promise; clientReuseManifest?: ClientReuseManifestParseResult; @@ -565,6 +566,16 @@ async function dispatchAppPageInner( const isForceStatic = dynamicConfig === "force-static"; const isDynamicError = dynamicConfig === "error"; const isForceDynamic = dynamicConfig === "force-dynamic"; + const isPrerender = process.env.VINEXT_PRERENDER === "1"; + const serveStreamingMetadata = shouldServeStreamingMetadata( + options.request.headers.get("user-agent") ?? "", + options.htmlLimitedBots, + ); + // Full static artifacts resolve generated metadata into . PPR fallback + // shells keep request-time placement until metadata staticness can be tracked + // independently from the route's staticness. + const placeGeneratedMetadataInBody = + (!isPrerender || options.pprFallbackShell !== undefined) && serveStreamingMetadata; const isPrefetchDynamicShell = options.renderMode === APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL; const isDraftMode = isDraftModeRequest(options.request, options.draftModeSecret); const requestHeadersContext = getHeadersContext(); @@ -718,6 +729,9 @@ async function dispatchAppPageInner( { observeMetadataSearchParamsAccess: revalidationDynamicConfig !== "force-static", observePageSearchParamsAccess: revalidationDynamicConfig !== "force-static", + // Cache regeneration produces a complete static artifact, so metadata + // must be resolved into before the artifact is stored. + serveStreamingMetadata: false, }, ); const revalidatedOnError = options.createRscOnErrorHandler( @@ -847,6 +861,7 @@ async function dispatchAppPageInner( { observeMetadataSearchParamsAccess: sourceDynamicConfig !== "force-static", observePageSearchParamsAccess: sourceDynamicConfig !== "force-static", + serveStreamingMetadata: placeGeneratedMetadataInBody, }, ); }, @@ -919,6 +934,7 @@ async function dispatchAppPageInner( { observeMetadataSearchParamsAccess: !isForceStatic, observePageSearchParamsAccess: !isForceStatic, + serveStreamingMetadata: placeGeneratedMetadataInBody, }, ); }, @@ -938,7 +954,7 @@ async function dispatchAppPageInner( return options.renderErrorBoundaryPage(buildError); }, renderSpecialError(specialError) { - return renderPageSpecialError(options, specialError); + return renderPageSpecialError(options, specialError, serveStreamingMetadata); }, resolveSpecialError: resolveAppPageSpecialError, }); @@ -982,7 +998,6 @@ async function dispatchAppPageInner( const activeFallbackShellState = options.pprRuntime?.getState() ?? null; const pprFallbackShellSignal = activeFallbackShellState?.abortController.signal; const pprFallbackShellReactSignal = activeFallbackShellState?.reactAbortController.signal; - const isPrerender = process.env.VINEXT_PRERENDER === "1"; const isSpeculativePrerender = isPrerender && options.request.headers.get(VINEXT_PRERENDER_SPECULATIVE_HEADER) === "1"; @@ -1098,10 +1113,10 @@ async function dispatchAppPageInner( return options.renderErrorBoundaryPage(renderError, errorOrigin); }, renderLayoutSpecialError(specialError, layoutIndex) { - return renderLayoutSpecialError(options, specialError, layoutIndex); + return renderLayoutSpecialError(options, specialError, layoutIndex, serveStreamingMetadata); }, renderPageSpecialError(specialError) { - return renderPageSpecialError(options, specialError); + return renderPageSpecialError(options, specialError, serveStreamingMetadata); }, renderToReadableStream: options.renderToReadableStream, hasCustomGlobalError: options.hasCustomGlobalError, @@ -1121,6 +1136,7 @@ async function renderLayoutSpecialError( options: DispatchAppPageOptions, specialError: AppPageSpecialError, layoutIndex: number, + serveStreamingMetadata: boolean, ): Promise { return buildAppPageSpecialErrorResponse({ basePath: options.basePath, @@ -1131,10 +1147,7 @@ async function renderLayoutSpecialError( }), clearRequestContext: options.clearRequestContext, getAndClearPendingCookies, - serveStreamingMetadata: shouldServeStreamingMetadata( - options.request.headers.get("user-agent") ?? "", - options.htmlLimitedBots, - ), + serveStreamingMetadata, isEdgeRuntime: options.isEdgeRuntime, isRscRequest: options.isRscRequest, middlewareContext: options.middlewareContext, @@ -1167,6 +1180,7 @@ async function renderLayoutSpecialError( async function renderPageSpecialError( options: DispatchAppPageOptions, specialError: AppPageSpecialError, + serveStreamingMetadata: boolean, ): Promise { return buildAppPageSpecialErrorResponse({ basePath: options.basePath, @@ -1177,10 +1191,7 @@ async function renderPageSpecialError( }), clearRequestContext: options.clearRequestContext, getAndClearPendingCookies, - serveStreamingMetadata: shouldServeStreamingMetadata( - options.request.headers.get("user-agent") ?? "", - options.htmlLimitedBots, - ), + serveStreamingMetadata, isEdgeRuntime: options.isEdgeRuntime, isRscRequest: options.isRscRequest, middlewareContext: options.middlewareContext, diff --git a/packages/vinext/src/server/app-page-element-builder.ts b/packages/vinext/src/server/app-page-element-builder.ts index fc6d4acde7..07ac9c86a0 100644 --- a/packages/vinext/src/server/app-page-element-builder.ts +++ b/packages/vinext/src/server/app-page-element-builder.ts @@ -101,6 +101,8 @@ export type AppPagePageRequest = observePageSearchParamsAccess?: boolean; /** Observe page metadata `searchParams` access for cache-safety classification. */ observeMetadataSearchParamsAccess?: boolean; + /** Whether generated metadata may stream into the response body. */ + serveStreamingMetadata?: boolean; }; export type BuildPageElementsOptions< @@ -198,6 +200,7 @@ export async function buildPageElements< renderMode = APP_RSC_RENDER_MODE_NAVIGATION, observeMetadataSearchParamsAccess = false, observePageSearchParamsAccess = false, + serveStreamingMetadata, } = pageRequest; const pageModule: AppPageModule | null | undefined = route.page; @@ -360,10 +363,11 @@ export async function buildPageElements< const slotOverrides = buildSlotOverrides(route, params, routePath, opts); const metadataPlacement = hasDynamicMetadata && - shouldServeStreamingMetadata( - pageRequest.request.headers.get("user-agent") ?? "", - options.htmlLimitedBots, - ) + (serveStreamingMetadata ?? + shouldServeStreamingMetadata( + pageRequest.request.headers.get("user-agent") ?? "", + options.htmlLimitedBots, + )) ? "body" : "head"; diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index cd45d5773b..544cb96517 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -293,6 +293,7 @@ type CreateDispatchOptionsOverrides = { mountedSlotsHeader?: string | null; params?: Record; pprFallbackCacheShells?: DispatchOptions["pprFallbackCacheShells"]; + pprFallbackShell?: DispatchOptions["pprFallbackShell"]; pprRuntime?: DispatchOptions["pprRuntime"]; probeLayoutAt?: DispatchOptions["probeLayoutAt"]; probePage?: DispatchOptions["probePage"]; @@ -386,6 +387,7 @@ function createDispatchOptions(overrides: CreateDispatchOptionsOverrides = {}) { mountedSlotsHeader: overrides.mountedSlotsHeader, params, pprFallbackCacheShells: overrides.pprFallbackCacheShells, + pprFallbackShell: overrides.pprFallbackShell, pprRuntime: overrides.pprRuntime, probeLayoutAt: overrides.probeLayoutAt ?? createLayoutParamProbe(route, params, []), probePage: overrides.probePage ?? (() => null), @@ -569,6 +571,42 @@ function createLayoutParamProbe( } describe("app page dispatch", () => { + it("disables streaming metadata while producing prerendered HTML", async () => { + vi.stubEnv("VINEXT_PRERENDER", "1"); + const buildPageElement = vi.fn(async () => + React.createElement("main", null, "page"), + ); + const { options } = createDispatchOptions({ buildPageElement }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + expect(buildPageElement.mock.calls[0]?.[5]).toMatchObject({ + serveStreamingMetadata: false, + }); + }); + + it("preserves request-time metadata placement for PPR fallback-shell prerenders", async () => { + vi.stubEnv("VINEXT_PRERENDER", "1"); + const buildPageElement = vi.fn(async () => + React.createElement("main", null, "page"), + ); + const { options } = createDispatchOptions({ + buildPageElement, + pprFallbackShell: { + fallbackParamNames: ["slug"], + routePattern: "/posts/[slug]", + }, + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + expect(buildPageElement.mock.calls[0]?.[5]).toMatchObject({ + serveStreamingMetadata: true, + }); + }); + it("does not run a speculative connection() page probe for HTML renders", async () => { const probePage = vi.fn(async () => { await connection(); @@ -2407,7 +2445,11 @@ describe("app page dispatch", () => { await expect(response.text()).resolves.toBe("popular"); expect(buildOptions).toEqual([ - { observeMetadataSearchParamsAccess: true, observePageSearchParamsAccess: true }, + { + observeMetadataSearchParamsAccess: true, + observePageSearchParamsAccess: true, + serveStreamingMetadata: true, + }, ]); }); @@ -2474,9 +2516,13 @@ describe("app page dispatch", () => { }; let capturedWaitForAllReady: boolean | undefined; let capturedFallbackToErrorDocument: boolean | undefined; + let capturedServeStreamingMetadata: boolean | undefined; const isrSet = vi.fn(async () => {}); const { options } = createDispatchOptions({ - buildPageElement: async () => React.createElement("main", null, "fresh"), + buildPageElement: async (_route, _params, _opts, _searchParams, _layout, buildOptions) => { + capturedServeStreamingMetadata = buildOptions?.serveStreamingMetadata; + return React.createElement("main", null, "fresh"); + }, cleanPathname: "/posts/hello", isProduction: true, isrGet: vi.fn(async () => @@ -2517,6 +2563,7 @@ describe("app page dispatch", () => { await scheduledRender(); expect(capturedWaitForAllReady).toBe(true); + expect(capturedServeStreamingMetadata).toBe(false); expect(capturedFallbackToErrorDocument).toBeUndefined(); expect(isrSet).toHaveBeenCalled(); }); diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 0127d2aabd..61a71864b1 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -187,6 +187,35 @@ async function renderRouteEntry(elements: AppElements, routeId: string): Promise ); } +async function renderRouteDocument(elements: AppElements, routeId: string): Promise { + const { ElementsContext, Slot } = await import("../packages/vinext/src/shims/slot.js"); + return renderHtml( + createElement( + "html", + null, + createElement("head"), + createElement( + "body", + null, + createElement( + ElementsContext.Provider, + { value: elements }, + createElement(Slot, { id: routeId }), + ), + ), + ), + ); +} + +function readDocumentSection(html: string, tagName: "head" | "body"): string { + const start = html.indexOf(`<${tagName}>`); + const end = html.indexOf(``); + if (start === -1 || end === -1) { + throw new Error(`Rendered document is missing <${tagName}>`); + } + return html.slice(start, end + tagName.length + 3); +} + async function withTimeout(promise: Promise, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { @@ -290,6 +319,7 @@ function PageProbe() { async function buildGeneratedMetadataRouteHtml( userAgent: string, htmlLimitedBots?: string, + serveStreamingMetadata?: boolean, ): Promise { const elements = await buildResolvedPageElements({ route: { @@ -303,7 +333,14 @@ async function buildGeneratedMetadataRouteHtml( page: { default: PageProbe, async generateMetadata() { - return { title: "generated page" }; + return { + title: "generated page", + alternates: { + canonical: "https://example.com/generated", + languages: { "en-US": "https://example.com/en/generated" }, + }, + robots: { index: false, follow: false }, + }; }, }, params: [], @@ -322,12 +359,13 @@ async function buildGeneratedMetadataRouteHtml( headers: { "user-agent": userAgent }, }), searchParams: null, + serveStreamingMetadata, }, metadataRoutes: [], htmlLimitedBots, }); - return renderRouteEntry(elements, "route:/generated"); + return renderRouteDocument(elements, "route:/generated"); } function RouteLoadingProbe() { @@ -418,40 +456,77 @@ describe("app page route wiring helpers", () => { }); }); - it("renders generated metadata in a hidden body outlet for streaming-capable requests", async () => { + it("streams generated metadata into the body for streaming-capable requests", async () => { // Ported from Next.js: test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts const html = await buildGeneratedMetadataRouteHtml("HeadlessChrome"); - - expect(html).not.toContain("generated page