Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2a9a102
fix(plugin): resolve vinext/shims/* package subpaths to local shim files
NathanDrake2406 Jun 12, 2026
99789d7
fix(app-router): recover SSR shell render errors via __next_error__ d…
NathanDrake2406 Jun 12, 2026
dab9c72
refactor(review): address PR 1908 non-blocking notes
NathanDrake2406 Jun 12, 2026
7f8b8ba
refactor(review): clarify shell recovery assumptions
james-elicx Jun 12, 2026
f8ff07c
fix(ssr): cancel abandoned prerender streams
james-elicx Jun 12, 2026
ad6daac
refactor(ssr): clarify error shell root options
james-elicx Jun 12, 2026
2a6292a
fix(isr): recover shell errors during regeneration
james-elicx Jun 12, 2026
57b01b4
fix(ssr): scope client recovery to marked shells
james-elicx Jun 12, 2026
2da1242
fix(plugin): explicitly filter vinext shim subpaths
james-elicx Jun 12, 2026
e9f0119
fix(plugin): retain null-prefixed shim resolution
james-elicx Jun 12, 2026
a2f481b
test(nextjs-compat): cover no-boundary shell recovery fallback
NathanDrake2406 Jun 12, 2026
c760b2a
Merge upstream/main into nathan/next-dynamic-css
NathanDrake2406 Jun 13, 2026
001f36a
fix(app-router): preserve shell recovery error semantics
james-elicx Jun 13, 2026
8a259f4
test(app-router): harden shell recovery cache semantics
james-elicx Jun 13, 2026
fe3df57
fix(app-router): mark global error responses uncacheable
james-elicx Jun 13, 2026
e58a313
merge: sync main into next dynamic css
james-elicx Jun 13, 2026
98b93df
test(app-router): align error response status expectations
james-elicx Jun 13, 2026
53aef71
merge: sync main into next dynamic css
james-elicx Jun 14, 2026
384c40e
fix(build): preserve null-prefixed og resolution
james-elicx Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,7 @@ export default __createAppRscHandler({
getSourceRoute(sourceRouteIndex) {
return routes[sourceRouteIndex];
},
hasCustomGlobalError: ${globalErrorVar ? `Boolean(${globalErrorVar}?.default)` : "false"},

Copy link
Copy Markdown
Contributor

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 a global-error file with no default export as "no custom global error" — so an app with a malformed global-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 a global-error without a default export rather than silently falling back. Non-blocking, but a candidate for a follow-up parity check.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boolean(${globalErrorVar}?.default) treats a global-error.tsx that exists but has no default export as "no custom global error", so a malformed global-error file would silently get the __next_error__ recovery shell instead of erroring. That's a reasonable default, but Next.js validates app-router special files and throws on a missing default export rather than silently falling back. Non-blocking — worth a follow-up parity check (also flagged in a prior round).

hasGenerateStaticParams: __generateStaticParams.length > 0,
hasPageDefaultExport: !!PageComponent,
hasPageModule: !!route.page,
Expand Down
12 changes: 10 additions & 2 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)?$)/,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The meaningful change here besides adding vinext\/shims\/ is dropping the ^ anchor from the @vercel/og alternative (^@vercel\/og(?:\.js)?$ -> @vercel\/og(?:\.js)?$). That's a deliberate broadening so \0@vercel/og re-imports pass the filter (the new shims test asserts this), and it's correct — just flagging because prior review rounds described this diff as narrowing next/, which it does not. A one-line note in the filter comment that the @vercel/og alternative is intentionally unanchored (to admit \0-prefixed re-imports) would prevent someone re-adding ^ and silently breaking \0@vercel/og resolution.

},
handler(id, importer) {
// Strip \0 prefix if present — @vitejs/plugin-rsc's generated
Expand All @@ -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;
Expand Down
42 changes: 32 additions & 10 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1647,16 +1647,35 @@ 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]");
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(
Expand Down Expand Up @@ -2293,6 +2312,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;
Expand Down
15 changes: 12 additions & 3 deletions packages/vinext/src/server/app-page-boundary-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
Expand Down Expand Up @@ -500,17 +501,25 @@ export async function renderAppPageErrorBoundary<TModule extends AppPageModule>(
});
};

const renderWith = (BoundaryComponent: AppPageComponent): Promise<Response> =>
renderAppPageBoundaryElementResponse({
const renderWith = async (BoundaryComponent: AppPageComponent): Promise<Response> => {
const response = await renderAppPageBoundaryElementResponse({
...options,
element: buildElement(BoundaryComponent),
initialDevServerError: rawError,
layoutModules,
navigationParams: matchedParams,
route: options.route,
routePattern: options.route?.pattern,
status: 200,
status: errorBoundary.isGlobalError ? 500 : 200,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the unannounced scope expansion (added in 001f36a, after all prior review rounds). The status flip applies to every isGlobalError boundary render — server-component throws, metadata/viewport escalations, self-throwing global-error, and custom global-error.tsx apps — not just the SSR shell-recovery path this PR is titled for. The global-error.test.ts diff confirms 12 cases change from 200 to 500.

The behavior is correct (Next.js returns 500 for unhandled errors that escalate to global-error; the prior 200 was the divergence), so this isn't a blocker. But the PR description lists "changing custom-global-error apps' behavior in any way" as a non-goal and claims global-error.test.ts semantics are "unchanged" — both are now stale. Please update the description to call this out as an intentional parity fix, since a cross-cutting status-code change can affect existing deployments' monitoring/alerting.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirming the prior round's observation still stands at 384c40e: this 200→500 status flip applies to every isGlobalError boundary render (server-component throws, metadata/viewport escalations, self-throwing global-error, and custom global-error.tsx apps) — not only the SSR shell-recovery path the PR is titled for. The global-error.test.ts diff flips 12 cases to 500, and app-router-dev-server.test.ts flips three more (#834 missing-use-client cases and the dynamic = 'error' case).

The behavior is right (Next.js returns 500 for unhandled errors escalated to global-error; the old 200 was the divergence). But the PR description still lists "changing custom-global-error apps' behavior in any way" as a non-goal and claims global-error.test.ts semantics are "unchanged" — both are now stale. Please update the description so this cross-cutting status-code change is called out as a deliberate parity fix, since it can affect monitoring/alerting on existing deployments.

});
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);
Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/src/server/app-page-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ type DispatchAppPageOptions<TRoute extends AppPageDispatchRoute> = {
getNavigationContext: () => NavigationContext | null;
getSourceRoute: (sourceRouteIndex: number) => TRoute | undefined;
hasGenerateStaticParams: boolean;
hasCustomGlobalError?: boolean;
hasPageDefaultExport: boolean;
hasPageModule: boolean;
handlerStart: number;
Expand Down Expand Up @@ -1136,6 +1137,7 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(
return renderPageSpecialError(options, specialError);
},
renderToReadableStream: options.renderToReadableStream,
hasCustomGlobalError: options.hasCustomGlobalError,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This threads the flag into the initial render lifecycle. The background-revalidation handleSsr call in this same file (~line 653, revalidatedSsrEntry.handleSsr(...) with waitForAllReady: true at line 666) does not get the equivalent fallbackToErrorDocumentOnShellError, so revalidation of a throw-to-opt-out route permanently rejects after the first render caches a 200 recovery shell. See the review summary for details — worth threading the flag there too or documenting the divergence.

prerenderToReadableStream: options.prerenderToReadableStream,
routePattern: route.pattern,
runWithSuppressedHookWarning(probe) {
Expand Down
23 changes: 22 additions & 1 deletion packages/vinext/src/server/app-page-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import type {
ClientReuseManifestSkipDisposition,
ClientReuseManifestTraceFields,
} from "./client-reuse-manifest.js";
import { NO_STORE_CACHE_CONTROL } from "./cache-control.js";
import { NEVER_CACHE_CONTROL, NO_STORE_CACHE_CONTROL } from "./cache-control.js";
import {
createClientReuseSkipTransportPlan,
createStaticLayoutClientReuseArtifactCompatibility,
Expand Down Expand Up @@ -127,6 +127,7 @@ type RenderAppPageLifecycleOptions = {
peekRequestCacheLife?: () => AppPageRequestCacheLife | null;
getDraftModeCookieHeader: () => string | null | undefined;
handlerStart: number;
hasCustomGlobalError?: boolean;
hasLoadingBoundary: boolean;
dynamicStaleTimeSeconds?: number;
isDynamicError: boolean;
Expand Down Expand Up @@ -868,6 +869,7 @@ export async function renderAppPageLifecycle(
return renderAppPageHtmlStream({
capturedRscDataRef,
fontData,
hasCustomGlobalError: options.hasCustomGlobalError,
navigationContext: options.getNavigationContext(),
basePath: options.basePath,
clientTraceMetadata: options.clientTraceMetadata,
Expand Down Expand Up @@ -975,6 +977,25 @@ export async function renderAppPageLifecycle(
responseKind: "html",
});

if (htmlRender.shellErrorRecovered) {
const response = buildAppPageHtmlResponse(safeHtmlStream, {
draftCookie,
linkHeader,
isEdgeRuntime: options.isEdgeRuntime,
middlewareContext: {
headers: options.middlewareContext.headers,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed this is safe: buildAppPageHtmlResponse applies policy.cacheControl (no-store) at line 302, then mergeMiddlewareResponseHeaders (line 314) could re-apply a cacheable Cache-Control/CDN-Cache-Control set by middleware — but the explicit set/delete calls at lines 953-956 run after the helper returns, so they win. The app-page-render.test.ts case asserting middleware-supplied cacheable headers are stripped covers this. No change needed; flagging only because the correctness depends on this ordering being preserved — if a future refactor moves the header stripping before buildAppPageHtmlResponse, middleware headers would silently leak back in.

status: 500,
},
policy: { cacheControl: NEVER_CACHE_CONTROL },
timing: htmlResponseTiming,
});
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;
}

const shouldSpeculativelyWriteCache =
options.isProduction &&
shouldCaptureRscForCacheMetadata &&
Expand Down
20 changes: 19 additions & 1 deletion packages/vinext/src/server/app-page-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type AppSsrRenderResult = {
htmlStream: ReadableStream<Uint8Array>;
metadataReady: Promise<void>;
capturedRscData: Promise<ArrayBuffer> | null;
shellErrorRecovered?: boolean;
/**
* Preload `Link` header value emitted by React during SSR (via `onHeaders`),
* already capped to `reactMaxHeadersLength`. Empty/undefined when React
Expand Down Expand Up @@ -50,6 +51,7 @@ function normalizeAppSsrRenderResult(
htmlStream: raw,
metadataReady: resolvedMetadataReady,
capturedRscData: fallbackCapturedRscData,
shellErrorRecovered: false,
};
}

Expand Down Expand Up @@ -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<ReadableStream<Uint8Array> | AppSsrRenderResult>;
};
Expand Down Expand Up @@ -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 = {
Expand All @@ -185,6 +195,7 @@ type AppPageHtmlStreamRecoveryResult = {
response: Response | null;
metadataReady: Promise<void>;
capturedRscData: Promise<ArrayBuffer> | null;
shellErrorRecovered: boolean;
/** React-emitted preload `Link` header (already capped). */
linkHeader?: string;
};
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -336,14 +351,15 @@ export async function renderAppPageHtmlStreamWithRecovery<TSpecialError>(
): Promise<AppPageHtmlStreamRecoveryResult> {
try {
const rawResult = await options.renderHtmlStream();
const { htmlStream, metadataReady, capturedRscData, linkHeader } =
const { htmlStream, metadataReady, capturedRscData, linkHeader, shellErrorRecovered } =
normalizeAppSsrRenderResult(rawResult);
options.onShellRendered?.();
return {
htmlStream,
response: null,
metadataReady,
capturedRscData,
shellErrorRecovered: shellErrorRecovered === true,
linkHeader,
};
} catch (error) {
Expand All @@ -354,6 +370,7 @@ export async function renderAppPageHtmlStreamWithRecovery<TSpecialError>(
response: await options.renderSpecialErrorResponse(specialError),
metadataReady: resolvedMetadataReady,
capturedRscData: null,
shellErrorRecovered: false,
};
}

Expand All @@ -364,6 +381,7 @@ export async function renderAppPageHtmlStreamWithRecovery<TSpecialError>(
response: boundaryResponse,
metadataReady: resolvedMetadataReady,
capturedRscData: null,
shellErrorRecovered: false,
};
}

Expand Down
Loading
Loading