diff --git a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts index 6a6ff17fb..4d75cecde 100644 --- a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts +++ b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts @@ -149,9 +149,15 @@ export class CloudflareCdnCacheAdapter implements CdnCacheAdapter { } // A non-cacheable policy (no-store / no-cache / private) must never be - // promoted to an edge cache — pass it through unchanged. + // promoted to an edge cache. Clear any cacheable headers this adapter owns + // in case middleware stamped them before the final policy was known. if (/\b(?:no-store|no-cache|private)\b/.test(input.cacheControl)) { - return { "Cache-Control": input.cacheControl }; + return { + "Cache-Control": input.cacheControl, + "CDN-Cache-Control": null, + "Cloudflare-CDN-Cache-Control": null, + "Cache-Tag": null, + }; } // SWR policy on CDN-Cache-Control (edge caches + revalidates); the browser diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 4ac104d6d..80ffffaa5 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -714,6 +714,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 5b6963241..173e940fe 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -190,7 +190,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"; import { getViteMajorVersion } from "./utils/vite-version.js"; // Install the process-level peer-disconnect backstop at module load. @@ -2504,7 +2504,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // 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 @@ -2517,6 +2517,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return resolveShimModulePath(_shimsDir, "og"); } + const vinextShimPrefix = "vinext/shims/"; + if (cleanId.startsWith(vinextShimPrefix)) { + return resolveShimModulePath( + _shimsDir, + stripJsExtension(stripViteModuleQuery(cleanId.slice(vinextShimPrefix.length))), + ); + } + // Pages Router virtual modules if (cleanId === VIRTUAL_SERVER_ENTRY) return RESOLVED_SERVER_ENTRY; if (cleanId === VIRTUAL_CLIENT_ENTRY) return RESOLVED_CLIENT_ENTRY; diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 6e2a24a2a..ec4217bd9 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 { @@ -1647,16 +1647,35 @@ 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]"); + if (document.documentElement.id === "__next_error__") { + // Next.js client/app-index.tsx uses the document id alone to select CSR + // after any failed App Router server render. The style marker only scopes + // cleanup to vinext's shell-recovery placeholder styles. + // 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, + }); + } markInitialAppRouterBootstrapHydrated(); const navigateRsc: NavigationRuntimeNavigate = async function navigateRsc( @@ -2293,6 +2312,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-boundary-render.ts b/packages/vinext/src/server/app-page-boundary-render.ts index 24bab85cb..e29a3064f 100644 --- a/packages/vinext/src/server/app-page-boundary-render.ts +++ b/packages/vinext/src/server/app-page-boundary-render.ts @@ -24,6 +24,7 @@ import { } from "./app-page-stream.js"; import { AppElementsWire, type AppElements } from "./app-elements.js"; import { createAppPageLayoutEntries, createAppPageSourcePage } from "./app-page-route-wiring.js"; +import { NEVER_CACHE_CONTROL } from "./cache-control.js"; // oxlint-disable-next-line @typescript-eslint/no-explicit-any type AppPageComponent = ComponentType; @@ -500,8 +501,8 @@ export async function renderAppPageErrorBoundary( }); }; - const renderWith = (BoundaryComponent: AppPageComponent): Promise => - renderAppPageBoundaryElementResponse({ + const renderWith = async (BoundaryComponent: AppPageComponent): Promise => { + const response = await renderAppPageBoundaryElementResponse({ ...options, element: buildElement(BoundaryComponent), initialDevServerError: rawError, @@ -509,8 +510,16 @@ export async function renderAppPageErrorBoundary( navigationParams: matchedParams, route: options.route, routePattern: options.route?.pattern, - status: 200, + status: errorBoundary.isGlobalError ? 500 : 200, }); + if (errorBoundary.isGlobalError) { + response.headers.set("Cache-Control", NEVER_CACHE_CONTROL); + response.headers.delete("CDN-Cache-Control"); + response.headers.delete("Cloudflare-CDN-Cache-Control"); + response.headers.delete("Cache-Tag"); + } + return response; + }; try { return await renderWith(errorBoundary.component); diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index a453b1218..3a4b1918e 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -253,6 +253,7 @@ type DispatchAppPageOptions = { getNavigationContext: () => NavigationContext | null; getSourceRoute: (sourceRouteIndex: number) => TRoute | undefined; hasGenerateStaticParams: boolean; + hasCustomGlobalError?: boolean; hasPageDefaultExport: boolean; hasPageModule: boolean; handlerStart: number; @@ -1136,6 +1137,7 @@ async function dispatchAppPageInner( return renderPageSpecialError(options, specialError); }, renderToReadableStream: options.renderToReadableStream, + hasCustomGlobalError: options.hasCustomGlobalError, prerenderToReadableStream: options.prerenderToReadableStream, routePattern: route.pattern, runWithSuppressedHookWarning(probe) { diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index efd516478..6304ebcc0 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -57,7 +57,11 @@ import type { ClientReuseManifestSkipDisposition, ClientReuseManifestTraceFields, } from "./client-reuse-manifest.js"; -import { NO_STORE_CACHE_CONTROL } from "./cache-control.js"; +import { + applyCdnResponseHeaders, + NEVER_CACHE_CONTROL, + NO_STORE_CACHE_CONTROL, +} from "./cache-control.js"; import { createClientReuseSkipTransportPlan, createStaticLayoutClientReuseArtifactCompatibility, @@ -127,6 +131,7 @@ type RenderAppPageLifecycleOptions = { peekRequestCacheLife?: () => AppPageRequestCacheLife | null; getDraftModeCookieHeader: () => string | null | undefined; handlerStart: number; + hasCustomGlobalError?: boolean; hasLoadingBoundary: boolean; dynamicStaleTimeSeconds?: number; isDynamicError: boolean; @@ -868,6 +873,7 @@ export async function renderAppPageLifecycle( return renderAppPageHtmlStream({ capturedRscDataRef, fontData, + hasCustomGlobalError: options.hasCustomGlobalError, navigationContext: options.getNavigationContext(), basePath: options.basePath, clientTraceMetadata: options.clientTraceMetadata, @@ -975,6 +981,22 @@ export async function renderAppPageLifecycle( responseKind: "html", }); + if (htmlRender.shellErrorRecovered) { + const response = buildAppPageHtmlResponse(safeHtmlStream, { + draftCookie, + linkHeader, + isEdgeRuntime: options.isEdgeRuntime, + middlewareContext: { + headers: options.middlewareContext.headers, + status: 500, + }, + policy: { cacheControl: NEVER_CACHE_CONTROL }, + timing: htmlResponseTiming, + }); + applyCdnResponseHeaders(response.headers, { cacheControl: NEVER_CACHE_CONTROL }); + return response; + } + const shouldSpeculativelyWriteCache = options.isProduction && shouldCaptureRscForCacheMetadata && diff --git a/packages/vinext/src/server/app-page-stream.ts b/packages/vinext/src/server/app-page-stream.ts index 20bab031f..90eb10a57 100644 --- a/packages/vinext/src/server/app-page-stream.ts +++ b/packages/vinext/src/server/app-page-stream.ts @@ -22,6 +22,7 @@ export type AppSsrRenderResult = { htmlStream: ReadableStream; metadataReady: Promise; capturedRscData: Promise | null; + shellErrorRecovered?: boolean; /** * Preload `Link` header value emitted by React during SSR (via `onHeaders`), * already capped to `reactMaxHeadersLength`. Empty/undefined when React @@ -50,6 +51,7 @@ function normalizeAppSsrRenderResult( htmlStream: raw, metadataReady: resolvedMetadataReady, capturedRscData: fallbackCapturedRscData, + shellErrorRecovered: false, }; } @@ -134,6 +136,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>; }; @@ -170,6 +176,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 = { @@ -185,6 +195,7 @@ type AppPageHtmlStreamRecoveryResult = { response: Response | null; metadataReady: Promise; capturedRscData: Promise | null; + shellErrorRecovered: boolean; /** React-emitted preload `Link` header (already capped). */ linkHeader?: string; }; @@ -232,6 +243,10 @@ export async function renderAppPageHtmlStream( pprFallbackShellSignal: options.pprFallbackShellSignal, 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.waitForAllReady !== true && options.hasCustomGlobalError === false, }; const rawResult = await options.ssrHandler.handleSsr( @@ -336,7 +351,7 @@ export async function renderAppPageHtmlStreamWithRecovery( ): Promise { try { const rawResult = await options.renderHtmlStream(); - const { htmlStream, metadataReady, capturedRscData, linkHeader } = + const { htmlStream, metadataReady, capturedRscData, linkHeader, shellErrorRecovered } = normalizeAppSsrRenderResult(rawResult); options.onShellRendered?.(); return { @@ -344,6 +359,7 @@ export async function renderAppPageHtmlStreamWithRecovery( response: null, metadataReady, capturedRscData, + shellErrorRecovered: shellErrorRecovered === true, linkHeader, }; } catch (error) { @@ -354,6 +370,7 @@ export async function renderAppPageHtmlStreamWithRecovery( response: await options.renderSpecialErrorResponse(specialError), metadataReady: resolvedMetadataReady, capturedRscData: null, + shellErrorRecovered: false, }; } @@ -364,6 +381,7 @@ export async function renderAppPageHtmlStreamWithRecovery( response: boundaryResponse, metadataReady: resolvedMetadataReady, capturedRscData: null, + shellErrorRecovered: false, }; } diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index f29d31d46..4db0b541b 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -49,6 +49,7 @@ 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 { isPprFallbackShellAbortError } from "vinext/shims/ppr-fallback-shell"; +import DefaultGlobalError from "vinext/shims/default-global-error"; import { appendAssetDeploymentIdQuery } from "../utils/deployment-id.js"; /** @@ -146,6 +147,49 @@ async function loadStaticPrerender(): Promise { throw new Error("[vinext] react-dom/static.edge did not expose prerender()."); } +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 renderSsrErrorDocumentShell( + bootstrapModuleUrl?: string, + nonce?: string, +): ReadableStream { + const html = renderToStaticMarkup( + createReactElement(DefaultGlobalError, { + error: null, + }), + ).replace("