Files
James Anderson 6734371a59 fix(pages): preserve missing page props on errors (#2568)
* fix(pages): preserve missing page props on errors

* fix(pages): complete default error props contract

* fix(pages): match default error component contract

* fix(pages): complete next error parity

* fix(pages): make next error subclassable

* fix(pages): align next error context types

* fix(pages): isolate error context types

* test(pages): cover default error production hydration

* test(pages): cover default error hydration in dev

* fix(pages): hydrate dev error responses

* fix(pages): set dev error router context

* fix(pages): isolate dev error router state

* fix(pages): align dev error router hydration

* fix(pages): keep error router ready during hydration

* fix(pages): clean up error fallback handling

* fix(pages): load custom error page on navigation

* refactor(pages): share dev hydration entry
2026-07-09 16:23:18 +01:00

143 lines
4.3 KiB
TypeScript

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<void> {
const closed = new Promise<void>((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 <main>Home</main>; }\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 <main>unreachable</main>;
}
`,
);
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;
});
});