diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index c92b838fa..e71ba78bf 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -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); @@ -240,6 +241,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, isNextDataPathname as __isNextDataPathname, parseNextDataPathname as __parseNextDataPathname } from ${JSON.stringify(_pagesDataRoutePath)}; +import { buildDefaultPagesNotFoundResponse as __buildDefaultPagesNotFoundResponse } from ${JSON.stringify(_pagesDefault404Path)}; import { renderPagesPageResponse as __renderPagesPageResponse } from ${JSON.stringify(_pagesPageResponsePath)}; ${instrumentationImportCode} ${middlewareImportCode} @@ -563,8 +565,7 @@ async function _renderPage(request, url, manifest, middlewareHeaders, options) { if (isDataReq) { return __buildNextDataNotFoundResponse(); } - return new Response("

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); + return __buildDefaultPagesNotFoundResponse(); } const { route, params } = match; diff --git a/packages/vinext/src/server/app-fallback-renderer.ts b/packages/vinext/src/server/app-fallback-renderer.ts index 704db7d11..7378c30be 100644 --- a/packages/vinext/src/server/app-fallback-renderer.ts +++ b/packages/vinext/src/server/app-fallback-renderer.ts @@ -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"; @@ -140,6 +141,16 @@ export function createAppFallbackRenderer( 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 + // 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, @@ -222,7 +233,7 @@ export function createAppFallbackRenderer( resolveChildSegments, rootForbiddenModule, rootLayouts, - rootNotFoundModule, + rootNotFoundModule: effectiveRootNotFoundModule, rootUnauthorizedModule, route, renderToReadableStream: rscRenderer, diff --git a/packages/vinext/src/server/default-not-found-module.ts b/packages/vinext/src/server/default-not-found-module.ts new file mode 100644 index 000000000..62084bc4e --- /dev/null +++ b/packages/vinext/src/server/default-not-found-module.ts @@ -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; diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 044702f92..fac0a63da 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -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. @@ -1305,7 +1306,21 @@ async function renderErrorPage( } } - // No custom error page found — use plain text fallback + // 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 = {}; + 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`); } diff --git a/packages/vinext/src/server/pages-default-404.ts b/packages/vinext/src/server/pages-default-404.ts new file mode 100644 index 000000000..f56e83958 --- /dev/null +++ b/packages/vinext/src/server/pages-default-404.ts @@ -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 = `${STATUS}: ${MESSAGE}

${STATUS}

${MESSAGE}

`; + +/** + * 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; diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index 5c37408db..6ce15d3ea 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -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; @@ -163,10 +164,7 @@ type ResolvePagesPageDataResult = | ResolvePagesPageDataResponseResult; function buildPagesNotFoundResponse(): Response { - return new Response("

404 - Page not found

", { - status: 404, - headers: { "Content-Type": "text/html" }, - }); + return buildDefaultPagesNotFoundResponse(); } function buildPagesDataNotFoundResponse(): Response { diff --git a/packages/vinext/src/shims/default-not-found.tsx b/packages/vinext/src/shims/default-not-found.tsx new file mode 100644 index 000000000..15aedd6c8 --- /dev/null +++ b/packages/vinext/src/shims/default-not-found.tsx @@ -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, + fontWeight: 400, + lineHeight: "49px", + margin: 0, + }, +} satisfies Record; + +const STATUS = 404; +const MESSAGE = "This page could not be found."; + +/** + * Mirrors `` + * 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), + ), + ), + ), + ); +} diff --git a/tests/app-fallback-renderer.test.ts b/tests/app-fallback-renderer.test.ts index 162a48679..77c06a1f7 100644 --- a/tests/app-fallback-renderer.test.ts +++ b/tests/app-fallback-renderer.test.ts @@ -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

. + 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}. // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found @@ -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], @@ -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 () => { diff --git a/tests/pages-default-404.test.ts b/tests/pages-default-404.test.ts new file mode 100644 index 000000000..c7f7925da --- /dev/null +++ b/tests/pages-default-404.test.ts @@ -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 `

404 - Page not found

` 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(""); + }); +}); diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts index af4dc813b..b023b42b1 100644 --- a/tests/pages-page-data.test.ts +++ b/tests/pages-page-data.test.ts @@ -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 () => {