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/index.ts b/packages/vinext/src/index.ts index 6e97af368..185b265d5 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -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)?$)/, }, 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)) { + 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). diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 34f796554..89baa7d51 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,36 @@ 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, }); + 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) { + // 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): 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; diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 2c6c9474a..5053ccd82 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, @@ -659,6 +664,7 @@ async function dispatchAppPageInner( reactMaxHeadersLength: options.reactMaxHeadersLength, rootParams: options.rootParams, waitForAllReady: true, + fallbackToErrorDocumentOnShellError: options.hasCustomGlobalError === false, ...(revalidatedRscCapture.sideStream ? { sideStream: revalidatedRscCapture.sideStream, @@ -979,6 +985,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..c6f3a7118 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,58 @@ 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 { + // 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("