diff --git a/packages/vinext/src/client/entry.ts b/packages/vinext/src/client/entry.ts index 34d4019f2..b79a95e63 100644 --- a/packages/vinext/src/client/entry.ts +++ b/packages/vinext/src/client/entry.ts @@ -19,9 +19,12 @@ import type { VinextNextData } from "./vinext-next-data.js"; // Read the SSR data injected by the server const nextData = window.__NEXT_DATA__ as VinextNextData | undefined; -const pageProps = (nextData?.props.pageProps ?? {}) as Record; -const pageModulePath = nextData?.__pageModule; -const appModulePath = nextData?.__appModule; +const { pageProps = {}, ...appInitialProps } = (nextData?.props ?? {}) as Record< + string, + unknown +> & { pageProps?: Record }; +const pageModulePath = nextData?.__vinext?.pageModuleUrl ?? nextData?.__pageModule; +const appModulePath = nextData?.__vinext?.appModuleUrl ?? nextData?.__appModule; async function hydrate() { if (!isValidModulePath(pageModulePath)) { @@ -51,6 +54,7 @@ async function hydrate() { element = React.createElement(AppComponent, { Component: PageComponent, pageProps, + ...appInitialProps, }); } catch { // No _app, render page directly diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index cf5e7c32e..cd256707d 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -61,7 +61,7 @@ async function hydrate() { return; } - const { pageProps } = nextData.props; + const { pageProps, ...appInitialProps } = nextData.props; const loader = pageLoaders[nextData.page]; if (!loader) { console.error("[vinext] No page loader for route:", nextData.page); @@ -83,7 +83,7 @@ async function hydrate() { const appModule = await import(${JSON.stringify(appFileBase!)}); const AppComponent = appModule.default; window.__VINEXT_APP__ = AppComponent; - element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + element = React.createElement(AppComponent, { Component: PageComponent, pageProps, ...appInitialProps }); } catch { element = React.createElement(PageComponent, pageProps); } diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index de96eb2db..9fb123b48 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -429,7 +429,7 @@ function patternToNextFormat(pattern) { .replace(/:([\\w]+)/g, "[$1]"); } -function collectAssetTags(manifest, moduleIds) { +function collectAssetTags(manifest, moduleIds, eagerModuleIds) { // Fall back to embedded manifest (set by vinext:cloudflare-build for Workers) const m = (manifest && Object.keys(manifest).length > 0) ? manifest @@ -444,6 +444,35 @@ function collectAssetTags(manifest, moduleIds) { var lazyChunks = (typeof globalThis !== "undefined" && globalThis.__VINEXT_LAZY_CHUNKS__) || null; var lazySet = lazyChunks && lazyChunks.length > 0 ? new Set(lazyChunks) : null; + // Build a set of files that should bypass the lazySet filter even if they + // are technically reached via dynamic import. Used for _app: it is + // dynamically imported at hydration time, but since it is needed on every + // page we preload it unconditionally so the browser can fetch it in parallel + // with the entry script rather than waiting until hydrate() runs. + var eagerFiles = null; + if (lazySet && m && eagerModuleIds && eagerModuleIds.length > 0) { + eagerFiles = new Set(); + for (var ei = 0; ei < eagerModuleIds.length; ei++) { + var eid = eagerModuleIds[ei]; + var efiles = m[eid]; + if (!efiles) { + for (var emk in m) { + if (eid.endsWith("/" + emk) || eid === emk) { + efiles = m[emk]; + break; + } + } + } + if (efiles) { + for (var efi = 0; efi < efiles.length; efi++) { + var ef = efiles[efi]; + if (ef.charAt(0) === '/') ef = ef.slice(1); + if (ef.endsWith(".js")) eagerFiles.add(ef); + } + } + } + } + // Inject the client entry script if embedded by vinext:cloudflare-build if (typeof globalThis !== "undefined" && globalThis.__VINEXT_CLIENT_ENTRY__) { const entry = globalThis.__VINEXT_CLIENT_ENTRY__; @@ -528,9 +557,14 @@ function collectAssetTags(manifest, moduleIds) { } else if (tf.endsWith(".js")) { // Skip lazy chunks — they are behind dynamic import() boundaries // (React.lazy, next/dynamic) and should only be fetched on demand. - if (lazySet && lazySet.has(tf)) continue; + // Exception: eagerFiles bypasses this filter (used for _app and current page). + if (lazySet && lazySet.has(tf) && !(eagerFiles && eagerFiles.has(tf))) continue; + // Only emit — not a '); } } } @@ -704,6 +738,102 @@ async function readBodyWithLimit(request, maxBytes) { return chunks.join(""); } +/** + * Render a 404 or 500 error page through the custom _document (if any). + * + * Resolution order mirrors Next.js: + * 404 -> pages/404 -> pages/_error -> plain text fallback + * 500 -> pages/500 -> pages/_error -> plain text fallback + * + * NOTE: In the Cloudflare Workers runtime there is no Node.js IncomingMessage / + * ServerResponse, so ctx.req / ctx.res are not passed to DocumentContext. + * Custom documents that inspect ctx.req will receive undefined — this is an + * intentional limitation for now and is documented here. + */ +async function renderErrorPageResponse(statusCode, url, request) { + var candidates = statusCode === 404 + ? ["/404", "/_error"] + : statusCode === 500 + ? ["/500", "/_error"] + : ["/_error"]; + + for (var ci = 0; ci < candidates.length; ci++) { + var candidate = candidates[ci]; + var errorRoute = null; + for (var ri = 0; ri < pageRoutes.length; ri++) { + if (pageRoutes[ri].pattern === candidate) { errorRoute = pageRoutes[ri]; break; } + } + if (!errorRoute) continue; + var ErrorComponent = errorRoute.module.default; + if (!ErrorComponent) continue; + + var errorProps = { statusCode: statusCode }; + var errorElement; + if (AppComponent) { + var _errAppProps = {}; + if (typeof AppComponent.getInitialProps === "function") { + var _errParsedQuery = request ? Object.fromEntries(new URL(request.url).searchParams.entries()) : {}; + var _errPathname = (url || "/").split("?")[0] || "/"; + try { + _errAppProps = await AppComponent.getInitialProps({ + Component: ErrorComponent, + AppTree: AppComponent, + router: { pathname: _errPathname, query: _errParsedQuery, asPath: url || "/" }, + ctx: { pathname: _errPathname, query: _errParsedQuery, asPath: url || "/" }, + }); + } catch (e) { /* ignore getInitialProps errors on error pages */ } + } + errorElement = React.createElement(AppComponent, { Component: ErrorComponent, pageProps: errorProps, ..._errAppProps }); + } else { + errorElement = React.createElement(ErrorComponent, errorProps); + } + errorElement = wrapWithRouterContext(errorElement); + var bodyHtml = await renderToStringAsync(errorElement); + + var html; + if (DocumentComponent) { + var _docErrProps = {}; + var _DocErrClass = DocumentComponent; + if (typeof _DocErrClass.getInitialProps === "function") { + try { + var _docErrCtx = { + // NOTE: req/res are not available in the Workers runtime. + // ctx.req / ctx.res will be undefined for production Workers builds. + pathname: (url || "/").split("?")[0] || "/", + query: request ? Object.fromEntries(new URL(request.url).searchParams.entries()) : {}, + asPath: url || "/", + // renderPage is a no-op in vinext — the body is streamed separately. + // Returning empty html here is an intentional architectural deviation + // from Next.js (where renderPage actually renders the app). CSS-in-JS + // libraries that rely on renderPage for style extraction will get an + // empty html string; see buildDocumentContext in dev-server.ts for more. + renderPage: async () => ({ html: "" }), + defaultGetInitialProps: async (ctx) => ctx.renderPage(), + }; + _docErrProps = await _DocErrClass.getInitialProps(_docErrCtx); + } catch (e) { + console.error("[vinext] _document.getInitialProps threw during error page render:", e); + throw e; + } + } + var docErrElement = React.createElement(DocumentComponent, _docErrProps); + var docErrHtml = await renderToStringAsync(docErrElement); + docErrHtml = docErrHtml.replace("__NEXT_MAIN__", bodyHtml); + docErrHtml = docErrHtml.replace("", ""); + html = docErrHtml; + } else { + html = "\\n\\n\\n \\n \\n\\n\\n
" + bodyHtml + "
\\n\\n"; + } + return new Response(html, { status: statusCode, headers: { "Content-Type": "text/html" } }); + } + + // No custom error page found — plain text fallback + return new Response( + statusCode + " - " + (statusCode === 404 ? "Page not found" : "Internal Server Error"), + { status: statusCode, headers: { "Content-Type": "text/plain" } } + ); +} + export async function renderPage(request, url, manifest, ctx) { if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest)); return _renderPage(request, url, manifest); @@ -733,8 +863,7 @@ async function _renderPage(request, url, manifest) { const match = matchRoute(routeUrl, pageRoutes); if (!match) { - return new Response("

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); + return renderErrorPageResponse(404, routeUrl, request); } const { route, params } = match; @@ -793,8 +922,7 @@ async function _renderPage(request, url, manifest) { }); }); if (!isValidPath) { - return new Response("

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); + return renderErrorPageResponse(404, routeUrl, request); } } } @@ -822,7 +950,7 @@ async function _renderPage(request, url, manifest) { return new Response(null, { status: gsspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); } if (result && result.notFound) { - return new Response("404", { status: 404 }); + return renderErrorPageResponse(404, routeUrl, request); } // Preserve the res object so headers/status/cookies set by gSSP // can be merged into the final HTML response. @@ -893,14 +1021,25 @@ async function _renderPage(request, url, manifest) { } // Re-render the page with fresh props inside fresh render sub-scopes // so head/cache state cannot leak across passes. + var _regenAppProps = {}; + if (AppComponent && typeof AppComponent.getInitialProps === "function") { + try { + _regenAppProps = await AppComponent.getInitialProps({ + Component: PageComponent, + AppTree: AppComponent, + router: { pathname: patternToNextFormat(route.pattern), query: { ...params, ...parseQuery(routeUrl) }, asPath: routeUrl }, + ctx: { pathname: patternToNextFormat(route.pattern), query: { ...params, ...parseQuery(routeUrl) }, asPath: routeUrl }, + }); + } catch(e) { /* ignore getInitialProps errors during regen */ } + } var _el = AppComponent - ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp }) + ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp, ..._regenAppProps }) : React.createElement(PageComponent, _fp); _el = wrapWithRouterContext(_el); var _freshBody = await renderIsrPassToStringAsync(_el); // Rebuild __NEXT_DATA__ with fresh props var _regenPayload = { - props: { pageProps: _fp }, page: patternToNextFormat(route.pattern), + props: { pageProps: _fp, ..._regenAppProps }, page: patternToNextFormat(route.pattern), query: params, buildId: buildId, isFallback: false, }; if (i18nConfig) { @@ -963,7 +1102,7 @@ async function _renderPage(request, url, manifest) { return new Response(null, { status: gspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); } if (result && result.notFound) { - return new Response("404", { status: 404 }); + return renderErrorPageResponse(404, routeUrl, request); } if (typeof result.revalidate === "number" && result.revalidate > 0) { isrRevalidateSeconds = result.revalidate; @@ -972,7 +1111,18 @@ async function _renderPage(request, url, manifest) { let element; if (AppComponent) { - element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + var _appProps = {}; + if (typeof AppComponent.getInitialProps === "function") { + var _parsedQ = parseQuery(routeUrl); + var _pathname = routeUrl.split("?")[0]; + _appProps = await AppComponent.getInitialProps({ + Component: PageComponent, + AppTree: AppComponent, + router: { pathname: _pathname, query: { ...params, ..._parsedQ }, asPath: routeUrl }, + ctx: { pathname: _pathname, query: { ...params, ..._parsedQ }, asPath: routeUrl }, + }); + } + element = React.createElement(AppComponent, { Component: PageComponent, pageProps, ..._appProps }); } else { element = React.createElement(PageComponent, pageProps); } @@ -1000,9 +1150,10 @@ async function _renderPage(request, url, manifest) { } catch (e) { /* font styles not available */ } const pageModuleIds = route.filePath ? [route.filePath] : []; - const assetTags = collectAssetTags(manifest, pageModuleIds); + ${appFilePath !== null ? `pageModuleIds.push(${JSON.stringify(appFilePath.replace(/\\/g, "/"))});` : ""} + const assetTags = collectAssetTags(manifest, pageModuleIds, pageModuleIds); const nextDataPayload = { - props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, + props: { pageProps, ..._appProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, }; if (i18nConfig) { nextDataPayload.locale = locale; @@ -1021,7 +1172,34 @@ async function _renderPage(request, url, manifest) { var BODY_MARKER = ""; var shellHtml; if (DocumentComponent) { - const docElement = React.createElement(DocumentComponent); + // Call getInitialProps if the custom Document class defines it, + // mirroring the dev-server behavior so props (e.g. a theme) are + // available during rendering in production. + var _docProps = {}; + var _DocClass = DocumentComponent; + if (typeof _DocClass.getInitialProps === "function") { + try { + var _docCtx = { + // NOTE: req/res are not available in the Workers runtime. + // ctx.req / ctx.res will be undefined for production Workers builds. + pathname: url.split("?")[0] || "/", + query: Object.fromEntries(new URL(request.url).searchParams.entries()), + asPath: url, + // renderPage is a no-op in vinext — the body is streamed separately. + // Returning empty html here is an intentional architectural deviation + // from Next.js (where renderPage actually renders the app). CSS-in-JS + // libraries that rely on renderPage for style extraction will get an + // empty html string; see buildDocumentContext in dev-server.ts for more. + renderPage: async () => ({ html: "" }), + defaultGetInitialProps: async (ctx) => ctx.renderPage(), + }; + _docProps = await _DocClass.getInitialProps(_docCtx); + } catch (e) { + console.error("[vinext] _document.getInitialProps threw:", e); + throw e; + } + } + const docElement = React.createElement(DocumentComponent, _docProps); shellHtml = await renderToStringAsync(docElement); shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER); if (ssrHeadHTML || assetTags || fontHeadHTML) { @@ -1071,7 +1249,7 @@ async function _renderPage(request, url, manifest) { // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. var isrElement; if (AppComponent) { - isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps, ..._appProps }); } else { isrElement = React.createElement(PageComponent, pageProps); } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index db15cdb35..d0b7879f5 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1507,7 +1507,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { appDir = path.join(baseDir, "app"); hasPagesDir = fs.existsSync(pagesDir); hasAppDir = !options.disableAppRouter && fs.existsSync(appDir); - middlewarePath = findMiddlewareFile(root); instrumentationPath = findInstrumentationFile(root); // Load next.config.js if present (always from project root, not src/) @@ -1515,6 +1514,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const rawConfig = await loadNextConfig(root, phase); nextConfig = await resolveNextConfig(rawConfig, root); fileMatcher = createValidFileMatcher(nextConfig.pageExtensions); + middlewarePath = findMiddlewareFile(root, fileMatcher); // Merge env from next.config.js with NEXT_PUBLIC_* env vars const defines = getNextPublicEnvDefines(); diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 4e494ea1e..98343ec5c 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -83,11 +83,57 @@ const STREAM_BODY_MARKER = ""; * deferring them reduces TTFB and lets the browser start parsing the * shell sooner). */ +/** + * Build a minimal DocumentContext for calling _document.getInitialProps. + * + * Next.js DocumentContext includes renderPage, defaultGetInitialProps, + * req/res, pathname, query, asPath, and locale info. We provide the + * subset that custom documents typically use. + */ +function buildDocumentContext( + req: IncomingMessage, + url: string, + res?: ServerResponse, +): import("../shims/document.js").DocumentContext { + const [pathname, search] = url.split("?"); + const query: Record = {}; + if (search) { + for (const [k, v] of new URLSearchParams(search)) { + const existing = query[k]; + if (existing !== undefined) { + query[k] = Array.isArray(existing) ? [...existing, v] : [existing, v]; + } else { + query[k] = v; + } + } + } + const renderPage = async (): Promise => ({ + // NOTE: renderPage is intentionally a no-op in vinext — the app body is + // streamed separately rather than being rendered inside renderPage as Next.js + // does. This is an intentional architectural deviation: css-in-js libraries + // that rely on renderPage for style extraction (e.g. styled-components) + // will receive an empty html string. The real body content comes from the + // SSR stream that replaces __NEXT_MAIN__ after document rendering. + html: "", + }); + const ctx: import("../shims/document.js").DocumentContext = { + pathname: pathname || "/", + query, + asPath: url, + req, + res, + renderPage, + defaultGetInitialProps: async (c) => c.renderPage(), + }; + return ctx; +} + async function streamPageToResponse( res: ServerResponse, element: React.ReactElement, options: { url: string; + req: IncomingMessage; server: ViteDevServer; fontHeadHTML: string; scripts: string; @@ -100,6 +146,7 @@ async function streamPageToResponse( ): Promise { const { url, + req, server, fontHeadHTML, scripts, @@ -121,7 +168,27 @@ async function streamPageToResponse( let shellTemplate: string; if (DocumentComponent) { - const docElement = React.createElement(DocumentComponent); + // Call getInitialProps if the custom Document class defines it. + // This allows documents to augment props (e.g. inject a theme prop). + let docProps: Record = {}; + const DocClass = DocumentComponent as unknown as { + getInitialProps?: ( + ctx: import("../shims/document.js").DocumentContext, + ) => Promise>; + }; + if (typeof DocClass.getInitialProps === "function") { + try { + const ctx = buildDocumentContext(req, url, res); + docProps = await DocClass.getInitialProps(ctx); + } catch (e) { + console.error("[vinext] _document.getInitialProps threw during page render:", e); + throw e; + } + } + const docElement = React.createElement( + DocumentComponent, + docProps as React.ComponentProps, + ); let docHtml = await renderToStringAsync(docElement); // Replace __NEXT_MAIN__ with our stream marker docHtml = docHtml.replace("__NEXT_MAIN__", STREAM_BODY_MARKER); @@ -636,12 +703,44 @@ export function createSSRHandler( } } - let el = RegenApp - ? React.createElement(RegenApp, { - Component: pageModule.default, - pageProps: freshProps, - }) - : React.createElement(pageModule.default, freshProps); + let el: React.ReactElement; + let _regenAppProps: Record = {}; + if (RegenApp) { + if ( + typeof (RegenApp as { getInitialProps?: unknown }).getInitialProps === + "function" + ) { + const _regenParsedQ = parseQuery(url); + const _regenPathname = url.split("?")[0]; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _regenAppProps = + (await (RegenApp as any).getInitialProps({ + Component: pageModule.default, + AppTree: RegenApp, + router: { + pathname: _regenPathname, + query: { ...params, ..._regenParsedQ }, + asPath: url, + }, + ctx: { + pathname: _regenPathname, + query: { ...params, ..._regenParsedQ }, + asPath: url, + }, + })) ?? {}; + } catch { + /* ignore */ + } + } + el = React.createElement(RegenApp, { + Component: pageModule.default, + pageProps: freshProps, + ..._regenAppProps, + }); + } else { + el = React.createElement(pageModule.default, freshProps); + } if (routerShim.wrapWithRouterContext) { el = routerShim.wrapWithRouterContext(el); } @@ -661,7 +760,7 @@ export function createSSRHandler( : null; const freshNextData = ``; const nextDataScript = `'); } } } @@ -19797,6 +19831,102 @@ async function readBodyWithLimit(request, maxBytes) { return chunks.join(""); } +/** + * Render a 404 or 500 error page through the custom _document (if any). + * + * Resolution order mirrors Next.js: + * 404 -> pages/404 -> pages/_error -> plain text fallback + * 500 -> pages/500 -> pages/_error -> plain text fallback + * + * NOTE: In the Cloudflare Workers runtime there is no Node.js IncomingMessage / + * ServerResponse, so ctx.req / ctx.res are not passed to DocumentContext. + * Custom documents that inspect ctx.req will receive undefined — this is an + * intentional limitation for now and is documented here. + */ +async function renderErrorPageResponse(statusCode, url, request) { + var candidates = statusCode === 404 + ? ["/404", "/_error"] + : statusCode === 500 + ? ["/500", "/_error"] + : ["/_error"]; + + for (var ci = 0; ci < candidates.length; ci++) { + var candidate = candidates[ci]; + var errorRoute = null; + for (var ri = 0; ri < pageRoutes.length; ri++) { + if (pageRoutes[ri].pattern === candidate) { errorRoute = pageRoutes[ri]; break; } + } + if (!errorRoute) continue; + var ErrorComponent = errorRoute.module.default; + if (!ErrorComponent) continue; + + var errorProps = { statusCode: statusCode }; + var errorElement; + if (AppComponent) { + var _errAppProps = {}; + if (typeof AppComponent.getInitialProps === "function") { + var _errParsedQuery = request ? Object.fromEntries(new URL(request.url).searchParams.entries()) : {}; + var _errPathname = (url || "/").split("?")[0] || "/"; + try { + _errAppProps = await AppComponent.getInitialProps({ + Component: ErrorComponent, + AppTree: AppComponent, + router: { pathname: _errPathname, query: _errParsedQuery, asPath: url || "/" }, + ctx: { pathname: _errPathname, query: _errParsedQuery, asPath: url || "/" }, + }); + } catch (e) { /* ignore getInitialProps errors on error pages */ } + } + errorElement = React.createElement(AppComponent, { Component: ErrorComponent, pageProps: errorProps, ..._errAppProps }); + } else { + errorElement = React.createElement(ErrorComponent, errorProps); + } + errorElement = wrapWithRouterContext(errorElement); + var bodyHtml = await renderToStringAsync(errorElement); + + var html; + if (DocumentComponent) { + var _docErrProps = {}; + var _DocErrClass = DocumentComponent; + if (typeof _DocErrClass.getInitialProps === "function") { + try { + var _docErrCtx = { + // NOTE: req/res are not available in the Workers runtime. + // ctx.req / ctx.res will be undefined for production Workers builds. + pathname: (url || "/").split("?")[0] || "/", + query: request ? Object.fromEntries(new URL(request.url).searchParams.entries()) : {}, + asPath: url || "/", + // renderPage is a no-op in vinext — the body is streamed separately. + // Returning empty html here is an intentional architectural deviation + // from Next.js (where renderPage actually renders the app). CSS-in-JS + // libraries that rely on renderPage for style extraction will get an + // empty html string; see buildDocumentContext in dev-server.ts for more. + renderPage: async () => ({ html: "" }), + defaultGetInitialProps: async (ctx) => ctx.renderPage(), + }; + _docErrProps = await _DocErrClass.getInitialProps(_docErrCtx); + } catch (e) { + console.error("[vinext] _document.getInitialProps threw during error page render:", e); + throw e; + } + } + var docErrElement = React.createElement(DocumentComponent, _docErrProps); + var docErrHtml = await renderToStringAsync(docErrElement); + docErrHtml = docErrHtml.replace("__NEXT_MAIN__", bodyHtml); + docErrHtml = docErrHtml.replace("", ""); + html = docErrHtml; + } else { + html = "\\n\\n\\n \\n \\n\\n\\n
" + bodyHtml + "
\\n\\n"; + } + return new Response(html, { status: statusCode, headers: { "Content-Type": "text/html" } }); + } + + // No custom error page found — plain text fallback + return new Response( + statusCode + " - " + (statusCode === 404 ? "Page not found" : "Internal Server Error"), + { status: statusCode, headers: { "Content-Type": "text/plain" } } + ); +} + export async function renderPage(request, url, manifest, ctx) { if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest)); return _renderPage(request, url, manifest); @@ -19826,8 +19956,7 @@ async function _renderPage(request, url, manifest) { const match = matchRoute(routeUrl, pageRoutes); if (!match) { - return new Response("

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); + return renderErrorPageResponse(404, routeUrl, request); } const { route, params } = match; @@ -19886,8 +20015,7 @@ async function _renderPage(request, url, manifest) { }); }); if (!isValidPath) { - return new Response("

404 - Page not found

", - { status: 404, headers: { "Content-Type": "text/html" } }); + return renderErrorPageResponse(404, routeUrl, request); } } } @@ -19915,7 +20043,7 @@ async function _renderPage(request, url, manifest) { return new Response(null, { status: gsspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); } if (result && result.notFound) { - return new Response("404", { status: 404 }); + return renderErrorPageResponse(404, routeUrl, request); } // Preserve the res object so headers/status/cookies set by gSSP // can be merged into the final HTML response. @@ -19986,14 +20114,25 @@ async function _renderPage(request, url, manifest) { } // Re-render the page with fresh props inside fresh render sub-scopes // so head/cache state cannot leak across passes. + var _regenAppProps = {}; + if (AppComponent && typeof AppComponent.getInitialProps === "function") { + try { + _regenAppProps = await AppComponent.getInitialProps({ + Component: PageComponent, + AppTree: AppComponent, + router: { pathname: patternToNextFormat(route.pattern), query: { ...params, ...parseQuery(routeUrl) }, asPath: routeUrl }, + ctx: { pathname: patternToNextFormat(route.pattern), query: { ...params, ...parseQuery(routeUrl) }, asPath: routeUrl }, + }); + } catch(e) { /* ignore getInitialProps errors during regen */ } + } var _el = AppComponent - ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp }) + ? React.createElement(AppComponent, { Component: PageComponent, pageProps: _fp, ..._regenAppProps }) : React.createElement(PageComponent, _fp); _el = wrapWithRouterContext(_el); var _freshBody = await renderIsrPassToStringAsync(_el); // Rebuild __NEXT_DATA__ with fresh props var _regenPayload = { - props: { pageProps: _fp }, page: patternToNextFormat(route.pattern), + props: { pageProps: _fp, ..._regenAppProps }, page: patternToNextFormat(route.pattern), query: params, buildId: buildId, isFallback: false, }; if (i18nConfig) { @@ -20056,7 +20195,7 @@ async function _renderPage(request, url, manifest) { return new Response(null, { status: gspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } }); } if (result && result.notFound) { - return new Response("404", { status: 404 }); + return renderErrorPageResponse(404, routeUrl, request); } if (typeof result.revalidate === "number" && result.revalidate > 0) { isrRevalidateSeconds = result.revalidate; @@ -20065,7 +20204,18 @@ async function _renderPage(request, url, manifest) { let element; if (AppComponent) { - element = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + var _appProps = {}; + if (typeof AppComponent.getInitialProps === "function") { + var _parsedQ = parseQuery(routeUrl); + var _pathname = routeUrl.split("?")[0]; + _appProps = await AppComponent.getInitialProps({ + Component: PageComponent, + AppTree: AppComponent, + router: { pathname: _pathname, query: { ...params, ..._parsedQ }, asPath: routeUrl }, + ctx: { pathname: _pathname, query: { ...params, ..._parsedQ }, asPath: routeUrl }, + }); + } + element = React.createElement(AppComponent, { Component: PageComponent, pageProps, ..._appProps }); } else { element = React.createElement(PageComponent, pageProps); } @@ -20093,9 +20243,10 @@ async function _renderPage(request, url, manifest) { } catch (e) { /* font styles not available */ } const pageModuleIds = route.filePath ? [route.filePath] : []; - const assetTags = collectAssetTags(manifest, pageModuleIds); + pageModuleIds.push("/tests/fixtures/pages-basic/pages/_app.tsx"); + const assetTags = collectAssetTags(manifest, pageModuleIds, pageModuleIds); const nextDataPayload = { - props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, + props: { pageProps, ..._appProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false, }; if (i18nConfig) { nextDataPayload.locale = locale; @@ -20114,7 +20265,34 @@ async function _renderPage(request, url, manifest) { var BODY_MARKER = ""; var shellHtml; if (DocumentComponent) { - const docElement = React.createElement(DocumentComponent); + // Call getInitialProps if the custom Document class defines it, + // mirroring the dev-server behavior so props (e.g. a theme) are + // available during rendering in production. + var _docProps = {}; + var _DocClass = DocumentComponent; + if (typeof _DocClass.getInitialProps === "function") { + try { + var _docCtx = { + // NOTE: req/res are not available in the Workers runtime. + // ctx.req / ctx.res will be undefined for production Workers builds. + pathname: url.split("?")[0] || "/", + query: Object.fromEntries(new URL(request.url).searchParams.entries()), + asPath: url, + // renderPage is a no-op in vinext — the body is streamed separately. + // Returning empty html here is an intentional architectural deviation + // from Next.js (where renderPage actually renders the app). CSS-in-JS + // libraries that rely on renderPage for style extraction will get an + // empty html string; see buildDocumentContext in dev-server.ts for more. + renderPage: async () => ({ html: "" }), + defaultGetInitialProps: async (ctx) => ctx.renderPage(), + }; + _docProps = await _DocClass.getInitialProps(_docCtx); + } catch (e) { + console.error("[vinext] _document.getInitialProps threw:", e); + throw e; + } + } + const docElement = React.createElement(DocumentComponent, _docProps); shellHtml = await renderToStringAsync(docElement); shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER); if (ssrHeadHTML || assetTags || fontHeadHTML) { @@ -20164,7 +20342,7 @@ async function _renderPage(request, url, manifest) { // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. var isrElement; if (AppComponent) { - isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps, ..._appProps }); } else { isrElement = React.createElement(PageComponent, pageProps); } diff --git a/tests/e2e/pages-router-prod/document.spec.ts b/tests/e2e/pages-router-prod/document.spec.ts new file mode 100644 index 000000000..c5cd3488d --- /dev/null +++ b/tests/e2e/pages-router-prod/document.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; + +/** + * Production build E2E tests for custom _document and _app. + * + * Ported from tests/e2e/pages-router/document.spec.ts — same assertions, + * but exercised against the production server on port 4175. + */ +const BASE = "http://localhost:4175"; + +test.describe("Document (prod)", () => { + test("page includes theme attribute on the body", async ({ page }) => { + await page.goto(`${BASE}/`); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + }); + + test("page includes app prop attribute", async ({ page }) => { + await page.goto(`${BASE}/?appProp=value`); + + await expect(page.getAttribute("#app-wrapper", "data-app-prop")).resolves.toBe("value"); + }); + + test("error pages (404) also use the custom _document and get getInitialProps", async ({ + page, + }) => { + await page.goto(`${BASE}/this-page-does-not-exist`); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + }); + + test("basic document structure is present (id=__next, html/head/body)", async ({ page }) => { + await page.goto(`${BASE}/`); + + await expect(page.locator("#__next")).toBeVisible(); + const htmlLang = await page.evaluate(() => document.documentElement.lang); + expect(htmlLang).toBe("en"); + }); + + test("getInitialProps receives the correct pathname via DocumentContext", async ({ page }) => { + await page.goto(`${BASE}/about`); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + await expect(page.getAttribute("body", "data-pathname")).resolves.toBe("/about"); + await expect(page.locator("#__next")).toBeVisible(); + }); +}); diff --git a/tests/e2e/pages-router/document.spec.ts b/tests/e2e/pages-router/document.spec.ts new file mode 100644 index 000000000..8a6f4650d --- /dev/null +++ b/tests/e2e/pages-router/document.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Document", () => { + test("page includes theme attribute on the body", async ({ page }) => { + await page.goto("/"); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + }); + + test("page includes app prop attribute", async ({ page }) => { + await page.goto("/?appProp=value"); + + await expect(page.getAttribute("#app-wrapper", "data-app-prop")).resolves.toBe("value"); + }); + + test("error pages (404) also use the custom _document and get getInitialProps", async ({ + page, + }) => { + // The fixture _document adds data-theme-prop via getInitialProps. + // Visiting a nonexistent route triggers renderErrorPage, which should + // also call getInitialProps and wrap with the custom document. + await page.goto("/this-page-does-not-exist"); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + }); + + test("basic document structure is present (id=__next, html/head/body)", async ({ page }) => { + // Regression test: verifies that the document shell renders correctly + // regardless of whether a class-based or function-based document is used. + await page.goto("/"); + + await expect(page.locator("#__next")).toBeVisible(); + // The custom _document renders + const htmlLang = await page.evaluate(() => document.documentElement.lang); + expect(htmlLang).toBe("en"); + }); + + test("getInitialProps receives the correct pathname via DocumentContext", async ({ page }) => { + // Navigate to /about and verify that ctx.pathname was correctly set to "/about" + // in DocumentContext — the fixture stores ctx.pathname as data-pathname on . + await page.goto("/about"); + + await expect(page.getAttribute("body", "data-theme-prop")).resolves.toBe("light"); + await expect(page.getAttribute("body", "data-pathname")).resolves.toBe("/about"); + // The about page content should be present inside the document shell + await expect(page.locator("#__next")).toBeVisible(); + }); +}); diff --git a/tests/fixtures/pages-basic/next-shims.d.ts b/tests/fixtures/pages-basic/next-shims.d.ts index 6ad0fb5bf..fc5836ae0 100644 --- a/tests/fixtures/pages-basic/next-shims.d.ts +++ b/tests/fixtures/pages-basic/next-shims.d.ts @@ -107,9 +107,42 @@ declare module "next/dynamic" { declare module "next/app" { import { ComponentType } from "react"; - export interface AppProps { + export type AppTree = ComponentType; + export interface AppInitialProps { + pageProps: PageProps; + } + export interface AppContext { Component: ComponentType; - pageProps: Record; + AppTree: AppTree; + ctx: { + req?: unknown; + res?: unknown; + pathname: string; + query: Record; + asPath: string; + err?: Error & { statusCode?: number }; + locale?: string; + locales?: readonly string[]; + defaultLocale?: string; + }; + router: { + pathname: string; + query: Record; + asPath: string; + locale?: string; + locales?: readonly string[]; + defaultLocale?: string; + [key: string]: unknown; + }; + } + export interface AppProps

> { + Component: ComponentType

; + pageProps: P; + router: AppContext["router"]; + } + export default class App

{ + static getInitialProps(ctx: AppContext): Promise; + render(): JSX.Element; } } @@ -129,11 +162,45 @@ declare module "next" { } declare module "next/document" { - import { ComponentType, ReactNode } from "react"; - export const Html: ComponentType<{ lang?: string; children?: ReactNode; [key: string]: unknown }>; - export const Head: ComponentType<{ children?: ReactNode }>; - export const Main: ComponentType; - export const NextScript: ComponentType; + import type { IncomingMessage, ServerResponse } from "node:http"; + import { Component, type HTMLAttributes, type ReactNode, type ReactElement } from "react"; + + export type DocumentInitialProps = { + html: string; + head?: Array; + styles?: ReactElement[] | Iterable | ReactElement; + }; + + export type DocumentContext = { + pathname: string; + query: Record; + asPath?: string; + req?: IncomingMessage; + res?: ServerResponse; + err?: (Error & { statusCode?: number }) | null; + locale?: string; + locales?: readonly string[]; + defaultLocale?: string; + renderPage: () => DocumentInitialProps | Promise; + defaultGetInitialProps( + ctx: DocumentContext, + options?: { nonce?: string }, + ): Promise; + }; + + export type DocumentProps = DocumentInitialProps & { [key: string]: unknown }; + + export function Html( + props: HTMLAttributes & { children?: ReactNode }, + ): ReactElement; + export function Head(props: { children?: ReactNode }): ReactElement; + export function Main(): ReactElement; + export function NextScript(): ReactElement; + + export default class Document

extends Component { + static getInitialProps(ctx: DocumentContext): Promise; + render(): ReactElement; + } } declare module "next/config" { diff --git a/tests/fixtures/pages-basic/pages/_app.tsx b/tests/fixtures/pages-basic/pages/_app.tsx index cf942e46c..43ba64217 100644 --- a/tests/fixtures/pages-basic/pages/_app.tsx +++ b/tests/fixtures/pages-basic/pages/_app.tsx @@ -1,8 +1,8 @@ -import type { AppProps } from "next/app"; +import type { AppProps, AppContext } from "next/app"; -export default function MyApp({ Component, pageProps }: AppProps) { +function MyApp({ Component, pageProps, appProps }: AppProps & { appProps: { appProp: string } }) { return ( -

+
@@ -10,3 +10,7 @@ export default function MyApp({ Component, pageProps }: AppProps) {
); } + +MyApp.getInitialProps = (ctx: AppContext) => ({ appProps: { appProp: ctx.router.query.appProp } }); + +export default MyApp; diff --git a/tests/fixtures/pages-basic/pages/_document.tsx b/tests/fixtures/pages-basic/pages/_document.tsx index 0929cc7e9..a035555ea 100644 --- a/tests/fixtures/pages-basic/pages/_document.tsx +++ b/tests/fixtures/pages-basic/pages/_document.tsx @@ -1,15 +1,30 @@ -import { Html, Head, Main, NextScript } from "next/document"; - -export default function Document() { - return ( - - - - - -
- - - - ); +import DocumentImpl, { Html, Head, Main, NextScript } from "next/document"; +import type { DocumentContext, DocumentInitialProps } from "next/document"; + +type DocumentProps = DocumentInitialProps & { theme: string; pathname: string }; + +export default class Document extends DocumentImpl { + static async getInitialProps(ctx: DocumentContext): Promise { + const initialProps = await DocumentImpl.getInitialProps(ctx); + + return Promise.resolve({ ...initialProps, theme: "light", pathname: ctx.pathname }); + } + + render() { + return ( + + + + + +
+ + + + ); + } }