From 2a9a1026dca876b1bca8cb42349fa06eed3e6ea3 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:06:00 +1000 Subject: [PATCH 01/11] fix(plugin): resolve vinext/shims/* package subpaths to local shim files Runtime helper modules embedded into generated entries import vinext's own shims by package subpath (e.g. `vinext/shims/headers`), while source checkouts alias userland `next/*` imports to the local shim files. The two specifiers resolved to different module instances, so request-scoped singleton state (navigation context, headers) split between the source shim copy and the package export copy. The violated invariant is that every shim module must be a per-request singleton regardless of import specifier. Resolve `vinext/shims/*` through the same plugin path as the `next/*` aliases so both forms land on the local shim files. Exercised by the SSR shell-error recovery browser spec, whose fixture imports vinext from the source checkout and depends on shared navigation state across both import forms. --- packages/vinext/src/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 6e97af368..abc0e6722 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2578,6 +2578,17 @@ 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. + const vinextShimPrefix = "vinext/shims/"; + if (cleanId.startsWith(vinextShimPrefix)) { + return resolveShimModulePath(_shimsDir, 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). From 99789d7ff7226ea474df09ddc671c0141466f9d1 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:06:23 +1000 Subject: [PATCH 02/11] fix(app-router): recover SSR shell render errors via __next_error__ document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the HTML (Fizz) render rejects during SSR, vinext re-rendered a server-side global-error page whose flight payload encodes the error tree. For an app without a custom global-error.tsx that meant the default error card with no path back to the real page: an SSR-phase-only throw (e.g. a client component using the "throw to opt out of server rendering" pattern) left the browser stuck on the card even though the client render would succeed. The violated expectation is Next.js's shell-error semantics: a failed HTML shell is served as the default `__next_error__` error document carrying the ORIGINAL flight payload and the bootstrap module, and the browser re-renders the real tree from that payload with createRoot instead of hydrating. Local error.tsx boundaries still win — they ship inside the flight payload and catch the re-thrown error client-side. handleSsr now resolves to that recovery document instead of rejecting, but only when both hold: - the error did not originate in the RSC render (no string `digest`), so flight errors, redirect()/notFound(), and server-component throws keep driving the existing rejection-based boundary machinery, and - the app has no custom global-error.tsx (the generated entry knows at build time and threads hasCustomGlobalError through dispatch/render options); apps with one keep the server-rendered boundary re-render. The browser entry switches from hydrateRoot to createRoot when the document root carries id="__next_error__", dropping the error-shell styles first. Covered by the new ssr-error-shell-recovery browser spec (recovery to real content, local error.tsx for SSR-only and unconditional client throws) and the existing tests/nextjs-compat/global-error.test.ts boundary-semantics suite. --- packages/vinext/src/entries/app-rsc-entry.ts | 1 + .../vinext/src/server/app-browser-entry.ts | 32 +- .../vinext/src/server/app-page-dispatch.ts | 6 + packages/vinext/src/server/app-page-render.ts | 5 + packages/vinext/src/server/app-page-stream.ts | 11 + packages/vinext/src/server/app-ssr-entry.ts | 87 +++++- .../ssr-error-shell-recovery.browser.spec.ts | 287 ++++++++++++++++++ 7 files changed, 415 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/app-router/nextjs-compat/ssr-error-shell-recovery.browser.spec.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index a73404b88..1a3488fc8 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -678,6 +678,7 @@ export default __createAppRscHandler({ getSourceRoute(sourceRouteIndex) { return routes[sourceRouteIndex]; }, + hasCustomGlobalError: ${globalErrorVar ? `Boolean(${globalErrorVar}?.default)` : "false"}, hasGenerateStaticParams: __generateStaticParams.length > 0, hasPageDefaultExport: !!PageComponent, hasPageModule: !!route.page, diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 34f796554..25562ec6b 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -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,28 @@ function bootstrapHydration(rscStream: ReadableStream): 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, }); + if (document.documentElement.id === "__next_error__") { + for (const style of document.querySelectorAll("style[data-vinext-error-shell-style]")) { + style.remove(); + } + startTransition(() => { + const clientRoot = createRoot(document, hydrateRootOptions); + 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, diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 2c6c9474a..2c3325e22 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -267,6 +267,11 @@ type DispatchAppPageOptions = { 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; renderHttpAccessFallbackPage: ( statusCode: number, @@ -979,6 +984,7 @@ async function dispatchAppPageInner( return renderPageSpecialError(options, specialError); }, renderToReadableStream: options.renderToReadableStream, + hasCustomGlobalError: options.hasCustomGlobalError, routePattern: route.pattern, runWithSuppressedHookWarning(probe) { return options.runWithSuppressedHookWarning(probe); diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 27239e408..9e5b8b51b 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -170,6 +170,10 @@ type RenderAppPageLifecycleOptions = { element: ReactNode | AppOutgoingElements, options: { onError: AppPageBoundaryOnError }, ) => ReadableStream; + /** True when the app supplies a custom global-error.tsx. When false, SSR + * shell render errors fall back to the default `__next_error__` recovery + * document instead of a server-side boundary re-render. */ + hasCustomGlobalError?: boolean; routePattern: string; runWithSuppressedHookWarning(probe: () => Promise): Promise; scriptNonce?: string; @@ -836,6 +840,7 @@ export async function renderAppPageLifecycle( rscStream: rscForResponse, scriptNonce: options.scriptNonce, sideStream: rscCapture.sideStream, + hasCustomGlobalError: options.hasCustomGlobalError, ssrHandler, waitForAllReady: options.isPrerender, }); diff --git a/packages/vinext/src/server/app-page-stream.ts b/packages/vinext/src/server/app-page-stream.ts index a0b46eaae..2d8500474 100644 --- a/packages/vinext/src/server/app-page-stream.ts +++ b/packages/vinext/src/server/app-page-stream.ts @@ -132,6 +132,10 @@ export type AppPageSsrHandler = { waitForAllReady?: boolean; /** Dev-only: original server error to surface in the browser overlay. */ initialDevServerError?: unknown; + /** 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. */ + fallbackToErrorDocumentOnShellError?: boolean; }, ) => Promise | AppSsrRenderResult>; }; @@ -166,6 +170,10 @@ type RenderAppPageHtmlStreamOptions = { waitForAllReady?: boolean; /** Dev-only: original server error to surface in the browser overlay. */ initialDevServerError?: unknown; + /** 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. */ + hasCustomGlobalError?: boolean; }; type RenderAppPageHtmlResponseOptions = { @@ -227,6 +235,9 @@ export async function renderAppPageHtmlStream( capturedRscDataRef: options.capturedRscDataRef, waitForAllReady: options.waitForAllReady, initialDevServerError: options.initialDevServerError, + // Only when the caller affirmatively knows there is no custom + // global-error.tsx; undefined (unknown) keeps reject semantics. + fallbackToErrorDocumentOnShellError: options.hasCustomGlobalError === false, }; const rawResult = await options.ssrHandler.handleSsr( diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index 5a7b7f874..af57cd44a 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -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,55 @@ function getErrorMessage(error: unknown): string { return Object.prototype.toString.call(error); } +function createUtf8Stream(html: string): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(html)); + controller.close(); + }, + }); +} + +function buildBootstrapModuleScript(bootstrapModuleUrl?: string, nonce?: string): string { + if (!bootstrapModuleUrl) return ""; + return ( + `' + ); +} + +function markErrorShellStyle(html: string): string { + return html.replace("