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
1 change: 1 addition & 0 deletions packages/vinext/src/client/empty-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {};
2 changes: 2 additions & 0 deletions packages/vinext/src/client/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/
import React from "react";
import { hydrateRoot } from "react-dom/client";
import "./instrumentation-client.js";
// Eagerly import the router shim so its module-level popstate listener is
// registered. Without this, browser back/forward buttons do nothing because
// navigateClient() is never invoked on history changes.
Expand Down Expand Up @@ -75,6 +76,7 @@ async function hydrate() {
// re-render the tree during client-side navigation. import.meta.hot.data
// is module-scoped and cannot be read across module boundaries.
window.__VINEXT_ROOT__ = root;
window.__VINEXT_HYDRATED_AT = performance.now();
}

void hydrate();
27 changes: 27 additions & 0 deletions packages/vinext/src/client/instrumentation-client-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ClientInstrumentationHooks } from "./instrumentation-client.js";

let clientInstrumentationHooks: ClientInstrumentationHooks | null = null;

export function normalizeClientInstrumentationHooks(
hooks: ClientInstrumentationHooks,
): ClientInstrumentationHooks | null {
return Object.values(hooks).some((value) => typeof value === "function") ? hooks : null;
}

export function setClientInstrumentationHooks(
hooks: ClientInstrumentationHooks | null,
): ClientInstrumentationHooks | null {
clientInstrumentationHooks = hooks;
return clientInstrumentationHooks;
}

export function getClientInstrumentationHooks(): ClientInstrumentationHooks | null {
return clientInstrumentationHooks;
}

export function notifyAppRouterTransitionStart(
href: string,
navigationType: "push" | "replace" | "traverse",
): void {
clientInstrumentationHooks?.onRouterTransitionStart?.(href, navigationType);
}
13 changes: 13 additions & 0 deletions packages/vinext/src/client/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as instrumentationClientHooks from "private-next-instrumentation-client";
import {
normalizeClientInstrumentationHooks,
setClientInstrumentationHooks,
} from "./instrumentation-client-state.js";

export interface ClientInstrumentationHooks {
onRouterTransitionStart?: (href: string, navigationType: "push" | "replace" | "traverse") => void;
}

