-
Notifications
You must be signed in to change notification settings - Fork 326
fix(app-router): emit streamed redirect and not-found meta tags #1261
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import { | |
| } from "./html.js"; | ||
| import { createRscEmbedTransform, createTickBufferedTransform } from "./app-ssr-stream.js"; | ||
| import { deferUntilStreamConsumed } from "./app-page-stream.js"; | ||
| import { createSsrErrorMetaRenderer } from "./app-ssr-error-meta.js"; | ||
| import { AppElementsWire, type AppWireElements } from "./app-elements.js"; | ||
| import { ElementsContext, Slot } from "vinext/shims/slot"; | ||
| import { AppRouterContext } from "vinext/shims/internal/app-router-context"; | ||
|
|
@@ -169,6 +170,7 @@ export async function handleSsr( | |
| /** Out-parameter: filled with accumulated raw RSC bytes when sideStream is consumed. */ | ||
| capturedRscDataRef?: { value: Promise<ArrayBuffer> | null }; | ||
| formState?: ReactFormState | null; | ||
| basePath?: string; | ||
| /** 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. */ | ||
|
|
@@ -242,12 +244,17 @@ export async function handleSsr( | |
| const ssrRoot = withScriptNonce(ssrTree, options?.scriptNonce); | ||
|
|
||
| const bootstrapScriptContent = await import.meta.viteRsc.loadBootstrapScriptContent("index"); | ||
| const errorMetaRenderer = createSsrErrorMetaRenderer({ | ||
| basePath: options?.basePath, | ||
| }); | ||
|
|
||
| const htmlStream = await renderToReadableStream(ssrRoot, { | ||
| bootstrapScriptContent, | ||
| formState: options?.formState ?? null, | ||
| nonce: options?.scriptNonce, | ||
| onError(error) { | ||
| errorMetaRenderer.capture(error); | ||
|
|
||
| if (error && typeof error === "object" && "digest" in error) { | ||
| return String(error.digest); | ||
| } | ||
|
|
@@ -274,14 +281,15 @@ export async function handleSsr( | |
| let didInjectHeadHTML = false; | ||
| const getInsertedHTML = (): string => { | ||
| const insertedHTML = renderInsertedHtml(renderServerInsertedHTML()); | ||
| if (didInjectHeadHTML) return insertedHTML; | ||
| const errorMetaHTML = errorMetaRenderer.flush(); | ||
| if (didInjectHeadHTML) return insertedHTML + errorMetaHTML; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The ordering here — |
||
|
|
||
| didInjectHeadHTML = true; | ||
| return buildHeadInjectionHtml( | ||
| navContext, | ||
| bootstrapScriptContent, | ||
| options?.formState ?? null, | ||
| insertedHTML, | ||
| insertedHTML + errorMetaHTML, | ||
| fontHTML, | ||
| options?.scriptNonce, | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { addBasePathToPathname } from "../utils/base-path.js"; | ||
| import { escapeHtmlAttr } from "./html.js"; | ||
| import { | ||
| getNextErrorDigest, | ||
| parseNextHttpErrorDigest, | ||
| parseNextRedirectDigest, | ||
| } from "./next-error-digest.js"; | ||
|
|
||
| type SsrErrorMetaRenderOptions = { | ||
| basePath?: string; | ||
| nodeEnv?: string; | ||
| }; | ||
|
|
||
| type SsrErrorMetaRenderer = { | ||
| capture: (error: unknown) => void; | ||
| flush: () => string; | ||
| }; | ||
|
|
||
| const PERMANENT_REDIRECT_STATUS = 308; | ||
|
|
||
| function prefixRedirectLocation(location: string, basePath?: string): string { | ||
| if (!basePath || !location.startsWith("/")) { | ||
| return location; | ||
| } | ||
|
|
||
| const hashIndex = location.indexOf("#"); | ||
| const queryIndex = location.indexOf("?"); | ||
| const pathnameEnd = | ||
| queryIndex === -1 | ||
| ? hashIndex === -1 | ||
| ? location.length | ||
| : hashIndex | ||
| : hashIndex === -1 | ||
| ? queryIndex | ||
| : Math.min(queryIndex, hashIndex); | ||
| const pathname = location.slice(0, pathnameEnd); | ||
|
|
||
| return addBasePathToPathname(pathname, basePath) + location.slice(pathnameEnd); | ||
| } | ||
|
|
||
| function renderSsrErrorMetaTag(error: unknown, options: SsrErrorMetaRenderOptions): string { | ||
| const digest = getNextErrorDigest(error); | ||
| if (!digest) return ""; | ||
|
|
||
| const httpError = parseNextHttpErrorDigest(digest); | ||
| if (httpError) { | ||
| let html = '<meta name="robots" content="noindex" />'; | ||
| if ((options.nodeEnv ?? process.env.NODE_ENV) === "development") { | ||
| html += '<meta name="next-error" content="not-found" />'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: the |
||
| } | ||
| return html; | ||
| } | ||
|
|
||
| const redirect = parseNextRedirectDigest(digest); | ||
| if (!redirect) return ""; | ||
|
|
||
| const delay = redirect.status === PERMANENT_REDIRECT_STATUS ? 0 : 1; | ||
| const location = prefixRedirectLocation(redirect.url, options.basePath); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor observation: Next.js's |
||
| return ( | ||
| '<meta id="__next-page-redirect" http-equiv="refresh" content="' + | ||
| delay + | ||
| ";url=" + | ||
| escapeHtmlAttr(location) + | ||
| '" />' | ||
| ); | ||
| } | ||
|
|
||
| export function renderSsrErrorMetaTags( | ||
| errors: readonly unknown[], | ||
| options: SsrErrorMetaRenderOptions = {}, | ||
| ): string { | ||
| let html = ""; | ||
|
|
||
| for (const error of errors) { | ||
| html += renderSsrErrorMetaTag(error, options); | ||
| } | ||
|
|
||
| return html; | ||
| } | ||
|
|
||
| export function createSsrErrorMetaRenderer( | ||
| options: SsrErrorMetaRenderOptions = {}, | ||
| ): SsrErrorMetaRenderer { | ||
| const capturedErrors: unknown[] = []; | ||
| let flushedUntil = 0; | ||
|
|
||
| return { | ||
| capture(error) { | ||
| capturedErrors.push(error); | ||
| }, | ||
| flush() { | ||
| if (flushedUntil >= capturedErrors.length) return ""; | ||
|
|
||
| const html = renderSsrErrorMetaTags(capturedErrors.slice(flushedUntil), options); | ||
| flushedUntil = capturedErrors.length; | ||
| return html; | ||
| }, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,7 +28,11 @@ export function safeJsonStringify(data: unknown): string { | |
| } | ||
|
|
||
| export function escapeHtmlAttr(value: string): string { | ||
| return value.replace(/&/g, "&").replace(/"/g, """); | ||
| return value | ||
| .replace(/&/g, "&") | ||
| .replace(/"/g, """) | ||
| .replace(/</g, "<") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good addition — |
||
| .replace(/>/g, ">"); | ||
| } | ||
|
|
||
| export function createNonceAttribute(nonce?: string): string { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ISR regeneration path now always passes a
basePathkey in the options object (even when it'sundefined). The old code passedundefinedas the 4th argument when there was no sideStream. The new shape{ basePath: options.basePath, ...conditionalSpread }is cleaner — it avoids the conditionalundefined4th parameter and ensures basePath flows through ISR regeneration consistently.