Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
5 changes: 3 additions & 2 deletions packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const _pagesPageResponsePath = resolveEntryPath(
);
const _pagesPageDataPath = resolveEntryPath("../server/pages-page-data.js", import.meta.url);
const _pagesDataRoutePath = resolveEntryPath("../server/pages-data-route.js", import.meta.url);
const _pagesDefault404Path = resolveEntryPath("../server/pages-default-404.js", import.meta.url);
const _pagesNodeCompatPath = resolveEntryPath("../server/pages-node-compat.js", import.meta.url);
const _pagesApiRoutePath = resolveEntryPath("../server/pages-api-route.js", import.meta.url);
const _isrCachePath = resolveEntryPath("../server/isr-cache.js", import.meta.url);
Expand Down Expand Up @@ -215,6 +216,7 @@ import {
import { getScriptNonceFromHeaderSources as __getScriptNonceFromHeaderSources } from ${JSON.stringify(_cspPath)};
import { resolvePagesPageData as __resolvePagesPageData } from ${JSON.stringify(_pagesPageDataPath)};
import { buildNextDataJsonResponse as __buildNextDataJsonResponse, buildNextDataNotFoundResponse as __buildNextDataNotFoundResponse } from ${JSON.stringify(_pagesDataRoutePath)};
import { buildDefaultPagesNotFoundResponse as __buildDefaultPagesNotFoundResponse } from ${JSON.stringify(_pagesDefault404Path)};
import { renderPagesPageResponse as __renderPagesPageResponse } from ${JSON.stringify(_pagesPageResponsePath)};
${instrumentationImportCode}
${middlewareImportCode}
Expand Down Expand Up @@ -482,8 +484,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) {
if (isDataReq) {
return __buildNextDataNotFoundResponse();
}
return new Response("<!DOCTYPE html><html><body><h1>404 - Page not found</h1></body></html>",
{ status: 404, headers: { "Content-Type": "text/html" } });
return __buildDefaultPagesNotFoundResponse();
}

