diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index 654b4ef577..bd91e808c8 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -65,6 +65,7 @@ export async function generateClientEntry( const apiRoutes = await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); const appFilePath = findFileWithExts(pagesDir, "_app", fileMatcher); + const errorFilePath = findFileWithExts(pagesDir, "_error", fileMatcher); const hasApp = appFilePath !== null; const appPrefetchRoutes = options.appPrefetchRoutes ?? []; const pagesPrefetchRoutes: VinextPagesLinkPrefetchRoute[] = [ @@ -102,6 +103,11 @@ export async function generateClientEntry( // lgtm[js/bad-code-sanitization] return ` ${JSON.stringify(nextFormatPattern)}: () => import(${JSON.stringify(absPath)})`; }); + loaderEntries.push( + errorFilePath !== null + ? ` "/_error": () => import(${JSON.stringify(errorFilePath)})` + : ' "/_error": () => import("next/error")', + ); const appFileBase = appFilePath ?? undefined; diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 4432cc566a..e4bcc8d70e 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -95,7 +95,7 @@ export async function generateServerEntry( const errorImportCode = errorFilePath !== null ? `import * as ErrorPageModule from ${JSON.stringify(errorFilePath)};` - : `const ErrorPageModule = null;`; + : `import * as ErrorPageModule from "next/error";`; // Serialize i18n config for embedding in the server entry const i18nConfigJson = nextConfig?.i18n @@ -301,16 +301,14 @@ export const pageRoutes = [ ${pageRouteEntries.join(",\n")} ]; const _pageRouteTrie = _buildRouteTrie(pageRoutes); -const _errorPageRoute = ErrorPageModule - ? { - pattern: "/_error", - patternParts: ["_error"], - isDynamic: false, - params: [], - module: ErrorPageModule, - filePath: ${errorAssetPathJson}, - } - : null; +const _errorPageRoute = { + pattern: "/_error", + patternParts: ["_error"], + isDynamic: false, + params: [], + module: ErrorPageModule, + filePath: ${errorAssetPathJson}, +}; const apiRoutes = [ ${apiRouteEntries.join(",\n")} diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index dae2cbfcd7..eda6106883 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -69,6 +69,7 @@ import { type PagesStaticPathsEntry, } from "./pages-page-data.js"; import { createPagesDevAssetUrl, createPagesDevModuleUrl } from "./pages-dev-module-url.js"; +import { createPagesDevHydrationScript } from "./pages-dev-hydration.js"; import { getManifestFilesForModule } from "./pages-asset-tags.js"; import { isSerializableProps } from "./pages-serializable-props.js"; import { @@ -758,7 +759,23 @@ export function createSSRHandler( return; } // No route matched — try to render custom 404 page - await renderErrorPage(server, runner, req, res, url, pagesDir, 404, undefined, matcher); + const requestContext = createRequestContext(); + await runWithRequestContext(requestContext, async () => { + await _alsRegistration; + await renderErrorPage( + server, + runner, + req, + res, + url, + pagesDir, + 404, + undefined, + matcher, + undefined, + reactStrictMode, + ); + }); return; } @@ -975,6 +992,8 @@ export function createSSRHandler( 404, routerShim.wrapWithRouterContext, matcher, + undefined, + reactStrictMode, ); return; } @@ -1140,6 +1159,9 @@ export function createSSRHandler( pagesDir, 404, routerShim.wrapWithRouterContext, + undefined, + undefined, + reactStrictMode, ); return; } @@ -1591,6 +1613,9 @@ export function createSSRHandler( pagesDir, 404, routerShim.wrapWithRouterContext, + undefined, + undefined, + reactStrictMode, ); return; } @@ -1832,83 +1857,13 @@ export function createSSRHandler( }, }; - // Hydration entry: inline script that imports the page and hydrates. - // Stores the React root and page loader for client-side navigation. - const hydrationScript = ` -`; + const hydrationScript = createPagesDevHydrationScript({ + appModuleSource, + pageModuleSource, + reactStrictMode: reactStrictMode === true, + replaceFallbackRoute: true, + scriptNonce, + }); const nextDataScript = ``; + const errorHydrationScript = createPagesDevHydrationScript({ + appModuleSource, + forceRouterReady: true, + normalizePageProps: false, + pageModuleSource: errorModuleSource, + reactStrictMode: reactStrictMode === true, + scriptNonce, + setPagePatternsFromNextData: true, + }); + const errorScripts = `${errorNextDataScript}\n${errorHydrationScript}`; if (DocumentComponent) { - const errorPathname = candidate === "_error" ? "/_error" : `/${candidate}`; await streamPageToResponse(res, element, { url, server, fontHeadHTML: "", assetHeadHTML, - scripts: "", + scripts: errorScripts, DocumentComponent, statusCode, documentContext: { err, - pathname: errorPathname, + pathname: errorPage, query: parseQuery(url), asPath: url, req, @@ -2293,6 +2312,7 @@ async function renderErrorPage(
${bodyHtml}
+ ${errorScripts} `; const transformedHtml = await server.transformIndexHtml(url, html); @@ -2304,14 +2324,15 @@ async function renderErrorPage( if (res.headersSent || res.writableEnded) return; // This candidate doesn't exist, try next continue; + } finally { + if (typeof errorRouterShim?.setSSRContext === "function") { + errorRouterShim.setSSRContext(null); + } } } - // 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. + // Defensive fallback for a missing or invalid framework error module. The + // normal no-user-error-file path resolves `next/error` in the candidate loop. if (statusCode === 404) { const defaultResponse = buildDefaultPagesNotFoundResponse(); const headers: Record = {}; diff --git a/packages/vinext/src/server/pages-dev-hydration.ts b/packages/vinext/src/server/pages-dev-hydration.ts new file mode 100644 index 0000000000..cfbf6c5f98 --- /dev/null +++ b/packages/vinext/src/server/pages-dev-hydration.ts @@ -0,0 +1,104 @@ +import { createNonceAttribute } from "./html.js"; + +export type PagesDevHydrationOptions = { + appModuleSource: string | null; + forceRouterReady?: boolean; + normalizePageProps?: boolean; + pageModuleSource: string; + reactStrictMode: boolean; + replaceFallbackRoute?: boolean; + scriptNonce?: string; + setPagePatternsFromNextData?: boolean; +}; + +export function createPagesDevHydrationScript(options: PagesDevHydrationOptions): string { + const nonceAttr = createNonceAttribute(options.scriptNonce); + const initializeRouter = options.forceRouterReady + ? "_initializePagesRouterReadyFromNextData(nextData, true);" + : "_initializePagesRouterReadyFromNextData(nextData);"; + const pagePatterns = options.setPagePatternsFromNextData + ? "window.__VINEXT_PAGE_PATTERNS__ = [nextData.page];" + : ""; + const pageProps = + options.normalizePageProps === false + ? "const pageProps = rawPageProps ?? {};" + : 'const pageProps = rawPageProps && typeof rawPageProps === "object" ? rawPageProps : {};'; + const fallbackReplacement = options.replaceFallbackRoute + ? ` + if (nextData.isFallback) { + await Router.replace(window.location.pathname + window.location.search + window.location.hash, undefined, { _h: 1, scroll: false }); + }` + : ""; + const createElement = options.appModuleSource + ? ` + const appModule = await import(${JSON.stringify(options.appModuleSource)}); + const AppComponent = appModule.default; + window.__VINEXT_APP__ = AppComponent; + const appRouter = ${options.forceRouterReady ? "{ ...Router, isReady: true }" : "Router"}; + element = React.createElement(AppComponent, { + ...props, + Component: PageComponent, + pageProps: rawPageProps, + router: appRouter, + }); + ` + : ` + element = React.createElement(PageComponent, pageProps); + `; + + return ` +`; +} diff --git a/packages/vinext/src/server/pages-page-handler.ts b/packages/vinext/src/server/pages-page-handler.ts index 1382e9ab4c..7f9aba96ea 100644 --- a/packages/vinext/src/server/pages-page-handler.ts +++ b/packages/vinext/src/server/pages-page-handler.ts @@ -596,13 +596,18 @@ export function createPagesPageHandler( const parsedRouteUrl = new URL(routeUrl, originalRequestUrl); const routePathname = parsedRouteUrl.pathname || "/"; const pagesResolvedUrl = routePathname + originalRequestUrl.search; - const createPageReqRes = () => - createPagesReqRes({ + const createPageReqRes = () => { + const reqRes = createPagesReqRes({ body: undefined, query, request, url: originalRequestPathAndSearch, }); + if (typeof renderStatusCode === "number") { + reqRes.res.statusCode = renderStatusCode; + } + return reqRes; + }; const pageDataResult = await resolvePagesPageData({ isDataReq, @@ -694,7 +699,11 @@ export function createPagesPageHandler( let pageProps = pageDataResult.pageProps; let renderProps = pageDataResult.props; if (previewData !== false) renderProps = { ...renderProps, __N_PREVIEW: true }; - if (routePattern === "/_error" && typeof renderStatusCode === "number") { + if ( + routePattern === "/_error" && + typeof renderStatusCode === "number" && + renderProps.pageProps !== undefined + ) { pageProps = { ...pageProps, statusCode: renderStatusCode }; renderProps = { ...renderProps, pageProps }; } diff --git a/packages/vinext/src/shims/error.tsx b/packages/vinext/src/shims/error.tsx index fc2fe53b50..ed18f7436d 100644 --- a/packages/vinext/src/shims/error.tsx +++ b/packages/vinext/src/shims/error.tsx @@ -10,74 +10,134 @@ * `next/error`'s public surface. */ import React from "react"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import Head from "./head.js"; import { isNextRouterError } from "./navigation.js"; import { useUntrackedPathname } from "./internal/navigation-untracked.js"; import { AppRouterContext, type AppRouterInstance } from "./internal/app-router-context.js"; import { RouterContext } from "./internal/router-context.js"; -type ErrorProps = { +const statusCodes: Record = { + 400: "Bad Request", + 404: "This page could not be found", + 405: "Method Not Allowed", + 500: "Internal Server Error", +}; + +export type ErrorProps = { statusCode: number; + hostname?: string; title?: string; withDarkMode?: boolean; }; -function ErrorComponent({ statusCode, title }: ErrorProps): React.ReactElement { - const defaultTitle = - statusCode === 404 ? "This page could not be found" : "Internal Server Error"; +type ErrorPageContext = { + err?: (Error & { statusCode?: number }) | null; + req?: IncomingMessage; + res?: ServerResponse; +}; + +function getErrorInitialProps({ err, req, res }: ErrorPageContext): ErrorProps { + const statusCode = res?.statusCode ? res.statusCode : err ? err.statusCode! : 404; + let hostname: string | undefined; - const displayTitle = title ?? defaultTitle; + if (typeof window !== "undefined") { + hostname = window.location.hostname; + } else if (req) { + if (req.url) { + try { + hostname = new URL(req.url).hostname; + } catch { + // Node Pages requests commonly expose a path-only URL, so use the + // request Host header below when no absolute request URL is available. + } + } - return React.createElement( - "div", - { - style: { - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', - height: "100vh", - textAlign: "center" as const, - display: "flex", - flexDirection: "column" as const, - alignItems: "center", - justifyContent: "center", - }, - }, - React.createElement( + if (!hostname) { + const host = Array.isArray(req.headers?.host) ? req.headers.host[0] : req.headers?.host; + if (host) hostname = new URL(`http://${host}`).hostname; + } + } + + return { statusCode, hostname }; +} + +const styles: Record = { + error: { + fontFamily: + 'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"', + height: "100vh", + textAlign: "center", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + }, + desc: { lineHeight: "48px" }, + h1: { + display: "inline-block", + margin: "0 20px 0 0", + paddingRight: 23, + fontSize: 24, + fontWeight: 500, + verticalAlign: "top", + }, + h2: { fontSize: 14, fontWeight: 400, lineHeight: "28px" }, + wrap: { display: "inline-block" }, +}; + +class ErrorComponent

extends React.Component

{ + static displayName = "ErrorPage"; + static getInitialProps = getErrorInitialProps; + static origGetInitialProps = getErrorInitialProps; + + render(): React.ReactElement { + const { statusCode, hostname, title: customTitle, withDarkMode = true } = this.props; + const title = customTitle || statusCodes[statusCode] || "An unexpected error has occurred"; + + return React.createElement( "div", - null, + { style: styles.error }, React.createElement( - "h1", - { - style: { - display: "inline-block", - margin: "0 20px 0 0", - padding: "0 23px 0 0", - fontSize: 24, - fontWeight: 500, - verticalAlign: "top", - lineHeight: "49px", - borderRight: "1px solid rgba(0, 0, 0, .3)", - }, - }, - statusCode, + Head, + null, + React.createElement( + "title", + null, + statusCode + ? `${statusCode}: ${title}` + : "Application error: a client-side exception has occurred", + ), ), React.createElement( "div", - { style: { display: "inline-block" } }, - React.createElement( - "h2", - { - style: { - fontSize: 14, - fontWeight: 400, - lineHeight: "49px", - margin: 0, - }, + { style: styles.desc }, + React.createElement("style", { + dangerouslySetInnerHTML: { + __html: `body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}${ + withDarkMode + ? "@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}" + : "" + }`, }, - displayTitle + ".", + }), + statusCode + ? React.createElement("h1", { className: "next-error-h1", style: styles.h1 }, statusCode) + : null, + React.createElement( + "div", + { style: styles.wrap }, + React.createElement( + "h2", + { style: styles.h2 }, + customTitle || statusCode + ? `${title}.` + : `Application error: a client-side exception has occurred${hostname ? ` while loading ${hostname}` : ""} (see the browser console for more information).`, + ), ), ), - ), - ); + ); + } } export default ErrorComponent; diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts index b1dbace7b6..476ffd5399 100644 --- a/packages/vinext/src/shims/next-shims.d.ts +++ b/packages/vinext/src/shims/next-shims.d.ts @@ -8,6 +8,20 @@ declare module "next" { import type { IncomingMessage, ServerResponse } from "node:http"; + import type { ParsedUrlQuery } from "node:querystring"; + import type { ComponentType } from "react"; + export type NextPageContext = { + err?: (Error & { statusCode?: number }) | null; + req?: IncomingMessage; + res?: ServerResponse; + pathname: string; + query: ParsedUrlQuery; + asPath?: string; + locale?: string; + locales?: readonly string[]; + defaultLocale?: string; + AppTree: ComponentType<{ pageProps: unknown; [name: string]: unknown }>; + }; type Env = { [key: string]: string | undefined }; export type PreviewData = string | false | object | undefined; // oxlint-disable-next-line typescript/consistent-type-definitions @@ -430,16 +444,37 @@ declare module "next/legacy/image" { } declare module "next/error" { + import type { IncomingMessage, ServerResponse } from "node:http"; + import type { ParsedUrlQuery } from "node:querystring"; + import * as React from "react"; import { ComponentType, ReactNode } from "react"; - type ErrorProps = { + type ErrorPageContext = { + err?: (Error & { statusCode?: number }) | null; + req?: IncomingMessage; + res?: ServerResponse; + pathname: string; + query: ParsedUrlQuery; + asPath?: string; + locale?: string; + locales?: readonly string[]; + defaultLocale?: string; + AppTree: ComponentType<{ pageProps: unknown; [name: string]: unknown }>; + }; + + export type ErrorProps = { statusCode: number; + hostname?: string; title?: string; withDarkMode?: boolean; }; - const ErrorComponent: ComponentType; - export default ErrorComponent; + export default class ErrorComponent

extends React.Component

{ + static displayName: string; + static getInitialProps: (context: ErrorPageContext) => ErrorProps | Promise; + static origGetInitialProps: (context: ErrorPageContext) => ErrorProps | Promise; + render(): React.ReactNode; + } export type ErrorInfo = { error: unknown; diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index ee1981628a..6ad852219e 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -1373,13 +1373,14 @@ function markPagesRouterReady(): boolean { return true; } -function initializePagesRouterReadyFromNextData(nextData: VinextNextData): void { +function initializePagesRouterReadyFromNextData( + nextData: VinextNextData, + forceReady = false, +): void { if (typeof window === "undefined") return; - routerRuntimeState.pagesRouterReady = getPagesNavigationIsReadyFromSerializedState( - nextData.page, - window.location.search, - nextData, - ); + routerRuntimeState.pagesRouterReady = + forceReady || + getPagesNavigationIsReadyFromSerializedState(nextData.page, window.location.search, nextData); } function markPagesRouterHydrated(): void { diff --git a/tests/e2e/pages-router-prod/custom-error-navigation.browser.spec.ts b/tests/e2e/pages-router-prod/custom-error-navigation.browser.spec.ts new file mode 100644 index 0000000000..e0a3402c7d --- /dev/null +++ b/tests/e2e/pages-router-prod/custom-error-navigation.browser.spec.ts @@ -0,0 +1,125 @@ +import fs from "node:fs/promises"; +import type { Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { test as base, expect } from "../fixtures"; +import { waitForHydration } from "../helpers"; + +type ProductionApp = { + baseUrl: string; +}; + +async function closeServer(server: Server): Promise { + const closed = new Promise((resolve) => server.close(() => resolve())); + server.closeIdleConnections(); + server.closeAllConnections(); + await closed; +} + +async function buildAndServeProductionFixture(): Promise<{ + fixtureRoot: string; + server: Server; + app: ProductionApp; +}> { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-pages-custom-error-")); + const pagesDir = path.join(fixtureRoot, "pages"); + + await fs.symlink( + path.resolve(process.cwd(), "tests/fixtures/pages-basic/node_modules"), + path.join(fixtureRoot, "node_modules"), + "junction", + ); + await fs.mkdir(pagesDir, { recursive: true }); + await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"type":"module"}\n'); + await fs.writeFile( + path.join(pagesDir, "index.tsx"), + `import Link from "next/link"; + +export default function Home() { + return Missing; +} +`, + ); + await fs.writeFile( + path.join(pagesDir, "_error.tsx"), + `export default function CustomError({ statusCode }) { + return

Custom error {statusCode}
; +} + +CustomError.getInitialProps = ({ res, err }) => ({ + statusCode: res?.statusCode ?? err?.statusCode ?? 404, +}); +`, + ); + + const configFile = path.join(fixtureRoot, "vite.config.ts"); + const vinextSource = path.resolve(process.cwd(), "packages/vinext/src/index.ts"); + await fs.writeFile( + configFile, + `import { defineConfig } from "vite"; +import vinext from ${JSON.stringify(pathToFileURL(vinextSource).href)}; + +export default defineConfig({ + plugins: [vinext()], +}); +`, + ); + + const { createBuilder } = await import("vite"); + const builder = await createBuilder({ + root: fixtureRoot, + configFile, + logLevel: "silent", + }); + await builder.buildApp(); + + const { startProdServer } = await import( + pathToFileURL(path.resolve(process.cwd(), "packages/vinext/dist/server/prod-server.js")).href + ); + const started = await startProdServer({ + host: "127.0.0.1", + port: 0, + outDir: path.join(fixtureRoot, "dist"), + noCompression: true, + }); + + return { + fixtureRoot, + server: started.server, + app: { baseUrl: `http://127.0.0.1:${started.port}` }, + }; +} + +/* oxlint-disable eslint-plugin-react-hooks/rules-of-hooks -- Playwright fixture `use`, not a React hook */ +const test = base.extend<{ productionApp: ProductionApp }>({ + productionApp: async ({ page }, use) => { + const { fixtureRoot, server, app } = await buildAndServeProductionFixture(); + + try { + await use(app); + } finally { + await page.close(); + await closeServer(server); + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }, +}); +/* oxlint-enable eslint-plugin-react-hooks/rules-of-hooks */ + +test.setTimeout(60_000); + +// Ported from Next.js: test/e2e/no-page-props/no-page-props.test.ts +// https://github.com/vercel/next.js/blob/v16.3.0-canary.80/test/e2e/no-page-props/no-page-props.test.ts +test("client navigation loads the custom pages/_error component", async ({ + page, + productionApp, +}) => { + await page.goto(productionApp.baseUrl, { waitUntil: "load" }); + await waitForHydration(page); + + await page.getByTestId("missing-link").click(); + + await expect(page.getByTestId("custom-error")).toHaveText("Custom error 404"); + await expect(page).toHaveURL(`${productionApp.baseUrl}/missing`); +}); diff --git a/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts new file mode 100644 index 0000000000..0dfa5815b6 --- /dev/null +++ b/tests/e2e/pages-router-prod/default-error-dev.browser.spec.ts @@ -0,0 +1,181 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { ViteDevServer } from "vite"; +import { test as base, expect } from "../fixtures"; +import { waitForHydration } from "../helpers"; + +type DevelopmentApp = { + baseUrl: string; +}; + +async function createDevelopmentFixture(): Promise<{ + fixtureRoot: string; + server: ViteDevServer; + app: DevelopmentApp; +}> { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-pages-default-error-dev-")); + const pagesDir = path.join(fixtureRoot, "pages"); + + await fs.symlink( + path.resolve(process.cwd(), "tests/fixtures/pages-basic/node_modules"), + path.join(fixtureRoot, "node_modules"), + "junction", + ); + await fs.mkdir(pagesDir, { recursive: true }); + await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"type":"module"}\n'); + await fs.writeFile( + path.join(pagesDir, "_app.tsx"), + `import { useEffect, useState } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { useRouter } from "next/router"; + +export default function App({ Component, pageProps, customEnvelope, router }) { + const [hydrated, setHydrated] = useState(false); + const contextRouter = useRouter(); + useEffect(() => setHydrated(true), []); + + return ( +
+ +
+ ); +} + +App.getInitialProps = async ({ AppTree, ctx, router }) => { + if (ctx.asPath === "/missing-a") await new Promise((resolve) => setTimeout(resolve, 50)); + const appTreeHtml = renderToStaticMarkup(); + const appTreeHasRouter = appTreeHtml.includes('data-router-pathname="/_error"') + && appTreeHtml.includes('data-context-as-path="' + ctx.asPath + '"'); + return { + customEnvelope: router.route === "/_error" && router.isReady === true && appTreeHasRouter + ? "preserved" + : "broken", + }; +}; +`, + ); + await fs.writeFile( + path.join(pagesDir, "index.tsx"), + `export default function Home() { return
Home
; }\n`, + ); + + const configFile = path.join(fixtureRoot, "vite.config.ts"); + const vinextSource = path.resolve(process.cwd(), "packages/vinext/src/index.ts"); + await fs.writeFile( + configFile, + `import { defineConfig } from "vite"; +import vinext from ${JSON.stringify(pathToFileURL(vinextSource).href)}; + +export default defineConfig({ + plugins: [vinext()], +}); +`, + ); + + const { createServer } = await import("vite"); + const server = await createServer({ + root: fixtureRoot, + configFile, + logLevel: "silent", + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + + const baseUrl = server.resolvedUrls?.local[0]; + if (!baseUrl) { + await server.close(); + await fs.rm(fixtureRoot, { recursive: true, force: true }); + throw new Error("Vite dev server did not expose a local URL"); + } + + return { fixtureRoot, server, app: { baseUrl } }; +} + +/* oxlint-disable eslint-plugin-react-hooks/rules-of-hooks -- Playwright fixture `use`, not a React hook */ +const test = base.extend<{ developmentApp: DevelopmentApp }>({ + developmentApp: async ({ page }, use) => { + const { fixtureRoot, server, app } = await createDevelopmentFixture(); + + try { + await use(app); + } finally { + await page.close(); + await server.close(); + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }, +}); +/* oxlint-enable eslint-plugin-react-hooks/rules-of-hooks */ + +test.setTimeout(30_000); + +test.describe("Pages Router framework error page development fallback", () => { + test("preserves a custom app envelope without pageProps", async ({ + page, + developmentApp, + consoleErrors, + }) => { + const response = await page.goto(`${developmentApp.baseUrl}missing?x=1`, { + waitUntil: "load", + }); + + expect(response?.status()).toBe(404); + await expect(page.locator("#__next h1")).toHaveCount(0); + await expect(page.locator("#__next h2")).toHaveText( + "Application error: a client-side exception has occurred (see the browser console for more information).", + ); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-envelope", "preserved"); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-has-router", "true"); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-router-pathname", "/_error"); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-router-ready", "true"); + await expect(page.getByTestId("custom-app")).toHaveAttribute( + "data-context-pathname", + "/_error", + ); + await expect(page.getByTestId("custom-app")).toHaveAttribute( + "data-context-as-path", + "/missing?x=1", + ); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-context-ready", "true"); + await waitForHydration(page); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-hydrated", "true"); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-router-ready", "true"); + await expect(page.getByTestId("custom-app")).toHaveAttribute("data-context-ready", "true"); + + const nextData = await page.evaluate(() => window.__NEXT_DATA__); + expect(nextData.props).toEqual({ customEnvelope: "preserved" }); + + const unexpectedErrors = consoleErrors.filter( + (message) => + !/^Failed to load resource: the server responded with a status of 404 \(.+\)$/.test( + message, + ), + ); + expect(unexpectedErrors).toEqual([]); + consoleErrors.length = 0; + }); + + test("isolates router context across concurrent missing routes", async ({ developmentApp }) => { + const [first, second] = await Promise.all([ + fetch(`${developmentApp.baseUrl}missing-a`).then((response) => response.text()), + fetch(`${developmentApp.baseUrl}missing-b`).then((response) => response.text()), + ]); + + expect(first).toContain('data-context-as-path="/missing-a"'); + expect(second).toContain('data-context-as-path="/missing-b"'); + expect(first).toContain('data-envelope="preserved"'); + expect(second).toContain('data-envelope="preserved"'); + }); +}); diff --git a/tests/e2e/pages-router-prod/default-error.browser.spec.ts b/tests/e2e/pages-router-prod/default-error.browser.spec.ts new file mode 100644 index 0000000000..69bed88674 --- /dev/null +++ b/tests/e2e/pages-router-prod/default-error.browser.spec.ts @@ -0,0 +1,142 @@ +import fs from "node:fs/promises"; +import type { Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { test as base, expect } from "../fixtures"; +import { waitForHydration } from "../helpers"; + +type ProductionApp = { + baseUrl: string; +}; + +async function closeServer(server: Server): Promise { + const closed = new Promise((resolve) => server.close(() => resolve())); + server.closeIdleConnections(); + server.closeAllConnections(); + await closed; +} + +async function buildAndServeProductionFixture(): Promise<{ + fixtureRoot: string; + server: Server; + app: ProductionApp; +}> { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-pages-default-error-")); + const pagesDir = path.join(fixtureRoot, "pages"); + + await fs.symlink( + path.resolve(process.cwd(), "tests/fixtures/pages-basic/node_modules"), + path.join(fixtureRoot, "node_modules"), + "junction", + ); + await fs.mkdir(pagesDir, { recursive: true }); + await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"type":"module"}\n'); + await fs.writeFile( + path.join(pagesDir, "index.tsx"), + `export default function Home() { return
Home
; }\n`, + ); + await fs.writeFile( + path.join(pagesDir, "server-error.tsx"), + `export async function getServerSideProps() { + throw new Error("intentional production error"); +} + +export default function ServerError() { + return
unreachable
; +} +`, + ); + + const configFile = path.join(fixtureRoot, "vite.config.ts"); + const vinextSource = path.resolve(process.cwd(), "packages/vinext/src/index.ts"); + await fs.writeFile( + configFile, + `import { defineConfig } from "vite"; +import vinext from ${JSON.stringify(pathToFileURL(vinextSource).href)}; + +export default defineConfig({ + plugins: [vinext()], +}); +`, + ); + + const { createBuilder } = await import("vite"); + const builder = await createBuilder({ + root: fixtureRoot, + configFile, + logLevel: "silent", + }); + await builder.buildApp(); + + const { startProdServer } = await import( + pathToFileURL(path.resolve(process.cwd(), "packages/vinext/dist/server/prod-server.js")).href + ); + const started = await startProdServer({ + host: "127.0.0.1", + port: 0, + outDir: path.join(fixtureRoot, "dist"), + noCompression: true, + }); + + return { + fixtureRoot, + server: started.server, + app: { baseUrl: `http://127.0.0.1:${started.port}` }, + }; +} + +/* oxlint-disable eslint-plugin-react-hooks/rules-of-hooks -- Playwright fixture `use`, not a React hook */ +const test = base.extend<{ productionApp: ProductionApp }>({ + productionApp: async ({ page }, use) => { + const { fixtureRoot, server, app } = await buildAndServeProductionFixture(); + + try { + await use(app); + } finally { + await page.close(); + await closeServer(server); + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }, +}); +/* oxlint-enable eslint-plugin-react-hooks/rules-of-hooks */ + +test.setTimeout(60_000); + +test.describe("Pages Router framework error page production fallback", () => { + test("renders and hydrates the framework error page without custom error files", async ({ + page, + productionApp, + consoleErrors, + }) => { + const notFoundResponse = await page.goto(`${productionApp.baseUrl}/missing`, { + waitUntil: "load", + }); + + expect(notFoundResponse?.status()).toBe(404); + await expect(page.locator("h1")).toHaveText("404"); + await expect(page.locator("h2")).toHaveText("This page could not be found."); + await expect(page).toHaveTitle("404: This page could not be found"); + await waitForHydration(page); + + const errorResponse = await page.goto(`${productionApp.baseUrl}/server-error`, { + waitUntil: "load", + }); + + expect(errorResponse?.status()).toBe(500); + await expect(page.locator("h1")).toHaveText("500"); + await expect(page.locator("h2")).toHaveText("Internal Server Error."); + await expect(page).toHaveTitle("500: Internal Server Error"); + await waitForHydration(page); + + const unexpectedErrors = consoleErrors.filter( + (message) => + !/^Failed to load resource: the server responded with a status of (404|500) \(.+\)$/.test( + message, + ), + ); + expect(unexpectedErrors).toEqual([]); + consoleErrors.length = 0; + }); +}); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 3832411954..e16b1b5bf3 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1187,6 +1187,60 @@ describe("Pages Router entry template", () => { } }); + // Ported from Next.js: test/e2e/no-page-props/no-page-props.test.ts + // https://github.com/vercel/next.js/blob/v16.3.0-canary.80/test/e2e/no-page-props/no-page-props.test.ts + it("uses the framework error page in server and client entries when _error is absent", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-default-error-entry-")); + const pagesDir = path.join(tmpDir, "pages"); + + try { + fs.mkdirSync(pagesDir, { recursive: true }); + fs.writeFileSync( + path.join(pagesDir, "index.tsx"), + "export default function Page() { return null; }", + ); + + const nextConfig = await resolveNextConfig({}); + const matcher = createValidFileMatcher(); + const serverCode = await generateServerEntry(pagesDir, nextConfig, matcher, null, null); + const clientCode = await generateClientEntry(pagesDir, nextConfig, matcher); + + expect(serverCode).toContain('import * as ErrorPageModule from "next/error";'); + expect(clientCode).toContain('"/_error": () => import("next/error")'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + // Ported from Next.js: test/e2e/no-page-props/no-page-props.test.ts + // https://github.com/vercel/next.js/blob/v16.3.0-canary.80/test/e2e/no-page-props/no-page-props.test.ts + it("uses a custom error page in the client entry across configured page extensions", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-custom-error-entry-")); + const pagesDir = path.join(tmpDir, "pages"); + + try { + fs.mkdirSync(pagesDir, { recursive: true }); + fs.writeFileSync( + path.join(pagesDir, "index.tsx"), + "export default function Page() { return null; }", + ); + const errorFilePath = path.join(pagesDir, "_error.page.tsx"); + fs.writeFileSync(errorFilePath, "export default function ErrorPage() { return null; }"); + + const nextConfig = await resolveNextConfig({ pageExtensions: ["page.tsx", "tsx"] }); + const clientCode = await generateClientEntry( + pagesDir, + nextConfig, + createValidFileMatcher(nextConfig.pageExtensions), + ); + + expect(clientCode).toContain(`"/_error": () => import(${JSON.stringify(errorFilePath)})`); + expect(clientCode).not.toContain('"/_error": () => import("next/error")'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + // Refs #1474: Pages Router client entry must import the user's // `instrumentation-client.ts` (at the project root) as a side-effect import // before calling `hydrateRoot()`. Mirrors Next.js's `page-bootstrap.ts` diff --git a/tests/pages-dev-hydration.test.ts b/tests/pages-dev-hydration.test.ts new file mode 100644 index 0000000000..68396b159e --- /dev/null +++ b/tests/pages-dev-hydration.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; +import { createPagesDevHydrationScript } from "../packages/vinext/src/server/pages-dev-hydration.js"; + +describe("createPagesDevHydrationScript", () => { + it("generates the normal Pages Router hydration entry", () => { + const script = createPagesDevHydrationScript({ + appModuleSource: "/pages/_app.tsx", + pageModuleSource: "/pages/index.tsx", + reactStrictMode: true, + replaceFallbackRoute: true, + scriptNonce: "nonce-value", + }); + + expect(script).toContain('