diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts
index 4432cc566..790b3f417 100644
--- a/packages/vinext/src/entries/pages-server-entry.ts
+++ b/packages/vinext/src/entries/pages-server-entry.ts
@@ -290,11 +290,10 @@ async function _renderIsrPassToStringAsync(element) {
);
}
+${docImportCode}
+${appImportCode}
${pageImports.join("\n")}
${apiImports.join("\n")}
-
-${appImportCode}
-${docImportCode}
${errorImportCode}
export const pageRoutes = [
diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts
index 49df92b3e..c24c64b3c 100644
--- a/packages/vinext/src/server/dev-server.ts
+++ b/packages/vinext/src/server/dev-server.ts
@@ -244,6 +244,40 @@ async function collectDevInitialStylesheetHeadHTML(
return html;
}
+async function loadPagesRootComponents(
+ runner: ModuleImporter,
+ pagesDir: string,
+ matcher: ValidFileMatcher,
+ options: { includeDocument?: boolean } = {},
+): Promise<{
+ // oxlint-disable-next-line typescript/no-explicit-any
+ DocumentComponent: any;
+ // oxlint-disable-next-line typescript/no-explicit-any
+ AppComponent: any;
+ appAssetPath: string | null;
+}> {
+ const { includeDocument = true } = options;
+ // oxlint-disable-next-line typescript/no-explicit-any
+ let DocumentComponent: any = null;
+ if (includeDocument) {
+ const documentAssetPath = findFileWithExts(pagesDir, "_document", matcher);
+ if (documentAssetPath) {
+ const documentModule = await importModule(runner, documentAssetPath);
+ DocumentComponent = documentModule.default ?? null;
+ }
+ }
+
+ // oxlint-disable-next-line typescript/no-explicit-any
+ let AppComponent: any = null;
+ const appAssetPath = findFileWithExts(pagesDir, "_app", matcher);
+ if (appAssetPath) {
+ const appModule = await importModule(runner, appAssetPath);
+ AppComponent = appModule.default ?? null;
+ }
+
+ return { DocumentComponent, AppComponent, appAssetPath };
+}
+
/**
* Emit a `getServerSideProps` / `getStaticProps` `{ redirect }` result.
*
@@ -778,23 +812,18 @@ export function createSSRHandler(
}
}
- // Load the page module through Vite's SSR pipeline
- // This gives us HMR and transform support for free
+ // Next.js evaluates _app before the matched page on every dev path,
+ // while _document is only evaluated for HTML rendering.
+ const { DocumentComponent, AppComponent } = await loadPagesRootComponents(
+ runner,
+ pagesDir,
+ matcher,
+ { includeDocument: !isDataReq },
+ );
+ // Load the page module through Vite's SSR pipeline after the root
+ // components. This gives us HMR and transform support for free while
+ // preserving Next.js module evaluation order.
const pageModule = await importModule(runner, route.filePath);
- // Try to load _app.tsx if it exists. This happens before the readiness
- // predicate so app-level getInitialProps participates in the same
- // initial Pages Router state as the client __NEXT_DATA__ payload.
- // oxlint-disable-next-line typescript/no-explicit-any
- let AppComponent: any = null;
- const appPath = path.join(pagesDir, "_app");
- if (findFileWithExtensions(appPath, matcher)) {
- try {
- const appModule = await importModule(runner, appPath);
- AppComponent = appModule.default ?? null;
- } catch {
- // _app exists but failed to load
- }
- }
const pagesNextData = buildPagesReadinessNextData({
pageModule,
appComponent: AppComponent,
@@ -1860,19 +1889,6 @@ hydrate();
},
)}`;
- // Try to load custom _document.tsx
- const docPath = path.join(pagesDir, "_document");
- // oxlint-disable-next-line typescript/no-explicit-any
- let DocumentComponent: any = null;
- if (findFileWithExtensions(docPath, matcher)) {
- try {
- const docModule = (await runner.import(docPath)) as Record
{read().join(",")}
; } +`, + ); + + const testServer = await createServer({ + root: tmpDir, + configFile: false, + plugins: [vinext({ appDir: tmpDir })], + server: { port: 0, cors: false }, + logLevel: "silent", + }); + + try { + await testServer.listen(); + const addr = testServer.httpServer?.address(); + if (!addr || typeof addr !== "object") throw new Error("Expected dev server address"); + + const res = await fetch(`http://localhost:${addr.port}/`); + expect(res.status).toBe(200); + expect(await res.text()).toContain('_document,_app,page
'); + } finally { + await testServer.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("evaluates Pages root components before a cold-start missing route in dev", async () => { + // Ported from Next.js: test/e2e/app-document-import-order/app-document-import-order.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-document-import-order/app-document-import-order.test.ts + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-error-import-order-dev-")); + const pagesDir = path.join(tmpDir, "pages"); + fs.mkdirSync(pagesDir, { recursive: true }); + fs.symlinkSync(path.join(process.cwd(), "node_modules"), path.join(tmpDir, "node_modules")); + fs.writeFileSync( + path.join(tmpDir, "side-effect.ts"), + `const calls = ((globalThis as any).__VINEXT_ERROR_IMPORT_ORDER__ ??= []); +export function record(name: string) { calls.push(name); } +export function read() { return calls; } +`, + ); + fs.writeFileSync( + path.join(pagesDir, "_document.tsx"), + `import Document, { Html, Head, Main, NextScript } from "next/document"; +import { record } from "../side-effect"; +record("_document"); +export default class CustomDocument extends Document { + render() { return{read().join(",")}
; } +`, + ); + + const testServer = await createServer({ + root: tmpDir, + configFile: false, + plugins: [vinext({ appDir: tmpDir })], + server: { port: 0, cors: false }, + logLevel: "silent", + }); + + try { + await testServer.listen(); + const addr = testServer.httpServer?.address(); + if (!addr || typeof addr !== "object") throw new Error("Expected dev server address"); + + const res = await fetch(`http://localhost:${addr.port}/missing`); + expect(res.status).toBe(404); + expect(await res.text()).toContain('_document,_app,404
'); + } finally { + await testServer.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("propagates a throwing _document import on a normal dev route", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-document-import-error-")); + const pagesDir = path.join(tmpDir, "pages"); + fs.mkdirSync(pagesDir, { recursive: true }); + fs.symlinkSync(path.join(process.cwd(), "node_modules"), path.join(tmpDir, "node_modules")); + fs.writeFileSync( + path.join(pagesDir, "_document.tsx"), + `throw new Error("document import failed"); +export default function Document() { return null; } +`, + ); + fs.writeFileSync( + path.join(pagesDir, "index.tsx"), + `export default function Page() { returnpage rendered
; } +`, + ); + + const testServer = await createServer({ + root: tmpDir, + configFile: false, + plugins: [vinext({ appDir: tmpDir })], + server: { port: 0, cors: false }, + logLevel: "silent", + }); + + try { + await testServer.listen(); + const addr = testServer.httpServer?.address(); + if (!addr || typeof addr !== "object") throw new Error("Expected dev server address"); + + const res = await fetch(`http://localhost:${addr.port}/`); + expect(res.status).toBe(500); + const html = await res.text(); + expect(html).toContain("document import failed"); + expect(html).not.toContain("page rendered"); + } finally { + await testServer.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("does not evaluate _document for a dev data request", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-data-document-dev-")); + const pagesDir = path.join(tmpDir, "pages"); + fs.mkdirSync(pagesDir, { recursive: true }); + fs.symlinkSync(path.join(process.cwd(), "node_modules"), path.join(tmpDir, "node_modules")); + fs.writeFileSync( + path.join(tmpDir, "next.config.js"), + `module.exports = { generateBuildId: () => "test-build-id" };\n`, + ); + fs.writeFileSync( + path.join(pagesDir, "_document.tsx"), + `throw new Error("document import failed"); +export default function Document() { return null; } +`, + ); + fs.writeFileSync( + path.join(pagesDir, "_app.tsx"), + `export default function App({ Component, pageProps }) { return{value}
; } +`, + ); + + const testServer = await createServer({ + root: tmpDir, + configFile: false, + plugins: [vinext({ appDir: tmpDir })], + server: { port: 0, cors: false }, + logLevel: "silent", + }); + + try { + await testServer.listen(); + const addr = testServer.httpServer?.address(); + if (!addr || typeof addr !== "object") throw new Error("Expected dev server address"); + + const res = await fetch(`http://localhost:${addr.port}/_next/data/test-build-id/index.json`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(await res.json()).toEqual({ + pageProps: { value: "data response" }, + __N_SSP: true, + }); + } finally { + await testServer.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("propagates a throwing _app import on a cold-start missing dev route", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-app-import-error-")); + const pagesDir = path.join(tmpDir, "pages"); + fs.mkdirSync(pagesDir, { recursive: true }); + fs.symlinkSync(path.join(process.cwd(), "node_modules"), path.join(tmpDir, "node_modules")); + fs.writeFileSync( + path.join(pagesDir, "_app.tsx"), + `throw new Error("app import failed"); +export default function App({ Component, pageProps }) { return404 rendered
; } +`, + ); + + const testServer = await createServer({ + root: tmpDir, + configFile: false, + plugins: [vinext({ appDir: tmpDir })], + server: { port: 0, cors: false }, + logLevel: "silent", + }); + + try { + await testServer.listen(); + const addr = testServer.httpServer?.address(); + if (!addr || typeof addr !== "object") throw new Error("Expected dev server address"); + + const res = await fetch(`http://localhost:${addr.port}/missing`); + expect(res.status).toBe(500); + const html = await res.text(); + expect(html).toContain("app import failed"); + expect(html).not.toContain("404 rendered"); + } finally { + await testServer.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("dev Pages client assets expose virtual CSS added by client transforms", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-virtual-css-")); fs.mkdirSync(path.join(tmpDir, "pages"), { recursive: true }); @@ -8434,7 +8691,11 @@ describe("Pages Router dev ISR regeneration", () => { }; } - if (id === path.join(FIXTURE_DIR, "pages", "_app")) { + if (/[/\\]_document(?:\.[^/\\]+)?$/.test(id)) { + return { default: null }; + } + + if (/[/\\]_app(?:\.[^/\\]+)?$/.test(id)) { return { default: App }; }