const { route, params } = match;
Expand Down
13 changes: 12 additions & 1 deletion packages/vinext/src/server/app-fallback-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type AppPageBoundaryRoute,
} from "./app-page-boundary-render.js";
import { DEFAULT_GLOBAL_ERROR_MODULE } from "./default-global-error-module.js";
import { DEFAULT_NOT_FOUND_MODULE } from "./default-not-found-module.js";
import type { AppPageFontPreload } from "./app-page-execution.js";
import type { AppPageMiddlewareContext } from "./app-page-response.js";
import type { AppPageSsrHandler } from "./app-page-stream.js";
Expand Down Expand Up @@ -140,6 +141,16 @@ export function createAppFallbackRenderer<TModule extends AppPageModule>(
const effectiveGlobalErrorModule: TModule | null =
globalErrorModule ?? (DEFAULT_GLOBAL_ERROR_MODULE as unknown as TModule);

// When the app does not define `app/not-found.tsx` (and has not opted into
// `app/global-not-found.tsx`), fall back to vinext's built-in default
// not-found component so route-miss 404s render the canonical Next.js
// markup (status + "This page could not be found." message). Matches the
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.

Clean. Mirrors the effectiveGlobalErrorModule pattern perfectly. The as unknown as TModule cast is unavoidable given the generic signature — same trade-off as the global error fallback.

// default not-found UI shipped with Next.js's app loader.
// See packages/vinext/src/shims/default-not-found.tsx and
// packages/vinext/src/server/default-not-found-module.ts.
const effectiveRootNotFoundModule: TModule | null =
rootNotFoundModule ?? (DEFAULT_NOT_FOUND_MODULE as unknown as TModule);

return {
renderHttpAccessFallback(
route,
Expand Down Expand Up @@ -222,7 +233,7 @@ export function createAppFallbackRenderer<TModule extends AppPageModule>(
resolveChildSegments,
rootForbiddenModule,
rootLayouts,
rootNotFoundModule,
rootNotFoundModule: effectiveRootNotFoundModule,
rootUnauthorizedModule,
route,
renderToReadableStream: rscRenderer,
Expand Down
18 changes: 18 additions & 0 deletions packages/vinext/src/server/default-not-found-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import DefaultNotFound from "vinext/shims/default-not-found";

/**
* Module-shaped wrapper around vinext's built-in default not-found component.
* Used as the fallback when an app does not define its own `app/not-found.tsx`
* (and has not opted into `app/global-not-found.tsx`). The runtime treats any
* `{ default: Component }` record as a "not-found module", so wrapping the
* component this way lets us thread the default through the existing
* `rootNotFoundModule` plumbing without introducing a parallel code path.
*
* Mirrors Next.js's `defaultNotFoundPath`
* (`next/dist/client/components/builtin/not-found.js`), which is selected
* automatically when the user has not supplied a custom not-found file:
* https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/next-app-loader/index.ts
*/
export const DEFAULT_NOT_FOUND_MODULE = {
default: DefaultNotFound,
} as const;
19 changes: 17 additions & 2 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
parseCookieLocaleFromHeader,
resolvePagesI18nRequest,
} from "./pages-i18n.js";
import { buildDefaultPagesNotFoundResponse } from "./pages-default-404.js";

/**
* Render a React element to a string using renderToReadableStream.
Expand Down Expand Up @@ -1288,7 +1289,21 @@ async function renderErrorPage(
}
}

// No custom error page found — use plain text fallback
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.

Nit (no action needed): The Response → Node.js ServerResponse bridge here works but is a little ceremonial. If you ever touch this again, buildDefaultPagesNotFoundResponse() could also expose the raw HTML + status directly (or the dev server could use DEFAULT_PAGES_NOT_FOUND_HTML with a manual writeHead). Not worth changing now — just noting it.

// No custom error page found — fall back to vinext's default. The 404 case
// renders the canonical Next.js HTML body (matching `pages/_error.tsx`) so
// dev-server responses include "This page could not be found." just like
// production. Other status codes keep the plain-text fallback because
// Next.js's `_error.tsx` defaults already handle those cases when present.
if (statusCode === 404) {
const defaultResponse = buildDefaultPagesNotFoundResponse();
const headers: Record<string, string> = {};
defaultResponse.headers.forEach((value, key) => {
headers[key] = value;
});
res.writeHead(defaultResponse.status, headers);
res.end(await defaultResponse.text());
return;
}
res.writeHead(statusCode, { "Content-Type": "text/plain" });
res.end(`${statusCode} - ${statusCode === 404 ? "Page not found" : "Internal Server Error"}`);
res.end(`${statusCode} - Internal Server Error`);
}
41 changes: 41 additions & 0 deletions packages/vinext/src/server/pages-default-404.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Default 404 HTML body for the Pages Router.
*
* Used when a Pages Router request does not match any route (and the app has
* not supplied a custom `pages/404.tsx`). Mirrors the markup Next.js's
* `pages/_error.tsx` produces for a 404 response: a centered status / message
* pair plus minified theme CSS and dark-mode media query. The message string
* `"This page could not be found."` (note the trailing period) is the
* canonical body asserted by Next.js's deploy suite
* (`test/e2e/getserversideprops/test/index.test.ts`,
* `test/e2e/basepath/error-pages.test.ts`).
*
* Kept as a hand-rendered HTML literal rather than a React-rendered template
* because the Pages Router server entry is invoked from both Workers and the
* dev server before any React-renderer wiring is available for this path —
* matching the lightweight build-time strategy Next.js uses for its packaged
* `_error` static fallback. See:
* .nextjs-ref/packages/next/src/pages/_error.tsx
*/

const STATUS = 404;
const MESSAGE = "This page could not be found.";

const CSS = `body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}`;

const HTML = `<!DOCTYPE html><html><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width"/><title>${STATUS}: ${MESSAGE}</title><meta name="next-head-count" content="2"/><style data-next-hide-fouc="true">body{display:none}</style><noscript data-next-hide-fouc="true"><style>body{display:block}</style></noscript></head><body><div id="__next"><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div style="line-height:48px"><style>${CSS}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding-right:23px;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">${STATUS}</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:28px">${MESSAGE}</h2></div></div></div></div></body></html>`;
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.

Bug: charSet should be charset in raw HTML.

charSet is the React JSX prop name (camelCase), which React transforms to charset when emitting HTML. But this is a hand-authored HTML string literal, not JSX — the browser receives it as-is. The HTML spec attribute is charset (all lowercase).

Browsers are forgiving here (they handle it case-insensitively), so this won't cause user-visible breakage, but it's technically malformed HTML. Since the goal is to match Next.js's _error.tsx output exactly, it should be charset.


Bug: stray line-height:49px on h1 — not present in Next.js's Pages Router _error.tsx.

Next.js's pages/_error.tsx does NOT set lineHeight on the h1 style object — the h1 inherits line-height: 48px from the parent desc div. The 49px value comes from the App Router access-error-styles.ts, not the Pages Router.

For reference, Next.js's _error.tsx styles.h1 is:

h1: {
  display: 'inline-block',
  margin: '0 20px 0 0',
  paddingRight: 23,
  fontSize: 24,
  fontWeight: 500,
  verticalAlign: 'top',
  // NO lineHeight here
}

Fix both on this line: charSetcharset, and remove line-height:49px from the h1 inline style.


/**
* Build the Next.js-compatible default 404 HTML response for the Pages Router.
* Content-type is `text/html; charset=utf-8`, matching Next.js's
* `pages-handler` 404 response.
*/
export function buildDefaultPagesNotFoundResponse(): Response {
return new Response(HTML, {
status: STATUS,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}

/** Exported for tests / callers that need the raw HTML body. */
export const DEFAULT_PAGES_NOT_FOUND_HTML = HTML;
6 changes: 2 additions & 4 deletions packages/vinext/src/server/pages-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type PagesGsspResponse,
type PagesI18nRenderContext,
} from "./pages-page-response.js";
import { buildDefaultPagesNotFoundResponse } from "./pages-default-404.js";

type PagesRedirectResult = {
destination: string;
Expand Down Expand Up @@ -163,10 +164,7 @@ type ResolvePagesPageDataResult =
| ResolvePagesPageDataResponseResult;

function buildPagesNotFoundResponse(): Response {
return new Response("<!DOCTYPE html><html><body><h1>404 - Page not found</h1></body></html>", {
status: 404,
headers: { "Content-Type": "text/html" },
});
return buildDefaultPagesNotFoundResponse();
}

function buildPagesDataNotFoundResponse(): Response {
Expand Down
90 changes: 90 additions & 0 deletions packages/vinext/src/shims/default-not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Ported from Next.js's built-in default not-found component:
* https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/builtin/not-found.tsx
* https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/http-access-fallback/error-fallback.tsx
*
* Rendered when an App Router request resolves to a 404 and the user has not
* supplied their own `app/not-found.tsx` (or `app/global-not-found.tsx`).
* Matches Next.js's `HTTPAccessErrorFallback` exactly: a centered 404 / message
* pair with minified theme CSS and dark-mode media query.
*
* The message string `"This page could not be found."` (note the trailing
* period) is the canonical body asserted by Next.js's deploy suite
* (`test/e2e/app-dir/prefetching-not-found/prefetching-not-found.test.ts`,
* `test/e2e/basepath/error-pages.test.ts`).
*/
import React from "react";

const styles = {
error: {
fontFamily:
'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',
height: "100vh",
textAlign: "center" as const,
display: "flex",
flexDirection: "column" as const,
alignItems: "center",
justifyContent: "center",
},
desc: {
display: "inline-block",
},
h1: {
display: "inline-block",
margin: "0 20px 0 0",
padding: "0 23px 0 0",
fontSize: 24,
fontWeight: 500,
verticalAlign: "top",
lineHeight: "49px",
},
h2: {
fontSize: 14,
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.

App Router component looks correct — styles match Next.js's access-error-styles.ts (h1.lineHeight: "49px", h2.lineHeight: "49px", padding: "0 23px 0 0"). Note this intentionally differs from the Pages Router template (which should use 48px / no h1 lineHeight per _error.tsx). Two different Next.js upstream sources, two different vinext implementations — correct.

fontWeight: 400,
lineHeight: "49px",
margin: 0,
},
} satisfies Record<string, React.CSSProperties>;

const STATUS = 404;
const MESSAGE = "This page could not be found.";

/**
* Mirrors `<HTTPAccessErrorFallback status={404} message="This page could not be found." />`
* from Next.js. Kept in sync with the upstream component's structure so HTML
* snapshot diffs between Next.js and vinext stay minimal.
*/
export default function DefaultNotFound(): React.ReactElement {
return React.createElement(
React.Fragment,
null,
React.createElement("title", null, `${STATUS}: ${MESSAGE}`),
React.createElement(
"div",
{ style: styles.error },
React.createElement(
"div",
null,
React.createElement("style", {
dangerouslySetInnerHTML: {
__html:
"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}",
},
}),
React.createElement(
"h1",
{
className: "next-error-h1",
style: styles.h1,
},
STATUS,
),
React.createElement(
"div",
{ style: styles.desc },
React.createElement("h2", { style: styles.h2 }, MESSAGE),
),
),
),
);
}
52 changes: 48 additions & 4 deletions tests/app-fallback-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,48 @@ describe("app fallback renderer default global error UI", () => {
});
});

// Regression for #1454 — default App Router 404 must match Next.js's built-in
// not-found component ("This page could not be found." with trailing period).
describe("app fallback renderer default not-found UI", () => {
it("renders the canonical 'This page could not be found.' body when no not-found.tsx exists", async () => {
const { renderer } = createRenderer();
const request = new Request("https://example.com/missing");

const response = await renderer.renderNotFound(null, false, request, undefined, undefined, {
headers: null,
status: null,
});

expect(response?.status).toBe(404);
const html = await response?.text();
// Canonical message must contain the trailing period to match Next.js
// (see .nextjs-ref/packages/next/src/client/components/builtin/not-found.tsx).
expect(html).toContain("This page could not be found.");
// Status code is surfaced as the <h1>.
expect(html).toContain("404");
// Old vinext default ("404 - Page not found") must NOT leak through.
expect(html).not.toContain("404 - Page not found");
});

it("prefers a user-defined root not-found.tsx over the default", async () => {
const { renderer } = createRenderer({ rootNotFoundModule: notFoundModule });
const request = new Request("https://example.com/missing");

const response = await renderer.renderNotFound(null, false, request, undefined, undefined, {
headers: null,
status: null,
});

expect(response?.status).toBe(404);
const html = await response?.text();
// The user-defined boundary wins.
expect(html).toContain('data-boundary="not-found"');
expect(html).toContain("Missing page");
// The default not-found body must NOT leak through.
expect(html).not.toContain("This page could not be found.");
});
});

// Mirrors Next.js 16 experimental.globalNotFound behavior.
// Ported from Next.js: test/e2e/app-dir/global-not-found/{basic,both-present,not-present}.
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.

Nice behavioral change. This test previously asserted null (renderer gave up), and now it correctly asserts the built-in default renders. Callers of renderNotFound no longer need their own fallback path for the "no not-found module" case — a simplification.

// Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found
Expand Down Expand Up @@ -581,8 +623,9 @@ describe("app fallback renderer with globalNotFoundModule", () => {
// Mirrors test/e2e/app-dir/global-not-found/not-present: when the user
// opted into experimental.globalNotFound but never created the file,
// route-miss 404s should still serve the default 404 response. With no
// root notFoundModule either, the renderer returns null and the caller
// falls back to the framework's 404 Response.
// user-defined root notFoundModule either, vinext renders its built-in
// default not-found component (parity with Next.js's packaged
// not-found.tsx — "This page could not be found." with trailing period).
const { renderer } = createRenderer({
globalNotFoundModule: null,
rootLayoutModules: [rootLayoutModule],
Expand All @@ -594,8 +637,9 @@ describe("app fallback renderer with globalNotFoundModule", () => {
status: null,
});

// No boundary component to render -> renderer returns null.
expect(response).toBeNull();
expect(response?.status).toBe(404);
const html = await response?.text();
expect(html).toContain("This page could not be found.");
});

it("does not use global-not-found for non-404 access fallbacks (403, 401)", async () => {
Expand Down
40 changes: 40 additions & 0 deletions tests/pages-default-404.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Regression for #1454.
*
* The Pages Router default 404 response (used when no `pages/404.tsx` and no
* `pages/_error.tsx` is defined) must include the canonical Next.js body
* `"This page could not be found."` (with trailing period). Pre-fix vinext
* shipped a minimal `<h1>404 - Page not found</h1>` placeholder that broke
* deploy-suite parity against `test/e2e/getserversideprops/test/index.test.ts`
* and `test/e2e/basepath/error-pages.test.ts`.
*/
import { describe, expect, it } from "vitest";
import {
buildDefaultPagesNotFoundResponse,
DEFAULT_PAGES_NOT_FOUND_HTML,
} from "../packages/vinext/src/server/pages-default-404.js";

describe("buildDefaultPagesNotFoundResponse", () => {
it("returns a 404 status with the canonical Next.js body", async () => {
const response = buildDefaultPagesNotFoundResponse();
expect(response.status).toBe(404);
const body = await response.text();
// The Next.js deploy suite asserts on this substring (with the trailing
// period — see test/e2e/basepath/error-pages.test.ts).
expect(body).toContain("This page could not be found.");
// The 404 status code is rendered in the heading.
expect(body).toContain("404");
// Old vinext placeholder body must NOT leak through.
expect(body).not.toContain("404 - Page not found");
});

it("uses Next.js-compatible content-type", () => {
const response = buildDefaultPagesNotFoundResponse();
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
});

it("exposes the raw HTML body for callers that need it", () => {
expect(DEFAULT_PAGES_NOT_FOUND_HTML).toContain("This page could not be found.");
expect(DEFAULT_PAGES_NOT_FOUND_HTML).toContain("<!DOCTYPE html>");
});
});
2 changes: 1 addition & 1 deletion tests/pages-page-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ describe("pages page data", () => {
throw new Error("expected response result");
}
expect(result.response.status).toBe(404);
await expect(result.response.text()).resolves.toContain("404 - Page not found");
await expect(result.response.text()).resolves.toContain("This page could not be found.");
});

it("short-circuits getServerSideProps responses after res.end()", async () => {
Expand Down