Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/vinext/src/entries/pages-client-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ import Router, {
wrapWithRouterContext,
_initializePagesRouterReadyFromNextData,
} from "next/router";
import { initScriptLoader } from "vinext/shims/script-loader";

const pageLoaders = {
${loaderEntries.join(",\n")}
Expand Down Expand Up @@ -216,6 +217,7 @@ async function hydrate() {
}

_initializePagesRouterReadyFromNextData(nextData);
initScriptLoader(Array.isArray(nextData.scriptLoader) ? nextData.scriptLoader : []);

let hydrateRootOptions;
if (import.meta.env.DEV) {
Expand Down
11 changes: 10 additions & 1 deletion packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2918,7 +2918,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
exclude: mergeOptimizeDepsExclude(incomingExclude, VINEXT_OPTIMIZE_DEPS_EXCLUDE, [
"@tailwindcss/oxide",
]),
...(incomingInclude.length > 0 ? { include: incomingInclude } : {}),
include: [
...new Set([
...incomingInclude,
"react",
"react-dom",
"react-dom/client",
"react/jsx-runtime",
"react/jsx-dev-runtime",
]),
],
...depOptimizeNodeEnvOptions,
rolldownOptions: {
...depOptimizeNodeEnvOptions.rolldownOptions,
Expand Down
31 changes: 31 additions & 0 deletions packages/vinext/src/server/before-interactive-collector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from "react";
import {
BeforeInteractiveContext,
type BeforeInteractiveInlineScript,
} from "vinext/shims/before-interactive-context";

export type BeforeInteractiveCollector = {
scripts: BeforeInteractiveInlineScript[];
wrapPageElement: (element: React.ReactElement) => React.ReactElement;
};

export function createBeforeInteractiveCollector(
context: typeof BeforeInteractiveContext = BeforeInteractiveContext,
): BeforeInteractiveCollector {
const scripts: BeforeInteractiveInlineScript[] = [];

return {
scripts,
wrapPageElement(element) {
return React.createElement(
context.Provider,
{
value(script: BeforeInteractiveInlineScript) {
scripts.push(script);
},
},
element,
);
},
};
}
18 changes: 18 additions & 0 deletions packages/vinext/src/server/before-interactive-head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function renderBeforeInteractiveInlineScripts(
let html = "";
for (const script of scripts) {
let attrs = "";
attrs += ` data-vinext-script-key="${escapeHtmlAttr(script.key)}"`;
if (script.id) {
attrs += ` id="${escapeHtmlAttr(script.id)}"`;
}
Expand Down Expand Up @@ -62,3 +63,20 @@ export function renderBeforeInteractiveInlineScripts(
}
return html;
}

export function insertBeforeInteractiveScripts(
html: string,
scripts: readonly BeforeInteractiveInlineScript[],
): string {
return insertAfterHeadOpen(html, renderBeforeInteractiveInlineScripts(scripts));
}

export function insertAfterHeadOpen(html: string, insertion: string): string {
if (!insertion) return html;

const headOpenIndex = html.indexOf("<head");
const headOpenEnd = headOpenIndex === -1 ? -1 : html.indexOf(">", headOpenIndex + 5);
if (headOpenEnd === -1) return html;

return html.slice(0, headOpenEnd + 1) + insertion + html.slice(headOpenEnd + 1);
}
137 changes: 112 additions & 25 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import "vinext/shims/router-state";
import { runWithHeadState } from "vinext/shims/head-state";
import { runWithServerInsertedHTMLState } from "vinext/shims/navigation-state";
import { withScriptNonce } from "vinext/shims/script-nonce-context";
import {
DocumentScriptContext,
type DocumentScriptRegistration,
} from "vinext/shims/document-script-context";
import type { ScriptProps } from "vinext/shims/script";
import {
createInlineScriptTag,
createNonceAttribute,
Expand Down Expand Up @@ -77,6 +82,15 @@ import {
runDocumentRenderPage,
} from "./pages-document-initial-props.js";
import { callDocumentGetInitialProps } from "./document-initial-head.js";
import {
insertAfterHeadOpen,
renderBeforeInteractiveInlineScripts,
} from "./before-interactive-head.js";
import { createBeforeInteractiveCollector } from "./before-interactive-collector.js";
import {
BeforeInteractiveContext,
type BeforeInteractiveInlineScript,
} from "vinext/shims/before-interactive-context";
import {
hasPagesGetInitialProps,
loadDevAppInitialProps,
Expand Down Expand Up @@ -320,7 +334,9 @@ async function streamPageToResponse(
server: ViteDevServer;
fontHeadHTML: string;
assetHeadHTML?: string;
scripts: string;
buildScripts: (scriptLoader: ScriptProps[]) => string;
documentScriptContext?: typeof DocumentScriptContext;
beforeInteractiveContext?: typeof BeforeInteractiveContext;
DocumentComponent: React.ComponentType | null;
statusCode?: number;
extraHeaders?: Record<string, string | string[]>;
Expand Down Expand Up @@ -360,7 +376,9 @@ async function streamPageToResponse(
server,
fontHeadHTML,
assetHeadHTML = "",
scripts,
buildScripts,
documentScriptContext = DocumentScriptContext,
beforeInteractiveContext = BeforeInteractiveContext,
DocumentComponent,
statusCode,
extraHeaders,
Expand All @@ -379,11 +397,13 @@ async function streamPageToResponse(
// streaming path stays as the default for the common case. The contract
// (including `withScriptNonce` and `styles` rendering) lives in the shared
// helper so dev and prod stay in lockstep.
const beforeInteractiveCollector = createBeforeInteractiveCollector(beforeInteractiveContext);
const documentRenderPage = await runDocumentRenderPage({
DocumentComponent,
enhancePageElement,
renderToReadableStream,
renderStylesToString: renderToStringAsync,
wrapPageElement: beforeInteractiveCollector.wrapPageElement,
scriptNonce,
context: documentContext,
});
Expand All @@ -402,7 +422,7 @@ async function streamPageToResponse(
// Start the React body stream FIRST — the promise resolves when the
// shell is ready (synchronous content outside Suspense boundaries).
// This triggers the render which populates <Head> tags.
bodyStream = await renderToReadableStream(element);
bodyStream = await renderToReadableStream(beforeInteractiveCollector.wrapPageElement(element));
}

// Fold any head tags returned by `_document.getInitialProps()` into the same
Expand Down Expand Up @@ -430,19 +450,40 @@ async function streamPageToResponse(

// Build the document shell with a placeholder for the body
let shellTemplate: string;
let beforeInteractiveHTML = "";

if (DocumentComponent) {
const documentClientScripts: ScriptProps[] = [];
const documentBeforeInteractiveScripts: BeforeInteractiveInlineScript[] = [];
// When the renderPage path already invoked getInitialProps, reuse its
// resolved props instead of calling it a second time.
// `skipped` means it was never invoked → fall through to the fast path.
const docProps =
documentRenderPage.status === "skipped"
? await loadUserDocumentInitialProps(DocumentComponent)
: documentRenderPage.docProps;
const docElement = docProps
const rawDocElement = docProps
? React.createElement(DocumentComponent, docProps)
: React.createElement(DocumentComponent);
const docElement = React.createElement(
documentScriptContext.Provider,
{
value(registration: DocumentScriptRegistration) {
if (registration.kind === "beforeInteractive") {
documentBeforeInteractiveScripts.push(registration.script);
} else {
documentClientScripts.push(registration.script);
}
},
},
rawDocElement,
);
let docHtml = await renderToStringAsync(docElement);
const scripts = buildScripts(documentClientScripts);
beforeInteractiveHTML = renderBeforeInteractiveInlineScripts([
...beforeInteractiveCollector.scripts,
...documentBeforeInteractiveScripts,
]);
// Replace __NEXT_MAIN__ with our stream marker
docHtml = docHtml.replace("__NEXT_MAIN__", STREAM_BODY_MARKER);
// Inject head tags
Expand All @@ -453,12 +494,31 @@ async function streamPageToResponse(
);
}
// Inject scripts: replace placeholder or append before </body>
docHtml = docHtml.replace("<!-- __NEXT_SCRIPTS__ -->", scripts);
const placeholder = "<!-- __NEXT_SCRIPTS__ -->";
const placeholderIndex = docHtml.indexOf(placeholder);
if (placeholderIndex !== -1) {
const spanOpen = docHtml.lastIndexOf("<span", placeholderIndex);
const spanClose = docHtml.indexOf("</span>", placeholderIndex + placeholder.length);
const openEnd = spanOpen === -1 ? -1 : docHtml.indexOf(">", spanOpen);
const onlyWhitespaceBefore =
openEnd !== -1 && docHtml.slice(openEnd + 1, placeholderIndex).trim().length === 0;
const onlyWhitespaceAfter =
spanClose !== -1 &&
docHtml.slice(placeholderIndex + placeholder.length, spanClose).trim().length === 0;
if (spanOpen !== -1 && onlyWhitespaceBefore && onlyWhitespaceAfter) {
docHtml = docHtml.slice(0, spanOpen) + scripts + docHtml.slice(spanClose + 7);
}
}
docHtml = docHtml.replace(placeholder, scripts);
if (!docHtml.includes("__NEXT_DATA__")) {
docHtml = docHtml.replace("</body>", ` ${scripts}\n</body>`);
}
shellTemplate = docHtml;
} else {
beforeInteractiveHTML = renderBeforeInteractiveInlineScripts(
beforeInteractiveCollector.scripts,
);
const scripts = buildScripts([]);
// charset + viewport are emitted via getSSRHeadHTML() (next/head's
// defaultHead seeds them with data-next-head=""), matching Next.js's
// canonical ordering. Don't duplicate them here.
Expand All @@ -477,7 +537,10 @@ async function streamPageToResponse(

// Apply Vite's HTML transforms (injects HMR client, etc.) on the full
// shell template, then split at the body marker.
const transformedShell = await server.transformIndexHtml(url, shellTemplate);
let transformedShell = await server.transformIndexHtml(url, shellTemplate);
if (beforeInteractiveHTML) {
transformedShell = insertAfterHeadOpen(transformedShell, beforeInteractiveHTML);
}
const markerIdx = transformedShell.indexOf(STREAM_BODY_MARKER);
const prefix = transformedShell.slice(0, markerIdx);
const suffix = transformedShell.slice(markerIdx + STREAM_BODY_MARKER.length);
Expand Down Expand Up @@ -1775,6 +1838,7 @@ import "vinext/instrumentation-client";
import React from "react";
import { hydrateRoot } from "react-dom/client";
import Router, { wrapWithRouterContext, _initializePagesRouterReadyFromNextData } from "next/router";
import { initScriptLoader } from "vinext/shims/script-loader";

const nextDataElement = document.getElementById("__NEXT_DATA__");
if (nextDataElement?.textContent) {
Expand All @@ -1785,6 +1849,7 @@ if (nextDataElement?.textContent) {
}
const nextData = window.__NEXT_DATA__;
_initializePagesRouterReadyFromNextData(nextData);
initScriptLoader(Array.isArray(nextData.scriptLoader) ? nextData.scriptLoader : []);
const props = nextData.props && typeof nextData.props === "object" ? nextData.props : {};
const rawPageProps = props.pageProps;
const pageProps = rawPageProps && typeof rawPageProps === "object" ? rawPageProps : {};
Expand Down Expand Up @@ -1845,21 +1910,6 @@ async function hydrate() {
hydrate();
</script>`;

const nextDataScript = `<script id="__NEXT_DATA__" type="application/json"${nonceAttr}>${safeJsonStringify(
{
props: renderProps,
page: patternToNextFormat(route.pattern),
query: params,
buildId: process.env.__VINEXT_BUILD_ID,
isFallback: isFallbackRender,
locale: locale ?? currentDefaultLocale,
locales: i18nConfig?.locales,
defaultLocale: currentDefaultLocale,
domainLocales,
...serializedPagesNextData,
},
)}</script>`;

// Try to load custom _document.tsx
const docPath = path.join(pagesDir, "_document");
// oxlint-disable-next-line typescript/no-explicit-any
Expand All @@ -1872,6 +1922,14 @@ hydrate();
// _document exists but failed to load
}
}
const documentScriptShim = (await importModule(
runner,
"vinext/shims/document-script-context",
)) as { DocumentScriptContext: typeof DocumentScriptContext };
const beforeInteractiveShim = (await importModule(
runner,
"vinext/shims/before-interactive-context",
)) as { BeforeInteractiveContext: typeof BeforeInteractiveContext };

// Expose page route patterns on window before hydration so the
// next/navigation compat hooks can resolve a dynamic pattern from a
Expand All @@ -1883,7 +1941,24 @@ hydrate();
scriptNonce,
);

const allScripts = `${nextDataScript}\n ${pagePatternsScript}\n ${hydrationScript}`;
const buildScripts = (scriptLoader: ScriptProps[]) => {
const nextDataScript = `<script id="__NEXT_DATA__" type="application/json"${nonceAttr}>${safeJsonStringify(
{
props: renderProps,
page: patternToNextFormat(route.pattern),
query: params,
buildId: process.env.__VINEXT_BUILD_ID,
isFallback: isFallbackRender,
locale: locale ?? currentDefaultLocale,
locales: i18nConfig?.locales,
defaultLocale: currentDefaultLocale,
domainLocales,
...serializedPagesNextData,
...(scriptLoader.length > 0 ? { scriptLoader } : {}),
},
)}</script>`;
return `${nextDataScript}\n ${pagePatternsScript}\n ${hydrationScript}`;
};

// Build response headers: start with gSSP headers, then layer on
// ISR and font preload headers (which take precedence).
Expand Down Expand Up @@ -1915,7 +1990,9 @@ hydrate();
server,
fontHeadHTML,
assetHeadHTML,
scripts: allScripts,
buildScripts,
documentScriptContext: documentScriptShim.DocumentScriptContext,
beforeInteractiveContext: beforeInteractiveShim.BeforeInteractiveContext,
DocumentComponent,
statusCode,
extraHeaders,
Expand Down Expand Up @@ -2006,7 +2083,7 @@ hydrate();
const isrBodyHtml = await renderIsrPassToStringAsync(
withScriptNonce(isrElement, scriptNonce),
);
const isrHtml = `<!DOCTYPE html><html><head>${assetHeadHTML}</head><body><div id="__next">${isrBodyHtml}</div>${allScripts}</body></html>`;
const isrHtml = `<!DOCTYPE html><html><head>${assetHeadHTML}</head><body><div id="__next">${isrBodyHtml}</div>${buildScripts([])}</body></html>`;
const cacheKey = pagesIsrCacheKey(url.split("?")[0]);
await isrSet(cacheKey, buildPagesCacheValue(isrHtml, pageProps), isrRevalidateSeconds);
setRevalidateDuration(cacheKey, isrRevalidateSeconds);
Expand Down Expand Up @@ -2179,15 +2256,25 @@ async function renderErrorPage(
[appAssetPath, errorAssetPath],
nonceAttr,
);
const beforeInteractiveShim = (await importModule(
runner,
"vinext/shims/before-interactive-context",
)) as { BeforeInteractiveContext: typeof BeforeInteractiveContext };

if (DocumentComponent) {
const errorPathname = candidate === "_error" ? "/_error" : `/${candidate}`;
const documentScriptShim = (await importModule(
runner,
"vinext/shims/document-script-context",
)) as { DocumentScriptContext: typeof DocumentScriptContext };
await streamPageToResponse(res, element, {
url,
server,
fontHeadHTML: "",
assetHeadHTML,
scripts: "",
buildScripts: () => "",
documentScriptContext: documentScriptShim.DocumentScriptContext,
beforeInteractiveContext: beforeInteractiveShim.BeforeInteractiveContext,
DocumentComponent,
statusCode,
documentContext: {
Expand Down
Loading
Loading