export const clientInstrumentationHooks = setClientInstrumentationHooks(
normalizeClientInstrumentationHooks(instrumentationClientHooks as ClientInstrumentationHooks),
);
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 @@ -43,6 +43,7 @@ export async function generateClientEntry(
const appFileBase = appFilePath?.replace(/\\/g, "/");

return `
import "vinext/instrumentation-client";
import React from "react";
import { hydrateRoot } from "react-dom/client";
// Eagerly import the router shim so its module-level popstate listener is
Expand Down Expand Up @@ -105,6 +106,7 @@ async function hydrate() {

const root = hydrateRoot(container, element);
window.__VINEXT_ROOT__ = root;
window.__VINEXT_HYDRATED_AT = performance.now();
}

hydrate();
Expand Down
6 changes: 6 additions & 0 deletions packages/vinext/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ declare global {
*/
__VINEXT_ROOT__: Root | undefined;

/**
* High-resolution timestamp recorded after client hydration completes.
* Used by instrumentation-client compatibility tests.
*/
__VINEXT_HYDRATED_AT: number | undefined;

/**
* The cached `_app` component for Pages Router.
* Written and read by `shims/router.ts` to avoid re-importing on every
Expand Down
51 changes: 47 additions & 4 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ import {
import { findMiddlewareFile, runMiddleware } from "./server/middleware.js";
import { logRequest, now } from "./server/request-log.js";
import { normalizePath } from "./server/normalize-path.js";
import { findInstrumentationFile, runInstrumentation } from "./server/instrumentation.js";
import {
findInstrumentationClientFile,
findInstrumentationFile,
runInstrumentation,
} from "./server/instrumentation.js";
import { PHASE_PRODUCTION_BUILD, PHASE_DEVELOPMENT_SERVER } from "./shims/constants.js";
import { validateDevRequest } from "./server/dev-origin-check.js";
import {
Expand All @@ -57,6 +61,7 @@ import {
import { hasBasePath } from "./utils/base-path.js";
import { asyncHooksStubPlugin } from "./plugins/async-hooks-stub.js";
import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js";
import { createInstrumentationClientTransformPlugin } from "./plugins/instrumentation-client.js";
import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js";
import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js";
import tsconfigPaths from "vite-tsconfig-paths";
Expand Down Expand Up @@ -99,6 +104,10 @@ function resolveShimModulePath(shimsDir: string, moduleName: string): string {
return path.join(shimsDir, `${moduleName}.js`);
}

function toRelativeFileEntry(root: string, absPath: string): string {
return path.relative(root, absPath).split(path.sep).join("/");
}

/**
* Fetch Google Fonts CSS, download .woff2 files, cache locally, and return
* @font-face CSS with local file references.
Expand Down Expand Up @@ -1191,6 +1200,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
let fileMatcher: ReturnType<typeof createValidFileMatcher>;
let middlewarePath: string | null = null;
let instrumentationPath: string | null = null;
let instrumentationClientPath: string | null = null;
let hasCloudflarePlugin = false;
let warnedInlineNextConfigOverride = false;
let hasNitroPlugin = false;
Expand Down Expand Up @@ -1864,6 +1874,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
nextConfig = await resolveNextConfig(rawConfig, root);
fileMatcher = createValidFileMatcher(nextConfig.pageExtensions);
instrumentationPath = findInstrumentationFile(root, fileMatcher);
instrumentationClientPath = findInstrumentationClientFile(root, fileMatcher);
middlewarePath = findMiddlewareFile(root, fileMatcher);

// Merge env from next.config.js with NEXT_PUBLIC_* env vars
Expand Down Expand Up @@ -2011,7 +2022,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
"vinext/i18n-state": path.join(shimsDir, "i18n-state"),
"vinext/i18n-context": path.join(shimsDir, "i18n-context"),
"vinext/instrumentation": path.resolve(__dirname, "server", "instrumentation"),
"vinext/instrumentation-client": path.resolve(
__dirname,
"client",
"instrumentation-client",
),
"vinext/html": path.resolve(__dirname, "server", "html"),
"private-next-instrumentation-client":
instrumentationClientPath ?? path.resolve(__dirname, "client", "empty-module"),
}).flatMap(([k, v]) =>
k.startsWith("next/")
? [
Expand Down Expand Up @@ -2268,6 +2286,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
exclude: [...new Set([...incomingExclude, "vinext", "@vercel/og"])],
...(incomingInclude.length > 0 ? { include: incomingInclude } : {}),
};
const pagesOptimizeEntries = !hasAppDir
? [
...(hasPagesDir
? [toRelativeFileEntry(root, pagesDir) + "/**/*.{tsx,ts,jsx,js}"]
: []),
...[instrumentationPath, instrumentationClientPath].flatMap((entry) =>
entry ? [toRelativeFileEntry(root, entry)] : [],
),
]
: [];

// If app/ directory exists, configure RSC environments
if (hasAppDir) {
Expand All @@ -2279,6 +2307,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// The entries must be relative to the project root.
const relAppDir = path.relative(root, appDir);
const appEntries = [`${relAppDir}/**/*.{tsx,ts,jsx,js}`];
const explicitInstrumentationEntries = [
instrumentationPath,
instrumentationClientPath,
].flatMap((entry) => (entry ? [toRelativeFileEntry(root, entry)] : []));
const optimizeEntries = [...new Set([...appEntries, ...explicitInstrumentationEntries])];

viteConfig.environments = {
rsc: {
Expand Down Expand Up @@ -2307,7 +2340,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}),
optimizeDeps: {
exclude: [...new Set([...incomingExclude, "vinext", "@vercel/og"])],
entries: appEntries,
entries: optimizeEntries,
},
build: {
outDir: options.rscOutDir ?? "dist/server",
Expand All @@ -2332,7 +2365,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
}),
optimizeDeps: {
exclude: [...new Set([...incomingExclude, "vinext", "@vercel/og"])],
entries: appEntries,
entries: optimizeEntries,
},
build: {
outDir: options.ssrOutDir ?? "dist/server/ssr",
Expand Down Expand Up @@ -2364,7 +2397,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// Crawl app/ source files up front so client-only deps imported
// by user components are discovered during startup instead of
// triggering a late re-optimisation + full page reload.
entries: appEntries,
entries: optimizeEntries,
// React packages aren't crawled from app/ source files,
// so must be pre-included to avoid late discovery (#25).
include: [
Expand Down Expand Up @@ -2403,6 +2436,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
viteConfig.environments = {
client: {
consumer: "client",
optimizeDeps:
pagesOptimizeEntries.length > 0 ? { entries: pagesOptimizeEntries } : undefined,
build: {
manifest: true,
ssrManifest: true,
Expand All @@ -2416,6 +2451,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
};
}

if (pagesOptimizeEntries.length > 0 && !hasCloudflarePlugin) {
viteConfig.optimizeDeps = {
...viteConfig.optimizeDeps,
entries: pagesOptimizeEntries,
};
}

return viteConfig;
},

Expand Down Expand Up @@ -2599,6 +2641,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
},
// Stub node:async_hooks in client builds — see src/plugins/async-hooks-stub.ts
asyncHooksStubPlugin,
createInstrumentationClientTransformPlugin(() => instrumentationClientPath),
// Dedup client references from RSC proxy modules — see src/plugins/client-reference-dedup.ts
...(options.experimental?.clientReferenceDedup ? [clientReferenceDedupPlugin()] : []),
// Proxy plugin for @mdx-js/rollup. The real MDX plugin is created lazily
Expand Down
47 changes: 47 additions & 0 deletions packages/vinext/src/plugins/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Plugin } from "vite";
import { normalizePath, parseAst } from "vite";
import MagicString from "magic-string";

export function createInstrumentationClientTransformPlugin(
getInstrumentationClientPath: () => string | null,
): Plugin {
return {
name: "vinext:instrumentation-client",
apply: "serve",
transform(code, id) {
const instrumentationClientPath = getInstrumentationClientPath();
if (!instrumentationClientPath) return null;

const normalizedId = normalizePath(id.split("?", 1)[0]);
if (normalizedId !== normalizePath(instrumentationClientPath)) return null;
if (code.includes("__vinextInstrumentationClientDuration")) return null;

const ast = parseAst(code);
let insertPos = 0;
// When the module has no imports, inject the timer at the top so the
// measurement still wraps the full module body execution.
for (const node of ast.body) {
if (node.type === "ImportDeclaration") {
insertPos = node.end;
}
}

const s = new MagicString(code);
s.appendLeft(insertPos, "\nconst __vinextInstrumentationClientStart = performance.now();\n");
s.append(
"\nconst __vinextInstrumentationClientEnd = performance.now();\n" +
"const __vinextInstrumentationClientDuration = __vinextInstrumentationClientEnd - __vinextInstrumentationClientStart;\n" +
"// Match Next.js: only report slow client instrumentation during dev.\n" +
"// Production should execute the hook without additional timing overhead.\n" +
"if (__vinextInstrumentationClientDuration > 16) {\n" +
" console.log(`[Client Instrumentation Hook] Slow execution detected: ${__vinextInstrumentationClientDuration.toFixed(0)}ms (Note: Code download overhead is not included in this measurement)`);\n" +
"}\n",
);

return {
code: s.toString(),
map: s.generateMap({ hires: true }),
};
},
};
}
6 changes: 6 additions & 0 deletions packages/vinext/src/private-next-instrumentation-client.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare module "private-next-instrumentation-client" {
export function onRouterTransitionStart(
href: string,
navigationType: "push" | "replace" | "traverse",
): void;
}
4 changes: 4 additions & 0 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
} from "@vitejs/plugin-rsc/browser";
import { flushSync } from "react-dom";
import { hydrateRoot } from "react-dom/client";
import "../client/instrumentation-client.js";
import { notifyAppRouterTransitionStart } from "../client/instrumentation-client-state.js";
import {
PREFETCH_CACHE_TTL,
getPrefetchCache,
Expand Down Expand Up @@ -188,6 +190,7 @@ async function main(): Promise<void> {
);

window.__VINEXT_RSC_ROOT__ = reactRoot;
window.__VINEXT_HYDRATED_AT = performance.now();

window.__VINEXT_RSC_NAVIGATE__ = async function navigateRsc(
href: string,
Expand Down Expand Up @@ -262,6 +265,7 @@ async function main(): Promise<void> {
};

window.addEventListener("popstate", () => {
notifyAppRouterTransitionStart(window.location.href, "traverse");
const pendingNavigation =
window.__VINEXT_RSC_NAVIGATE__?.(window.location.href) ?? Promise.resolve();
window.__VINEXT_RSC_PENDING__ = pendingNavigation;
Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,7 @@ export function createSSRHandler(
// Stores the React root and page loader for client-side navigation.
const hydrationScript = `
<script type="module">
import "vinext/instrumentation-client";
import React from "react";
import { hydrateRoot } from "react-dom/client";
import { wrapWithRouterContext } from "next/router";
Expand All @@ -883,6 +884,7 @@ async function hydrate() {
element = wrapWithRouterContext(element);
const root = hydrateRoot(document.getElementById("__next"), element);
window.__VINEXT_ROOT__ = root;
window.__VINEXT_HYDRATED_AT = performance.now();
}
hydrate();
</script>`;
Expand Down
28 changes: 23 additions & 5 deletions packages/vinext/src/server/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,14 @@ export async function importModule(

const INSTRUMENTATION_LOCATIONS = ["", "src/"];

/**
* Find the instrumentation file in the project root.
*/
export function findInstrumentationFile(
function findInstrumentationHookFile(
root: string,
basename: string,
fileMatcher: ValidFileMatcher,
): string | null {
for (const dir of INSTRUMENTATION_LOCATIONS) {
for (const ext of fileMatcher.dottedExtensions) {
const fullPath = path.join(root, dir, `instrumentation${ext}`);
const fullPath = path.join(root, dir, `${basename}${ext}`);
if (fs.existsSync(fullPath)) {
return fullPath;
}
Expand All @@ -84,6 +82,26 @@ export function findInstrumentationFile(
return null;
}

/**
* Find the instrumentation file in the project root.
*/
export function findInstrumentationFile(
root: string,
fileMatcher: ValidFileMatcher,
): string | null {
return findInstrumentationHookFile(root, "instrumentation", fileMatcher);
}

/**
* Find the instrumentation-client file in the project root.
*/
export function findInstrumentationClientFile(
root: string,
fileMatcher: ValidFileMatcher,
): string | null {
return findInstrumentationHookFile(root, "instrumentation-client", fileMatcher);
}

/**
* The onRequestError handler type from Next.js instrumentation.
*
Expand Down
Loading
Loading