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; - DocumentComponent = docModule.default ?? null; - } catch { - // _document exists but failed to load - } - } - // Expose page route patterns on window before hydration so the // next/navigation compat hooks can resolve a dynamic pattern from a // resolved path, matching the production client entry. Kept in its own @@ -2090,6 +2106,11 @@ async function renderErrorPage( // Try specific status page first, then _error, then fallback const candidates = statusCode === 404 ? ["404", "_error"] : statusCode === 500 ? ["500", "_error"] : ["_error"]; + const { DocumentComponent, AppComponent, appAssetPath } = await loadPagesRootComponents( + runner, + pagesDir, + matcher, + ); for (const candidate of candidates) { try { @@ -2100,20 +2121,6 @@ async function renderErrorPage( const ErrorComponent = errorModule.default; if (!ErrorComponent) continue; - // Try to load _app.tsx to wrap the error page - // oxlint-disable-next-line typescript/no-explicit-any - let AppComponent: any = null; - const appPathErr = path.join(pagesDir, "_app"); - const appAssetPath = findFileWithExts(pagesDir, "_app", matcher); - if (findFileWithExtensions(appPathErr, matcher)) { - try { - const appModule = await importModule(runner, appAssetPath ?? appPathErr); - AppComponent = appModule.default ?? null; - } catch { - // _app exists but failed to load - } - } - const createElement = React.createElement; const initialErrorProps = await loadPagesGetInitialProps(ErrorComponent, { req, @@ -2138,19 +2145,6 @@ async function renderErrorPage( } } - // Try custom _document - // oxlint-disable-next-line typescript/no-explicit-any - let DocumentComponent: any = null; - const docPathErr = path.join(pagesDir, "_document"); - if (findFileWithExtensions(docPathErr, matcher)) { - try { - const docModule = await importModule(runner, docPathErr); - DocumentComponent = docModule.default ?? null; - } catch { - // _document exists but failed to load - } - } - const createErrorElement = ( // oxlint-disable-next-line typescript/no-explicit-any FinalApp: any, diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 383241195..93d4ee4be 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1148,6 +1148,53 @@ describe("Pages Router entry template", () => { } }); + it("imports Pages root components in _document > _app > page order", 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-import-order-entry-")); + const pagesDir = path.join(tmpDir, "pages"); + + try { + fs.mkdirSync(pagesDir, { recursive: true }); + fs.writeFileSync( + path.join(pagesDir, "_document.tsx"), + "export default function Document() { return null; }", + ); + fs.writeFileSync( + path.join(pagesDir, "_app.tsx"), + "export default function App() { return null; }", + ); + fs.writeFileSync( + path.join(pagesDir, "index.tsx"), + "export default function Page() { return null; }", + ); + + const code = await generateServerEntry( + pagesDir, + await resolveNextConfig({}), + createValidFileMatcher(), + null, + null, + ); + + const documentImportIndex = code.indexOf( + `import { default as DocumentComponent } from ${JSON.stringify(path.join(pagesDir, "_document.tsx"))}`, + ); + const appImportIndex = code.indexOf( + `import { default as AppComponent } from ${JSON.stringify(path.join(pagesDir, "_app.tsx"))}`, + ); + const pageImportIndex = code.indexOf( + `import * as page_0 from ${JSON.stringify(path.join(pagesDir, "index.tsx"))}`, + ); + + expect(documentImportIndex).toBeGreaterThanOrEqual(0); + expect(appImportIndex).toBeGreaterThan(documentImportIndex); + expect(pageImportIndex).toBeGreaterThan(appImportIndex); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("precomputes Pages route dataKind in the server entry", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-data-kind-entry-")); const pagesDir = path.join(tmpDir, "pages"); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 959430984..1b1ef9fda 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -3033,6 +3033,263 @@ describe("Virtual server entry generation", () => { } }); + it("evaluates Pages root components in _document > _app > page order 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-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_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
; } +} +`, + ); + fs.writeFileSync( + path.join(pagesDir, "_app.tsx"), + `import { record } from "../side-effect"; +record("_app"); +export default function App({ Component, pageProps }) { return ; } +`, + ); + fs.writeFileSync( + path.join(pagesDir, "index.tsx"), + `import { read, record } from "../side-effect"; +record("page"); +export default function Page() { 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}/`); + 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
; } +} +`, + ); + fs.writeFileSync( + path.join(pagesDir, "_app.tsx"), + `import { record } from "../side-effect"; +record("_app"); +export default function App({ Component, pageProps }) { return ; } +`, + ); + fs.writeFileSync( + path.join(pagesDir, "404.tsx"), + `import { read, record } from "../side-effect"; +record("404"); +export default function NotFound() { 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() { return

page 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 ; } +`, + ); + fs.writeFileSync( + path.join(pagesDir, "index.tsx"), + `export async function getServerSideProps() { return { props: { value: "data response" } }; } +export default function Page({ value }) { 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 }) { return ; } +`, + ); + fs.writeFileSync( + path.join(pagesDir, "404.tsx"), + `export default function NotFound() { return

404 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 }; }