-
Notifications
You must be signed in to change notification settings - Fork 335
fix(app-router): recover SSR shell render errors via __next_error__ document #1908
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
base: main
Are you sure you want to change the base?
Changes from all commits
2a9a102
99789d7
dab9c72
7f8b8ba
f8ff07c
ad6daac
2a6292a
57b01b4
2da1242
e9f0119
a2f481b
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 |
|---|---|---|
|
|
@@ -678,6 +678,7 @@ export default __createAppRscHandler({ | |
| getSourceRoute(sourceRouteIndex) { | ||
| return routes[sourceRouteIndex]; | ||
| }, | ||
| hasCustomGlobalError: ${globalErrorVar ? `Boolean(${globalErrorVar}?.default)` : "false"}, | ||
|
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.
|
||
| hasGenerateStaticParams: __generateStaticParams.length > 0, | ||
| hasPageDefaultExport: !!PageComponent, | ||
| hasPageModule: !!route.page, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -185,7 +185,7 @@ import { createRequire } from "node:module"; | |
| import fs from "node:fs"; | ||
| import { randomBytes, randomUUID } from "node:crypto"; | ||
| import commonjs from "vite-plugin-commonjs"; | ||
| import { normalizePathSeparators, stripViteModuleQuery } from "./utils/path.js"; | ||
| import { normalizePathSeparators, stripJsExtension, stripViteModuleQuery } from "./utils/path.js"; | ||
|
|
||
| // Install the process-level peer-disconnect backstop at module load. | ||
| // Vite plugin lifecycle hooks (config / configureServer) proved | ||
|
|
@@ -2500,11 +2500,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |
|
|
||
| resolveId: { | ||
| // Hook filter: only invoke JS for handled Next/Vinext compatibility modules. | ||
| // Matches "next/navigation", "next/router.js", "virtual:vinext-rsc-entry", | ||
| // Matches "next/navigation", "next/router.js", internal | ||
| // "vinext/shims/*" package subpaths, "virtual:vinext-rsc-entry", | ||
| // direct @vercel/og imports in metadata routes, and \0-prefixed | ||
| // re-imports from @vitejs/plugin-rsc. | ||
| filter: { | ||
| id: /(?:next\/|virtual:vinext-|^@vercel\/og(?:\.js)?$)/, | ||
| id: /(?:next\/|vinext\/shims\/|virtual:vinext-|@vercel\/og(?:\.js)?$)/, | ||
|
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 meaningful change here besides adding |
||
| }, | ||
| handler(id, importer) { | ||
| // Strip \0 prefix if present — @vitejs/plugin-rsc's generated | ||
|
|
@@ -2578,6 +2579,23 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |
| ); | ||
| } | ||
|
|
||
| // Runtime helper modules embedded into generated entries import | ||
| // vinext's own shims by package subpath (for example | ||
| // `vinext/shims/headers`). Source checkouts also alias userland | ||
| // `next/*` imports to the local shim files. Resolve both forms | ||
| // through this plugin so request-scoped singleton state is not split | ||
| // between source shims and the package export copy. These internal | ||
| // package subpaths deliberately resolve to the plain shim today; if | ||
| // one gains an environment-specific variant, handle it here before | ||
| // returning rather than relying on the userland `next/*` map below. | ||
| const vinextShimPrefix = "vinext/shims/"; | ||
| if (cleanId.startsWith(vinextShimPrefix)) { | ||
|
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. This branch now intercepts every
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 new Consider either (a) adding an explicit |
||
| return resolveShimModulePath( | ||
| _shimsDir, | ||
| stripJsExtension(stripViteModuleQuery(cleanId.slice(vinextShimPrefix.length))), | ||
| ); | ||
| } | ||
|
|
||
| // Shims with react-server variants — resolve per-environment. | ||
| // These are NOT in resolve.alias (Vite's alias plugin runs | ||
| // before enforce:"pre" plugins and can't be overridden). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,7 +18,7 @@ import { | |
| setServerCallback, | ||
| } from "@vitejs/plugin-rsc/browser"; | ||
| import { flushSync } from "react-dom"; | ||
| import { hydrateRoot } from "react-dom/client"; | ||
| import { createRoot, hydrateRoot } from "react-dom/client"; | ||
| import "../client/instrumentation-client.js"; | ||
| import { notifyAppRouterTransitionStart } from "../client/instrumentation-client-state.js"; | ||
| import { | ||
|
|
@@ -1603,16 +1603,36 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void { | |
| onCaughtError: prodOnCaughtError, | ||
| onUncaughtError, | ||
| }); | ||
| window.__VINEXT_RSC_ROOT__ = hydrateRootInTransition({ | ||
| children: createElement(BrowserRoot, { | ||
| initialElements: root, | ||
| initialNavigationSnapshot, | ||
| }), | ||
| container: document, | ||
| hydrateRoot, | ||
| options: hydrateRootOptions, | ||
| startTransition, | ||
| const children = createElement(BrowserRoot, { | ||
| initialElements: root, | ||
| initialNavigationSnapshot, | ||
| }); | ||
| const errorShellStyles = document.querySelectorAll("style[data-vinext-error-shell-style]"); | ||
| // Only the SSR shell-error recovery document carries the style marker. | ||
| // Legacy server-rendered default-error pages also use `__next_error__`, but | ||
| // keep their existing hydration path so the serialized server error digest | ||
| // remains available to DefaultGlobalError. | ||
| if (document.documentElement.id === "__next_error__" && errorShellStyles.length > 0) { | ||
|
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. Behavioral regression worth confirming: unconditional client throw + no boundary + no custom global-error now leaves a blank page. This Consider a client component that throws on both server and client (not the SSR-only
The new spec only exercises the SSR-only throw (recovers) and the local-
Contributor
Author
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. Checked this on the current PR head and it does not leave a blank page. The shell-recovery path already lands on the default |
||
| // There is no server-rendered form to hydrate in this client-render path; | ||
| // reuse only the shared root error callbacks and related root options. | ||
| const { formState: _inertFormState, ...createRootOptions } = hydrateRootOptions; | ||
| for (const style of errorShellStyles) { | ||
| style.remove(); | ||
| } | ||
| startTransition(() => { | ||
| const clientRoot = createRoot(document, createRootOptions); | ||
| clientRoot.render(children); | ||
| window.__VINEXT_RSC_ROOT__ = clientRoot; | ||
| }); | ||
| } else { | ||
| window.__VINEXT_RSC_ROOT__ = hydrateRootInTransition({ | ||
| children, | ||
| container: document, | ||
| hydrateRoot, | ||
| options: hydrateRootOptions, | ||
| startTransition, | ||
| }); | ||
| } | ||
|
|
||
| const navigateRsc: NavigationRuntimeNavigate = async function navigateRsc( | ||
| href: string, | ||
|
|
@@ -2190,6 +2210,9 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void { | |
| // original document from there, so let the next HMR update reload the | ||
| // current URL. If the edit fixed the error the page comes back clean; if | ||
| // not, initial dev server errors re-populate the overlay. | ||
| // | ||
| // Reloading is safe for any default-error document because the dev | ||
| // server will render the current state of the source after the edit. | ||
| if (document.documentElement.id === "__next_error__") { | ||
| window.location.reload(); | ||
| return; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -267,6 +267,11 @@ type DispatchAppPageOptions<TRoute extends AppPageDispatchRoute> = { | |
| probeLayoutAt: (layoutIndex: number, layoutParamAccess?: AppLayoutParamAccessTracker) => unknown; | ||
| probePage: () => unknown; | ||
| expireSeconds?: number; | ||
| /** True when the app supplies a custom global-error.tsx (generated entry | ||
| * knows this at build time). Gates SSR shell-error recovery: without a | ||
| * custom global-error, shell errors serve the default `__next_error__` | ||
| * recovery document instead of a server-side boundary re-render. */ | ||
| hasCustomGlobalError?: boolean; | ||
| renderErrorBoundaryPage: (error: unknown) => Promise<Response | null>; | ||
| renderHttpAccessFallbackPage: ( | ||
| statusCode: number, | ||
|
|
@@ -659,6 +664,7 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>( | |
| reactMaxHeadersLength: options.reactMaxHeadersLength, | ||
| rootParams: options.rootParams, | ||
| waitForAllReady: true, | ||
| fallbackToErrorDocumentOnShellError: options.hasCustomGlobalError === false, | ||
| ...(revalidatedRscCapture.sideStream | ||
| ? { | ||
| sideStream: revalidatedRscCapture.sideStream, | ||
|
|
@@ -979,6 +985,7 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>( | |
| return renderPageSpecialError(options, specialError); | ||
| }, | ||
| renderToReadableStream: options.renderToReadableStream, | ||
| hasCustomGlobalError: options.hasCustomGlobalError, | ||
|
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. This threads the flag into the initial render lifecycle. The background-revalidation |
||
| routePattern: route.pattern, | ||
| runWithSuppressedHookWarning(probe) { | ||
| return options.runWithSuppressedHookWarning(probe); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,7 @@ import { BfcacheStateKeyMapContext, ElementsContext, Slot } from "vinext/shims/s | |
| import { AppRouterContext } from "vinext/shims/internal/app-router-context"; | ||
| import { createClientReferencePreloader } from "./app-client-reference-preloader.js"; | ||
| import { RSC_FORM_STATE_GLOBAL } from "./app-browser-hydration.js"; | ||
| import DefaultGlobalError from "vinext/shims/default-global-error"; | ||
|
|
||
| /** | ||
| * `@types/react-dom` does not yet type `maxHeadersLength` (it pairs with the | ||
|
|
@@ -106,6 +107,58 @@ function getErrorMessage(error: unknown): string { | |
| return Object.prototype.toString.call(error); | ||
| } | ||
|
|
||
| function createUtf8Stream(html: string): ReadableStream<Uint8Array> { | ||
| const encoder = new TextEncoder(); | ||
| return new ReadableStream<Uint8Array>({ | ||
| start(controller) { | ||
| controller.enqueue(encoder.encode(html)); | ||
| controller.close(); | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| function buildBootstrapModuleScript(bootstrapModuleUrl?: string, nonce?: string): string { | ||
| if (!bootstrapModuleUrl) return ""; | ||
| return ( | ||
| `<script type="module"${createNonceAttribute(nonce)} src="` + | ||
| escapeHtmlAttr(bootstrapModuleUrl) + | ||
| '" id="_R_" async=""></script>' | ||
| ); | ||
| } | ||
|
|
||
| function markErrorShellStyle(html: string): string { | ||
| // DefaultGlobalError intentionally emits one style tag. If that changes, | ||
| // update this marker logic without touching styles inserted later by the | ||
| // stream transforms. | ||
| return html.replace("<style>", '<style data-vinext-error-shell-style="">'); | ||
|
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.
|
||
| } | ||
|
|
||
| function renderSsrErrorDocumentShell( | ||
| bootstrapModuleUrl?: string, | ||
| nonce?: string, | ||
| ): ReadableStream<Uint8Array> { | ||
| const html = markErrorShellStyle( | ||
| renderToStaticMarkup( | ||
| createReactElement(DefaultGlobalError, { | ||
| error: null, | ||
| }), | ||
| ), | ||
| ); | ||
| const bootstrapScript = buildBootstrapModuleScript(bootstrapModuleUrl, nonce); | ||
| if (!bootstrapScript) { | ||
| return createUtf8Stream(`<!DOCTYPE html>${html}`); | ||
| } | ||
|
|
||
| const documentClose = "</body></html>"; | ||
| if (!html.endsWith(documentClose)) { | ||
| return createUtf8Stream(`<!DOCTYPE html>${html}${bootstrapScript}`); | ||
| } | ||
|
|
||
| return createUtf8Stream( | ||
| `<!DOCTYPE html>${html.slice(0, -documentClose.length)}${bootstrapScript}${documentClose}`, | ||
| ); | ||
| } | ||
|
|
||
| function renderInsertedHtml(insertedElements: readonly unknown[]): string { | ||
| let insertedHTML = ""; | ||
|
|
||
|
|
@@ -306,6 +359,12 @@ export async function handleSsr( | |
| * to resolve before returning the HTML stream. Used for static prerender | ||
| * and ISR cache writes to avoid caching fallback content. */ | ||
| waitForAllReady?: boolean; | ||
| /** When true, an SSR-phase shell render error (no RSC-originated `digest`) | ||
| * resolves to the default `__next_error__` error-document shell with the | ||
| * original flight payload and bootstrap module, instead of rejecting. | ||
| * Callers set this only when the app has no custom global-error.tsx, so | ||
| * boundary re-render semantics are unaffected. */ | ||
| fallbackToErrorDocumentOnShellError?: boolean; | ||
| }, | ||
| ): Promise<AppSsrRenderResult> { | ||
| return runWithNavigationContext(async () => { | ||
|
|
@@ -501,8 +560,6 @@ export async function handleSsr( | |
| }, | ||
| }; | ||
|
|
||
| const htmlStream = await renderToReadableStream(ssrRoot, ssrRenderOptions); | ||
|
|
||
| // Populated before any SSR request runs: at prod-server startup | ||
| // (prod-server.ts) or via build-time bundle injection (index.ts). Left | ||
| // undefined in dev, which naturally disables inline CSS there. | ||
|
|
@@ -560,8 +617,36 @@ export async function handleSsr( | |
| const getBeforeInteractiveHeadHTML = (): string => | ||
| renderBeforeInteractiveInlineScripts(beforeInteractiveInlineScripts); | ||
|
|
||
| if (options?.waitForAllReady === true) { | ||
| await htmlStream.allReady; | ||
| let renderedHtmlStream: Awaited<ReturnType<typeof renderToReadableStream>> | undefined; | ||
| let htmlStream: ReadableStream<Uint8Array>; | ||
| try { | ||
| renderedHtmlStream = await renderToReadableStream(ssrRoot, ssrRenderOptions); | ||
| if (options?.waitForAllReady === true) { | ||
| await renderedHtmlStream.allReady; | ||
| } | ||
| htmlStream = renderedHtmlStream; | ||
|
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. On the |
||
| } catch (error) { | ||
| void renderedHtmlStream?.cancel().catch(() => {}); | ||
| // Contract with renderAppPageHtmlStreamWithRecovery (app-page-stream.ts): | ||
| // a rejected handleSsr drives redirect()/notFound() responses and | ||
| // local/global error.tsx boundary re-renders. Errors that originate in | ||
| // the RSC render (rethrown here through the flight deserializer) carry | ||
| // a string `digest` and must always propagate so those semantics stay | ||
| // intact. Only an SSR-phase-only shell error — and only when the app | ||
| // has no custom global-error.tsx (the caller sets the flag) — falls | ||
| // back to the default `__next_error__` document shell: the original | ||
| // flight payload plus the bootstrap module are still emitted, so the | ||
| // browser recovers by re-rendering the real tree from the embedded | ||
| // RSC data (Next.js shell-error recovery semantics). This resolves | ||
| // as a successful shell render, so the caller preserves its normal | ||
| // status and ISR eligibility rather than treating it as a 500. | ||
| if ( | ||
| options?.fallbackToErrorDocumentOnShellError !== true || | ||
| typeof (error as { digest?: unknown } | null)?.digest === "string" | ||
| ) { | ||
| throw error; | ||
| } | ||
| htmlStream = renderSsrErrorDocumentShell(bootstrapModuleUrl, options?.scriptNonce); | ||
| } | ||
|
|
||
| const finalStream = deferUntilStreamConsumed( | ||
|
|
||
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.
Boolean(${globalErrorVar}?.default)correctly treats aglobal-errorfile with no default export as "no custom global error" — so an app with a malformedglobal-error.tsx(file present, no default) would still get the__next_error__recovery shell rather than the server-side boundary path. That's a reasonable choice, but worth confirming it matches Next.js, which I believe validates/throws on aglobal-errorwithout a default export rather than silently falling back. Non-blocking, but a candidate for a follow-up parity check.