From f88347d12f26991b16d53c4427d1983aa87b8d64 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:40:14 +0800 Subject: [PATCH 1/7] feat: add instrumentation-client support --- packages/vinext/src/client/empty-module.ts | 1 + packages/vinext/src/client/entry.ts | 2 + .../client/instrumentation-client-state.ts | 14 ++ .../src/client/instrumentation-client.ts | 10 ++ .../vinext/src/entries/pages-client-entry.ts | 2 + packages/vinext/src/global.d.ts | 6 + packages/vinext/src/index.ts | 60 +++++++- .../src/plugins/instrumentation-client.ts | 43 ++++++ .../private-next-instrumentation-client.d.ts | 6 + .../vinext/src/server/app-browser-entry.ts | 4 + packages/vinext/src/server/dev-server.ts | 2 + packages/vinext/src/server/instrumentation.ts | 28 +++- packages/vinext/src/shims/link.tsx | 9 ++ packages/vinext/src/shims/navigation.ts | 9 ++ playwright.config.ts | 14 +- .../entry-templates.test.ts.snap | 93 +++++------ tests/app-router.test.ts | 6 + .../instrumentation-client-src.spec.ts | 38 +++++ .../app-router/instrumentation-client.spec.ts | 144 ++++++++++++++++++ .../instrumentation-client.spec.ts | 38 +++++ .../app/instrumentation-client/page.tsx | 10 ++ .../instrumentation-client/some-page/page.tsx | 10 ++ .../app-basic/instrumentation-client.ts | 16 ++ tests/fixtures/app-basic/vite.config.ts | 2 +- tests/fixtures/app-with-src/next-shims.d.ts | 16 ++ tests/fixtures/app-with-src/package.json | 16 ++ .../fixtures/app-with-src/src/app/layout.tsx | 7 + tests/fixtures/app-with-src/src/app/page.tsx | 3 + .../src/instrumentation-client.ts | 5 + tests/fixtures/app-with-src/tsconfig.json | 12 ++ tests/fixtures/app-with-src/vite.config.ts | 6 + .../pages-basic/instrumentation-client.ts | 5 + .../pages/instrumentation-client.tsx | 3 + tests/fixtures/pages-basic/vite.config.ts | 2 +- tests/instrumentation.test.ts | 50 +++++- tests/pages-router.test.ts | 12 ++ 36 files changed, 647 insertions(+), 57 deletions(-) create mode 100644 packages/vinext/src/client/empty-module.ts create mode 100644 packages/vinext/src/client/instrumentation-client-state.ts create mode 100644 packages/vinext/src/client/instrumentation-client.ts create mode 100644 packages/vinext/src/plugins/instrumentation-client.ts create mode 100644 packages/vinext/src/private-next-instrumentation-client.d.ts create mode 100644 tests/e2e/app-router/instrumentation-client-src.spec.ts create mode 100644 tests/e2e/app-router/instrumentation-client.spec.ts create mode 100644 tests/e2e/pages-router/instrumentation-client.spec.ts create mode 100644 tests/fixtures/app-basic/app/instrumentation-client/page.tsx create mode 100644 tests/fixtures/app-basic/app/instrumentation-client/some-page/page.tsx create mode 100644 tests/fixtures/app-basic/instrumentation-client.ts create mode 100644 tests/fixtures/app-with-src/next-shims.d.ts create mode 100644 tests/fixtures/app-with-src/package.json create mode 100644 tests/fixtures/app-with-src/src/app/layout.tsx create mode 100644 tests/fixtures/app-with-src/src/app/page.tsx create mode 100644 tests/fixtures/app-with-src/src/instrumentation-client.ts create mode 100644 tests/fixtures/app-with-src/tsconfig.json create mode 100644 tests/fixtures/app-with-src/vite.config.ts create mode 100644 tests/fixtures/pages-basic/instrumentation-client.ts create mode 100644 tests/fixtures/pages-basic/pages/instrumentation-client.tsx diff --git a/packages/vinext/src/client/empty-module.ts b/packages/vinext/src/client/empty-module.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/vinext/src/client/empty-module.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/vinext/src/client/entry.ts b/packages/vinext/src/client/entry.ts index 34d4019f2..c82d94b39 100644 --- a/packages/vinext/src/client/entry.ts +++ b/packages/vinext/src/client/entry.ts @@ -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. @@ -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(); diff --git a/packages/vinext/src/client/instrumentation-client-state.ts b/packages/vinext/src/client/instrumentation-client-state.ts new file mode 100644 index 000000000..d44eed92c --- /dev/null +++ b/packages/vinext/src/client/instrumentation-client-state.ts @@ -0,0 +1,14 @@ +import type { ClientInstrumentationHooks } from "./instrumentation-client.js"; + +let clientInstrumentationHooks: ClientInstrumentationHooks | null = null; + +export function setClientInstrumentationHooks( + hooks: ClientInstrumentationHooks | null, +): ClientInstrumentationHooks | null { + clientInstrumentationHooks = hooks; + return clientInstrumentationHooks; +} + +export function getClientInstrumentationHooks(): ClientInstrumentationHooks | null { + return clientInstrumentationHooks; +} diff --git a/packages/vinext/src/client/instrumentation-client.ts b/packages/vinext/src/client/instrumentation-client.ts new file mode 100644 index 000000000..ded1760ef --- /dev/null +++ b/packages/vinext/src/client/instrumentation-client.ts @@ -0,0 +1,10 @@ +import * as instrumentationClientHooks from "private-next-instrumentation-client"; +import { setClientInstrumentationHooks } from "./instrumentation-client-state.js"; + +export interface ClientInstrumentationHooks { + onRouterTransitionStart?: (href: string, navigationType: "push" | "replace" | "traverse") => void; +} + +export const clientInstrumentationHooks = setClientInstrumentationHooks( + instrumentationClientHooks as ClientInstrumentationHooks | null, +); diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index cf5e7c32e..5c3bfff66 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -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 @@ -105,6 +106,7 @@ async function hydrate() { const root = hydrateRoot(container, element); window.__VINEXT_ROOT__ = root; + window.__VINEXT_HYDRATED_AT = performance.now(); } hydrate(); diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index ff360f2cc..c1a637260 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -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 diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 7ccec5b7f..a738d9a5d 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -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 { @@ -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"; @@ -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. @@ -1191,6 +1200,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let fileMatcher: ReturnType; let middlewarePath: string | null = null; let instrumentationPath: string | null = null; + let instrumentationClientPath: string | null = null; let hasCloudflarePlugin = false; let warnedInlineNextConfigOverride = false; let hasNitroPlugin = false; @@ -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 @@ -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/") ? [ @@ -2279,6 +2297,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: { @@ -2307,7 +2330,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", @@ -2332,7 +2355,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", @@ -2364,7 +2387,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: [ @@ -2396,6 +2419,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }; } else if (hasCloudflarePlugin) { + const pagesEntries = hasPagesDir + ? [toRelativeFileEntry(root, pagesDir) + "/**/*.{tsx,ts,jsx,js}"] + : []; + const optimizeEntries = [ + ...pagesEntries, + ...[instrumentationPath, instrumentationClientPath].flatMap((entry) => + entry ? [toRelativeFileEntry(root, entry)] : [], + ), + ]; // Pages Router on Cloudflare Workers: add a client environment // so the multi-environment build produces client JS bundles // alongside the worker. Without this, only the worker is built @@ -2403,6 +2435,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { viteConfig.environments = { client: { consumer: "client", + optimizeDeps: optimizeEntries.length > 0 ? { entries: optimizeEntries } : undefined, build: { manifest: true, ssrManifest: true, @@ -2416,6 +2449,24 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }; } + if (!hasAppDir) { + const pagesEntries = hasPagesDir + ? [toRelativeFileEntry(root, pagesDir) + "/**/*.{tsx,ts,jsx,js}"] + : []; + const optimizeEntries = [ + ...pagesEntries, + ...[instrumentationPath, instrumentationClientPath].flatMap((entry) => + entry ? [toRelativeFileEntry(root, entry)] : [], + ), + ]; + if (optimizeEntries.length > 0) { + viteConfig.optimizeDeps = { + ...viteConfig.optimizeDeps, + entries: optimizeEntries, + }; + } + } + return viteConfig; }, @@ -2599,6 +2650,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 diff --git a/packages/vinext/src/plugins/instrumentation-client.ts b/packages/vinext/src/plugins/instrumentation-client.ts new file mode 100644 index 000000000..9a5b0a778 --- /dev/null +++ b/packages/vinext/src/plugins/instrumentation-client.ts @@ -0,0 +1,43 @@ +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; + 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" + + "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 }), + }; + }, + }; +} diff --git a/packages/vinext/src/private-next-instrumentation-client.d.ts b/packages/vinext/src/private-next-instrumentation-client.d.ts new file mode 100644 index 000000000..5b0bef012 --- /dev/null +++ b/packages/vinext/src/private-next-instrumentation-client.d.ts @@ -0,0 +1,6 @@ +declare module "private-next-instrumentation-client" { + export function onRouterTransitionStart( + href: string, + navigationType: "push" | "replace" | "traverse", + ): void; +} diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 3713228b1..b9a0cd5d9 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -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 { getClientInstrumentationHooks } from "../client/instrumentation-client-state.js"; import { PREFETCH_CACHE_TTL, getPrefetchCache, @@ -188,6 +190,7 @@ async function main(): Promise { ); window.__VINEXT_RSC_ROOT__ = reactRoot; + window.__VINEXT_HYDRATED_AT = performance.now(); window.__VINEXT_RSC_NAVIGATE__ = async function navigateRsc( href: string, @@ -262,6 +265,7 @@ async function main(): Promise { }; window.addEventListener("popstate", () => { + getClientInstrumentationHooks()?.onRouterTransitionStart?.(window.location.href, "traverse"); const pendingNavigation = window.__VINEXT_RSC_NAVIGATE__?.(window.location.href) ?? Promise.resolve(); window.__VINEXT_RSC_PENDING__ = pendingNavigation; diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 4e494ea1e..fc767b5de 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -857,6 +857,7 @@ export function createSSRHandler( // Stores the React root and page loader for client-side navigation. const hydrationScript = ` `; diff --git a/packages/vinext/src/server/instrumentation.ts b/packages/vinext/src/server/instrumentation.ts index 20bd353ed..08828df2b 100644 --- a/packages/vinext/src/server/instrumentation.ts +++ b/packages/vinext/src/server/instrumentation.ts @@ -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; } @@ -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. * diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 258d992af..20c4c869d 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -21,6 +21,7 @@ import React, { // Import shared RSC prefetch utilities from navigation shim (relative path // so this resolves both via the Vite plugin and in direct vitest imports) import { toRscUrl, getPrefetchedUrls, storePrefetchResponse } from "./navigation.js"; +import { getClientInstrumentationHooks } from "../client/instrumentation-client-state.js"; import { isDangerousScheme } from "./url-safety.js"; import { resolveRelativeHref, @@ -122,6 +123,13 @@ function scrollToHash(hash: string): void { } } +function onRouterTransitionStart( + href: string, + navigationType: "push" | "replace" | "traverse", +): void { + getClientInstrumentationHooks()?.onRouterTransitionStart?.(href, navigationType); +} + // --------------------------------------------------------------------------- // Prefetching infrastructure // --------------------------------------------------------------------------- @@ -470,6 +478,7 @@ const Link = forwardRef(function Link( // App Router: push/replace history state, then fetch RSC stream. // Await the RSC navigate so scroll-to-top happens after the new // content is committed to the DOM (prevents flash of old page at top). + onRouterTransitionStart(absoluteFullHref, replace ? "replace" : "push"); if (replace) { window.history.replaceState(null, "", absoluteFullHref); } else { diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 2e23b5b8c..60522f18f 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -11,6 +11,7 @@ // would throw at link time for missing bindings. With `import * as React`, the // bindings are just `undefined` on the namespace object and we can guard at runtime. import * as React from "react"; +import { getClientInstrumentationHooks } from "../client/instrumentation-client-state.js"; import { toBrowserNavigationHref, toSameOriginAppPath } from "./url-utils.js"; import { stripBasePath } from "../utils/base-path.js"; import { ReadonlyURLSearchParams } from "./readonly-url-search-params.js"; @@ -512,6 +513,13 @@ function restoreScrollPosition(state: unknown): void { } } +function onRouterTransitionStart( + href: string, + navigationType: "push" | "replace" | "traverse", +): void { + getClientInstrumentationHooks()?.onRouterTransitionStart?.(href, navigationType); +} + /** * Navigate to a URL, handling external URLs, hash-only changes, and RSC navigation. */ @@ -537,6 +545,7 @@ async function navigateImpl( } const fullHref = toBrowserNavigationHref(normalizedHref, window.location.href, __basePath); + onRouterTransitionStart(fullHref, mode); // Save scroll position before navigating (for back/forward restoration) if (mode === "push") { diff --git a/playwright.config.ts b/playwright.config.ts index e28c64a4d..b36b2bb7e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,7 +22,7 @@ const projectServers = { testMatch: ["**/app-router/**/*.spec.ts", "**/og-image.spec.ts"], use: { baseURL: "http://localhost:4174" }, server: { - command: "npx vp dev --port 4174", + command: "npx tsc -p ../../../packages/vinext/tsconfig.json && npx vp dev --port 4174", cwd: "./tests/fixtures/app-basic", port: 4174, reuseExistingServer: !process.env.CI, @@ -107,6 +107,18 @@ const projectServers = { timeout: 30_000, }, }, + "app-router-src": { + testDir: "./tests/e2e", + testMatch: ["**/app-router/instrumentation-client-src.spec.ts"], + use: { baseURL: "http://localhost:4180" }, + server: { + command: "npx tsc -p ../../../packages/vinext/tsconfig.json && npx vp dev --port 4180", + cwd: "./tests/fixtures/app-with-src", + port: 4180, + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, + }, }; type ProjectName = keyof typeof projectServers; diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 2b7224a58..350a53a3b 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -13581,6 +13581,7 @@ export { default } from "/packages/vinext/src/server/app-ssr-entry.ts"; exports[`Pages Router entry templates > client entry snapshot 1`] = ` " +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 @@ -13605,6 +13606,7 @@ const pageLoaders = { "/dynamic-page": () => import("/tests/fixtures/pages-basic/pages/dynamic-page.tsx"), "/dynamic-ssr-false": () => import("/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"), "/header-override-delete": () => import("/tests/fixtures/pages-basic/pages/header-override-delete.tsx"), + "/instrumentation-client": () => import("/tests/fixtures/pages-basic/pages/instrumentation-client.tsx"), "/isr-second-render-state": () => import("/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx"), "/isr-test": () => import("/tests/fixtures/pages-basic/pages/isr-test.tsx"), "/link-test": () => import("/tests/fixtures/pages-basic/pages/link-test.tsx"), @@ -13674,6 +13676,7 @@ async function hydrate() { const root = hydrateRoot(container, element); window.__VINEXT_ROOT__ = root; + window.__VINEXT_HYDRATED_AT = performance.now(); } hydrate(); @@ -13833,28 +13836,29 @@ import * as page_12 from "/tests/fixtures/pages-basic/pages/counter.tsx"; import * as page_13 from "/tests/fixtures/pages-basic/pages/dynamic-page.tsx"; import * as page_14 from "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx"; import * as page_15 from "/tests/fixtures/pages-basic/pages/header-override-delete.tsx"; -import * as page_16 from "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx"; -import * as page_17 from "/tests/fixtures/pages-basic/pages/isr-test.tsx"; -import * as page_18 from "/tests/fixtures/pages-basic/pages/link-test.tsx"; -import * as page_19 from "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"; -import * as page_20 from "/tests/fixtures/pages-basic/pages/nav-test.tsx"; -import * as page_21 from "/tests/fixtures/pages-basic/pages/posts/missing.tsx"; -import * as page_22 from "/tests/fixtures/pages-basic/pages/redirect-xss.tsx"; -import * as page_23 from "/tests/fixtures/pages-basic/pages/router-events-test.tsx"; -import * as page_24 from "/tests/fixtures/pages-basic/pages/script-test.tsx"; -import * as page_25 from "/tests/fixtures/pages-basic/pages/shallow-test.tsx"; -import * as page_26 from "/tests/fixtures/pages-basic/pages/ssr.tsx"; -import * as page_27 from "/tests/fixtures/pages-basic/pages/ssr-headers.tsx"; -import * as page_28 from "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"; -import * as page_29 from "/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx"; -import * as page_30 from "/tests/fixtures/pages-basic/pages/streaming-ssr.tsx"; -import * as page_31 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; -import * as page_32 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; -import * as page_33 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; -import * as page_34 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; -import * as page_35 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; -import * as page_36 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; -import * as page_37 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; +import * as page_16 from "/tests/fixtures/pages-basic/pages/instrumentation-client.tsx"; +import * as page_17 from "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx"; +import * as page_18 from "/tests/fixtures/pages-basic/pages/isr-test.tsx"; +import * as page_19 from "/tests/fixtures/pages-basic/pages/link-test.tsx"; +import * as page_20 from "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx"; +import * as page_21 from "/tests/fixtures/pages-basic/pages/nav-test.tsx"; +import * as page_22 from "/tests/fixtures/pages-basic/pages/posts/missing.tsx"; +import * as page_23 from "/tests/fixtures/pages-basic/pages/redirect-xss.tsx"; +import * as page_24 from "/tests/fixtures/pages-basic/pages/router-events-test.tsx"; +import * as page_25 from "/tests/fixtures/pages-basic/pages/script-test.tsx"; +import * as page_26 from "/tests/fixtures/pages-basic/pages/shallow-test.tsx"; +import * as page_27 from "/tests/fixtures/pages-basic/pages/ssr.tsx"; +import * as page_28 from "/tests/fixtures/pages-basic/pages/ssr-headers.tsx"; +import * as page_29 from "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx"; +import * as page_30 from "/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx"; +import * as page_31 from "/tests/fixtures/pages-basic/pages/streaming-ssr.tsx"; +import * as page_32 from "/tests/fixtures/pages-basic/pages/suspense-test.tsx"; +import * as page_33 from "/tests/fixtures/pages-basic/pages/articles/[id].tsx"; +import * as page_34 from "/tests/fixtures/pages-basic/pages/blog/[slug].tsx"; +import * as page_35 from "/tests/fixtures/pages-basic/pages/posts/[id].tsx"; +import * as page_36 from "/tests/fixtures/pages-basic/pages/products/[pid].tsx"; +import * as page_37 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; +import * as page_38 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; import * as api_0 from "/tests/fixtures/pages-basic/pages/api/binary.ts"; import * as api_1 from "/tests/fixtures/pages-basic/pages/api/echo-body.ts"; import * as api_2 from "/tests/fixtures/pages-basic/pages/api/error-route.ts"; @@ -13886,28 +13890,29 @@ export const pageRoutes = [ { pattern: "/dynamic-page", patternParts: ["dynamic-page"], isDynamic: false, params: [], module: page_13, filePath: "/tests/fixtures/pages-basic/pages/dynamic-page.tsx" }, { pattern: "/dynamic-ssr-false", patternParts: ["dynamic-ssr-false"], isDynamic: false, params: [], module: page_14, filePath: "/tests/fixtures/pages-basic/pages/dynamic-ssr-false.tsx" }, { pattern: "/header-override-delete", patternParts: ["header-override-delete"], isDynamic: false, params: [], module: page_15, filePath: "/tests/fixtures/pages-basic/pages/header-override-delete.tsx" }, - { pattern: "/isr-second-render-state", patternParts: ["isr-second-render-state"], isDynamic: false, params: [], module: page_16, filePath: "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx" }, - { pattern: "/isr-test", patternParts: ["isr-test"], isDynamic: false, params: [], module: page_17, filePath: "/tests/fixtures/pages-basic/pages/isr-test.tsx" }, - { pattern: "/link-test", patternParts: ["link-test"], isDynamic: false, params: [], module: page_18, filePath: "/tests/fixtures/pages-basic/pages/link-test.tsx" }, - { pattern: "/mw-object-gated", patternParts: ["mw-object-gated"], isDynamic: false, params: [], module: page_19, filePath: "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx" }, - { pattern: "/nav-test", patternParts: ["nav-test"], isDynamic: false, params: [], module: page_20, filePath: "/tests/fixtures/pages-basic/pages/nav-test.tsx" }, - { pattern: "/posts/missing", patternParts: ["posts","missing"], isDynamic: false, params: [], module: page_21, filePath: "/tests/fixtures/pages-basic/pages/posts/missing.tsx" }, - { pattern: "/redirect-xss", patternParts: ["redirect-xss"], isDynamic: false, params: [], module: page_22, filePath: "/tests/fixtures/pages-basic/pages/redirect-xss.tsx" }, - { pattern: "/router-events-test", patternParts: ["router-events-test"], isDynamic: false, params: [], module: page_23, filePath: "/tests/fixtures/pages-basic/pages/router-events-test.tsx" }, - { pattern: "/script-test", patternParts: ["script-test"], isDynamic: false, params: [], module: page_24, filePath: "/tests/fixtures/pages-basic/pages/script-test.tsx" }, - { pattern: "/shallow-test", patternParts: ["shallow-test"], isDynamic: false, params: [], module: page_25, filePath: "/tests/fixtures/pages-basic/pages/shallow-test.tsx" }, - { pattern: "/ssr", patternParts: ["ssr"], isDynamic: false, params: [], module: page_26, filePath: "/tests/fixtures/pages-basic/pages/ssr.tsx" }, - { pattern: "/ssr-headers", patternParts: ["ssr-headers"], isDynamic: false, params: [], module: page_27, filePath: "/tests/fixtures/pages-basic/pages/ssr-headers.tsx" }, - { pattern: "/ssr-res-end", patternParts: ["ssr-res-end"], isDynamic: false, params: [], module: page_28, filePath: "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx" }, - { pattern: "/streaming-gssp-content-length", patternParts: ["streaming-gssp-content-length"], isDynamic: false, params: [], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx" }, - { pattern: "/streaming-ssr", patternParts: ["streaming-ssr"], isDynamic: false, params: [], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/streaming-ssr.tsx" }, - { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, - { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_32, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, - { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_33, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, - { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_34, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, - { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_35, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, - { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_36, filePath: "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx" }, - { pattern: "/sign-up/:sign-up*", patternParts: ["sign-up",":sign-up*"], isDynamic: true, params: ["sign-up"], module: page_37, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } + { pattern: "/instrumentation-client", patternParts: ["instrumentation-client"], isDynamic: false, params: [], module: page_16, filePath: "/tests/fixtures/pages-basic/pages/instrumentation-client.tsx" }, + { pattern: "/isr-second-render-state", patternParts: ["isr-second-render-state"], isDynamic: false, params: [], module: page_17, filePath: "/tests/fixtures/pages-basic/pages/isr-second-render-state.tsx" }, + { pattern: "/isr-test", patternParts: ["isr-test"], isDynamic: false, params: [], module: page_18, filePath: "/tests/fixtures/pages-basic/pages/isr-test.tsx" }, + { pattern: "/link-test", patternParts: ["link-test"], isDynamic: false, params: [], module: page_19, filePath: "/tests/fixtures/pages-basic/pages/link-test.tsx" }, + { pattern: "/mw-object-gated", patternParts: ["mw-object-gated"], isDynamic: false, params: [], module: page_20, filePath: "/tests/fixtures/pages-basic/pages/mw-object-gated.tsx" }, + { pattern: "/nav-test", patternParts: ["nav-test"], isDynamic: false, params: [], module: page_21, filePath: "/tests/fixtures/pages-basic/pages/nav-test.tsx" }, + { pattern: "/posts/missing", patternParts: ["posts","missing"], isDynamic: false, params: [], module: page_22, filePath: "/tests/fixtures/pages-basic/pages/posts/missing.tsx" }, + { pattern: "/redirect-xss", patternParts: ["redirect-xss"], isDynamic: false, params: [], module: page_23, filePath: "/tests/fixtures/pages-basic/pages/redirect-xss.tsx" }, + { pattern: "/router-events-test", patternParts: ["router-events-test"], isDynamic: false, params: [], module: page_24, filePath: "/tests/fixtures/pages-basic/pages/router-events-test.tsx" }, + { pattern: "/script-test", patternParts: ["script-test"], isDynamic: false, params: [], module: page_25, filePath: "/tests/fixtures/pages-basic/pages/script-test.tsx" }, + { pattern: "/shallow-test", patternParts: ["shallow-test"], isDynamic: false, params: [], module: page_26, filePath: "/tests/fixtures/pages-basic/pages/shallow-test.tsx" }, + { pattern: "/ssr", patternParts: ["ssr"], isDynamic: false, params: [], module: page_27, filePath: "/tests/fixtures/pages-basic/pages/ssr.tsx" }, + { pattern: "/ssr-headers", patternParts: ["ssr-headers"], isDynamic: false, params: [], module: page_28, filePath: "/tests/fixtures/pages-basic/pages/ssr-headers.tsx" }, + { pattern: "/ssr-res-end", patternParts: ["ssr-res-end"], isDynamic: false, params: [], module: page_29, filePath: "/tests/fixtures/pages-basic/pages/ssr-res-end.tsx" }, + { pattern: "/streaming-gssp-content-length", patternParts: ["streaming-gssp-content-length"], isDynamic: false, params: [], module: page_30, filePath: "/tests/fixtures/pages-basic/pages/streaming-gssp-content-length.tsx" }, + { pattern: "/streaming-ssr", patternParts: ["streaming-ssr"], isDynamic: false, params: [], module: page_31, filePath: "/tests/fixtures/pages-basic/pages/streaming-ssr.tsx" }, + { pattern: "/suspense-test", patternParts: ["suspense-test"], isDynamic: false, params: [], module: page_32, filePath: "/tests/fixtures/pages-basic/pages/suspense-test.tsx" }, + { pattern: "/articles/:id", patternParts: ["articles",":id"], isDynamic: true, params: ["id"], module: page_33, filePath: "/tests/fixtures/pages-basic/pages/articles/[id].tsx" }, + { pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, params: ["slug"], module: page_34, filePath: "/tests/fixtures/pages-basic/pages/blog/[slug].tsx" }, + { pattern: "/posts/:id", patternParts: ["posts",":id"], isDynamic: true, params: ["id"], module: page_35, filePath: "/tests/fixtures/pages-basic/pages/posts/[id].tsx" }, + { pattern: "/products/:pid", patternParts: ["products",":pid"], isDynamic: true, params: ["pid"], module: page_36, filePath: "/tests/fixtures/pages-basic/pages/products/[pid].tsx" }, + { pattern: "/docs/:slug+", patternParts: ["docs",":slug+"], isDynamic: true, params: ["slug"], module: page_37, filePath: "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx" }, + { pattern: "/sign-up/:sign-up*", patternParts: ["sign-up",":sign-up*"], isDynamic: true, params: ["sign-up"], module: page_38, filePath: "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx" } ]; const _pageRouteTrie = _buildRouteTrie(pageRoutes); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 27cd3f510..ec8ba733b 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1110,6 +1110,12 @@ describe("App Router integration", () => { expect(rscGlob).toMatch(/app\/\*\*\/\*\.\{tsx,ts,jsx,js\}/); expect(ssrGlob).toMatch(/app\/\*\*\/\*\.\{tsx,ts,jsx,js\}/); expect(clientGlob).toMatch(/app\/\*\*\/\*\.\{tsx,ts,jsx,js\}/); + expect(rscGlob).toContain("instrumentation.ts"); + expect(rscGlob).toContain("instrumentation-client.ts"); + expect(ssrGlob).toContain("instrumentation.ts"); + expect(ssrGlob).toContain("instrumentation-client.ts"); + expect(clientGlob).toContain("instrumentation.ts"); + expect(clientGlob).toContain("instrumentation-client.ts"); }); it("pre-includes framework dependencies in optimizeDeps.include to avoid late discovery", () => { diff --git a/tests/e2e/app-router/instrumentation-client-src.spec.ts b/tests/e2e/app-router/instrumentation-client-src.spec.ts new file mode 100644 index 000000000..b42187db1 --- /dev/null +++ b/tests/e2e/app-router/instrumentation-client-src.spec.ts @@ -0,0 +1,38 @@ +// Ported from Next.js: +// test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts +// https://github.com/vercel/next.js/blob/canary/test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts + +import { test, expect } from "@playwright/test"; + +test("executes src/instrumentation-client before hydration", async ({ page }) => { + await page.goto("/"); + await page.waitForFunction(() => { + const win = window as Window & { + __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; + __VINEXT_HYDRATED_AT?: number; + }; + return ( + win.__INSTRUMENTATION_CLIENT_EXECUTED_AT !== undefined && + win.__VINEXT_HYDRATED_AT !== undefined + ); + }); + + const timing = await page.evaluate(() => { + const win = window as Window & { + __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; + __VINEXT_HYDRATED_AT?: number; + }; + return { + instrumentation: win.__INSTRUMENTATION_CLIENT_EXECUTED_AT, + hydration: win.__VINEXT_HYDRATED_AT, + }; + }); + + expect(timing.instrumentation).toBeDefined(); + expect(timing.hydration).toBeDefined(); + if (timing.instrumentation === undefined || timing.hydration === undefined) { + throw new Error("Instrumentation or hydration timing marker was not recorded"); + } + expect(timing.instrumentation).toBeLessThan(timing.hydration); + await expect(page.locator("#app-with-src-home")).toBeVisible(); +}); diff --git a/tests/e2e/app-router/instrumentation-client.spec.ts b/tests/e2e/app-router/instrumentation-client.spec.ts new file mode 100644 index 000000000..51962422f --- /dev/null +++ b/tests/e2e/app-router/instrumentation-client.spec.ts @@ -0,0 +1,144 @@ +// Ported from Next.js: +// test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts +// https://github.com/vercel/next.js/blob/canary/test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts + +import { test, expect } from "@playwright/test"; +import { promises as fs } from "node:fs"; + +const instrumentationClientPath = `${process.cwd()}/tests/fixtures/app-basic/instrumentation-client.ts`; + +function filterNavigationStartLogs(logs: string[]): string[] { + return logs.filter((message) => message.startsWith("[Router Transition Start]")); +} + +async function waitForHydration(page: import("@playwright/test").Page): Promise { + await page.waitForFunction(() => !!window.__VINEXT_RSC_ROOT__); +} + +test.describe.serial("instrumentation-client (App Router)", () => { + test("executes instrumentation-client before hydration", async ({ page }) => { + const logs: string[] = []; + page.on("console", (message) => logs.push(message.text())); + + await page.goto("/instrumentation-client"); + await waitForHydration(page); + await page.waitForFunction(() => { + const win = window as Window & { + __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; + __VINEXT_HYDRATED_AT?: number; + }; + return ( + win.__INSTRUMENTATION_CLIENT_EXECUTED_AT !== undefined && + win.__VINEXT_HYDRATED_AT !== undefined + ); + }); + + const timing = await page.evaluate(() => { + const win = window as Window & { + __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; + __VINEXT_HYDRATED_AT?: number; + }; + return { + instrumentation: win.__INSTRUMENTATION_CLIENT_EXECUTED_AT, + hydration: win.__VINEXT_HYDRATED_AT, + }; + }); + + expect(timing.instrumentation).toBeDefined(); + expect(timing.hydration).toBeDefined(); + if (timing.instrumentation === undefined || timing.hydration === undefined) { + throw new Error("Instrumentation or hydration timing marker was not recorded"); + } + expect(timing.instrumentation).toBeLessThan(timing.hydration); + expect( + logs.some((message) => + message.startsWith("[Client Instrumentation Hook] Slow execution detected"), + ), + ).toBe(true); + }); + + test("onRouterTransitionStart fires at the start of push navigations", async ({ page }) => { + const logs: string[] = []; + page.on("console", (message) => logs.push(message.text())); + + await page.goto("/instrumentation-client"); + await waitForHydration(page); + await page.getByRole("link", { name: "Go to Some Page" }).click(); + await expect(page.locator("#instrumentation-client-some-page")).toBeVisible(); + await waitForHydration(page); + + await page.getByRole("link", { name: "Go Home" }).click(); + await expect(page.locator("#instrumentation-client-home")).toBeVisible(); + await waitForHydration(page); + + expect(filterNavigationStartLogs(logs)).toEqual([ + "[Router Transition Start] [push] /instrumentation-client/some-page", + "[Router Transition Start] [push] /instrumentation-client", + ]); + }); + + test("onRouterTransitionStart fires at the start of back/forward navigations", async ({ + page, + }) => { + const logs: string[] = []; + page.on("console", (message) => logs.push(message.text())); + + await page.goto("/instrumentation-client"); + await waitForHydration(page); + await page.getByRole("link", { name: "Go to Some Page" }).click(); + await expect(page.locator("#instrumentation-client-some-page")).toBeVisible(); + await waitForHydration(page); + + await page.goBack(); + await expect(page.locator("#instrumentation-client-home")).toBeVisible(); + await waitForHydration(page); + + await page.goForward(); + await expect(page.locator("#instrumentation-client-some-page")).toBeVisible(); + await waitForHydration(page); + + expect(filterNavigationStartLogs(logs)).toEqual([ + "[Router Transition Start] [push] /instrumentation-client/some-page", + "[Router Transition Start] [traverse] /instrumentation-client", + "[Router Transition Start] [traverse] /instrumentation-client/some-page", + ]); + }); + + test("reloads instrumentation-client when modified in dev", async ({ page }) => { + const originalContent = await fs.readFile(instrumentationClientPath, "utf8"); + + try { + await page.goto("/instrumentation-client"); + await waitForHydration(page); + await page.waitForFunction(() => { + const win = window as Window & { __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number }; + return win.__INSTRUMENTATION_CLIENT_EXECUTED_AT !== undefined; + }); + + const initialTime = await page.evaluate(() => { + const win = window as Window & { __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number }; + return win.__INSTRUMENTATION_CLIENT_EXECUTED_AT; + }); + expect(initialTime).toBeDefined(); + + await fs.writeFile( + instrumentationClientPath, + `${originalContent}\n(window as Window & { __INSTRUMENTATION_CLIENT_UPDATED?: boolean }).__INSTRUMENTATION_CLIENT_UPDATED = true;\n`, + ); + + await page.waitForFunction(() => { + const win = window as Window & { __INSTRUMENTATION_CLIENT_UPDATED?: boolean }; + return win.__INSTRUMENTATION_CLIENT_UPDATED === true; + }); + + const newTime = await page.evaluate(() => { + const win = window as Window & { __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number }; + return win.__INSTRUMENTATION_CLIENT_EXECUTED_AT; + }); + expect(newTime).toBeDefined(); + expect(newTime).not.toBe(initialTime); + } finally { + await fs.writeFile(instrumentationClientPath, originalContent); + } + }); +}); diff --git a/tests/e2e/pages-router/instrumentation-client.spec.ts b/tests/e2e/pages-router/instrumentation-client.spec.ts new file mode 100644 index 000000000..ab99ee3c9 --- /dev/null +++ b/tests/e2e/pages-router/instrumentation-client.spec.ts @@ -0,0 +1,38 @@ +// Ported from Next.js: +// test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts +// https://github.com/vercel/next.js/blob/canary/test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts + +import { test, expect } from "@playwright/test"; + +test("executes instrumentation-client before hydration in Pages Router", async ({ page }) => { + await page.goto("/instrumentation-client"); + await page.waitForFunction(() => { + const win = window as Window & { + __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; + __VINEXT_HYDRATED_AT?: number; + }; + return ( + win.__INSTRUMENTATION_CLIENT_EXECUTED_AT !== undefined && + win.__VINEXT_HYDRATED_AT !== undefined + ); + }); + + const timing = await page.evaluate(() => { + const win = window as Window & { + __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; + __VINEXT_HYDRATED_AT?: number; + }; + return { + instrumentation: win.__INSTRUMENTATION_CLIENT_EXECUTED_AT, + hydration: win.__VINEXT_HYDRATED_AT, + }; + }); + + expect(timing.instrumentation).toBeDefined(); + expect(timing.hydration).toBeDefined(); + if (timing.instrumentation === undefined || timing.hydration === undefined) { + throw new Error("Instrumentation or hydration timing marker was not recorded"); + } + expect(timing.instrumentation).toBeLessThan(timing.hydration); + await expect(page.locator("#pages-instrumentation-client")).toBeVisible(); +}); diff --git a/tests/fixtures/app-basic/app/instrumentation-client/page.tsx b/tests/fixtures/app-basic/app/instrumentation-client/page.tsx new file mode 100644 index 000000000..2b1276459 --- /dev/null +++ b/tests/fixtures/app-basic/app/instrumentation-client/page.tsx @@ -0,0 +1,10 @@ +import Link from "next/link"; + +export default function InstrumentationClientPage() { + return ( +
+

Instrumentation Client Home

+ Go to Some Page +
+ ); +} diff --git a/tests/fixtures/app-basic/app/instrumentation-client/some-page/page.tsx b/tests/fixtures/app-basic/app/instrumentation-client/some-page/page.tsx new file mode 100644 index 000000000..6f8a4ee9b --- /dev/null +++ b/tests/fixtures/app-basic/app/instrumentation-client/some-page/page.tsx @@ -0,0 +1,10 @@ +import Link from "next/link"; + +export default function InstrumentationClientSomePage() { + return ( +
+

Instrumentation Client Some Page

+ Go Home +
+ ); +} diff --git a/tests/fixtures/app-basic/instrumentation-client.ts b/tests/fixtures/app-basic/instrumentation-client.ts new file mode 100644 index 000000000..169327c75 --- /dev/null +++ b/tests/fixtures/app-basic/instrumentation-client.ts @@ -0,0 +1,16 @@ +const instrumentationWindow = window as Window & { + __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; + __INSTRUMENTATION_CLIENT_UPDATED?: boolean; +}; + +instrumentationWindow.__INSTRUMENTATION_CLIENT_EXECUTED_AT = performance.now(); + +const start = performance.now(); +while (performance.now() - start < 20) { + // Intentionally block for 20ms to verify slow-execution logging in dev. +} + +export function onRouterTransitionStart(href: string, navigationType: string): void { + const pathname = new URL(href, window.location.href).pathname; + console.log(`[Router Transition Start] [${navigationType}] ${pathname}`); +} diff --git a/tests/fixtures/app-basic/vite.config.ts b/tests/fixtures/app-basic/vite.config.ts index d0a0fd505..ed29d31dd 100644 --- a/tests/fixtures/app-basic/vite.config.ts +++ b/tests/fixtures/app-basic/vite.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "vite"; -import vinext from "vinext"; +import vinext from "../../../packages/vinext/src/index.js"; export default defineConfig({ plugins: [vinext({ appDir: import.meta.dirname })], diff --git a/tests/fixtures/app-with-src/next-shims.d.ts b/tests/fixtures/app-with-src/next-shims.d.ts new file mode 100644 index 000000000..fcbc9f8f7 --- /dev/null +++ b/tests/fixtures/app-with-src/next-shims.d.ts @@ -0,0 +1,16 @@ +declare module "next/link" { + import type { ComponentType, AnchorHTMLAttributes, ReactNode } from "react"; + type UrlQueryValue = string | number | boolean | null | undefined; + type UrlQuery = Record; + interface LinkProps extends Omit, "href"> { + href: string | { pathname?: string; query?: UrlQuery }; + as?: string; + replace?: boolean; + prefetch?: boolean; + scroll?: boolean; + onNavigate?: (event: { preventDefault(): void }) => void; + children?: ReactNode; + } + const Link: ComponentType; + export default Link; +} diff --git a/tests/fixtures/app-with-src/package.json b/tests/fixtures/app-with-src/package.json new file mode 100644 index 000000000..6569fd5b4 --- /dev/null +++ b/tests/fixtures/app-with-src/package.json @@ -0,0 +1,16 @@ +{ + "name": "app-with-src-fixture", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-rsc": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-server-dom-webpack": "catalog:", + "vinext": "workspace:*", + "vite": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/tests/fixtures/app-with-src/src/app/layout.tsx b/tests/fixtures/app-with-src/src/app/layout.tsx new file mode 100644 index 000000000..f3ef34cd8 --- /dev/null +++ b/tests/fixtures/app-with-src/src/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/app-with-src/src/app/page.tsx b/tests/fixtures/app-with-src/src/app/page.tsx new file mode 100644 index 000000000..5b15fd66f --- /dev/null +++ b/tests/fixtures/app-with-src/src/app/page.tsx @@ -0,0 +1,3 @@ +export default function HomePage() { + return

App With Src

; +} diff --git a/tests/fixtures/app-with-src/src/instrumentation-client.ts b/tests/fixtures/app-with-src/src/instrumentation-client.ts new file mode 100644 index 000000000..5d696ce3c --- /dev/null +++ b/tests/fixtures/app-with-src/src/instrumentation-client.ts @@ -0,0 +1,5 @@ +const instrumentationWindow = window as Window & { + __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; +}; + +instrumentationWindow.__INSTRUMENTATION_CLIENT_EXECUTED_AT = performance.now(); diff --git a/tests/fixtures/app-with-src/tsconfig.json b/tests/fixtures/app-with-src/tsconfig.json new file mode 100644 index 000000000..72021d641 --- /dev/null +++ b/tests/fixtures/app-with-src/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "types": ["vite/client", "@vitejs/plugin-rsc/types"] + }, + "include": ["src", "*.ts"] +} diff --git a/tests/fixtures/app-with-src/vite.config.ts b/tests/fixtures/app-with-src/vite.config.ts new file mode 100644 index 000000000..11a52049a --- /dev/null +++ b/tests/fixtures/app-with-src/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import vinext from "../../../packages/vinext/src/index.js"; + +export default defineConfig({ + plugins: [vinext({ appDir: `${import.meta.dirname}/src` })], +}); diff --git a/tests/fixtures/pages-basic/instrumentation-client.ts b/tests/fixtures/pages-basic/instrumentation-client.ts new file mode 100644 index 000000000..5d696ce3c --- /dev/null +++ b/tests/fixtures/pages-basic/instrumentation-client.ts @@ -0,0 +1,5 @@ +const instrumentationWindow = window as Window & { + __INSTRUMENTATION_CLIENT_EXECUTED_AT?: number; +}; + +instrumentationWindow.__INSTRUMENTATION_CLIENT_EXECUTED_AT = performance.now(); diff --git a/tests/fixtures/pages-basic/pages/instrumentation-client.tsx b/tests/fixtures/pages-basic/pages/instrumentation-client.tsx new file mode 100644 index 000000000..d330ce0c4 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/instrumentation-client.tsx @@ -0,0 +1,3 @@ +export default function InstrumentationClientPage() { + return

Pages Instrumentation Client

; +} diff --git a/tests/fixtures/pages-basic/vite.config.ts b/tests/fixtures/pages-basic/vite.config.ts index 88f1c2014..85e419efc 100644 --- a/tests/fixtures/pages-basic/vite.config.ts +++ b/tests/fixtures/pages-basic/vite.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "vite-plus"; -import vinext from "vinext"; +import vinext from "../../../packages/vinext/src/index.js"; export default defineConfig({ plugins: [vinext()], diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index e2481d9a2..cab5fef21 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test" import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { findInstrumentationFile } from "../packages/vinext/src/server/instrumentation.js"; +import { + findInstrumentationClientFile, + findInstrumentationFile, +} from "../packages/vinext/src/server/instrumentation.js"; import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js"; // The runInstrumentation/reportRequestError describe blocks re-import via @@ -56,6 +59,51 @@ describe("findInstrumentationFile", () => { }); }); +describe("findInstrumentationClientFile", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-instr-client-")); + }); + + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns the path when a file exists at root", () => { + fs.writeFileSync(path.join(tmpDir, "instrumentation-client.ts"), ""); + + const result = findInstrumentationClientFile(tmpDir, createValidFileMatcher()); + + expect(result).toBe(path.join(tmpDir, "instrumentation-client.ts")); + }); + + it("prefers root over src/ directory (priority order)", () => { + fs.writeFileSync(path.join(tmpDir, "instrumentation-client.ts"), ""); + fs.mkdirSync(path.join(tmpDir, "src")); + fs.writeFileSync(path.join(tmpDir, "src", "instrumentation-client.ts"), ""); + + const result = findInstrumentationClientFile(tmpDir, createValidFileMatcher()); + + expect(result).toBe(path.join(tmpDir, "instrumentation-client.ts")); + }); + + it("falls back to src/ directory", () => { + fs.mkdirSync(path.join(tmpDir, "src")); + fs.writeFileSync(path.join(tmpDir, "src", "instrumentation-client.ts"), ""); + + const result = findInstrumentationClientFile(tmpDir, createValidFileMatcher()); + + expect(result).toBe(path.join(tmpDir, "src", "instrumentation-client.ts")); + }); + + it("returns null when no instrumentation-client file exists", () => { + const result = findInstrumentationClientFile(tmpDir, createValidFileMatcher()); + + expect(result).toBeNull(); + }); +}); + describe("runInstrumentation", () => { let runInstrumentation: typeof import("../packages/vinext/src/server/instrumentation.js").runInstrumentation; let getOnRequestErrorHandler: typeof import("../packages/vinext/src/server/instrumentation.js").getOnRequestErrorHandler; diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 3a09dcaa6..d8149c534 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -223,6 +223,18 @@ describe("Pages Router integration", () => { expect(html).toContain("Go to About"); }); + it("sets optimizeDeps.entries for pages and instrumentation hooks so deps are discovered at startup", () => { + const entries = server.config.optimizeDeps?.entries; + + expect(entries).toBeDefined(); + expect(Array.isArray(entries)).toBe(true); + + const glob = (entries as string[]).join(","); + expect(glob).toMatch(/pages\/\*\*\/\*\.\{tsx,ts,jsx,js\}/); + expect(glob).toContain("instrumentation.ts"); + expect(glob).toContain("instrumentation-client.ts"); + }); + it("resolves tsconfig path aliases (@/ imports)", async () => { const res = await fetch(`${baseUrl}/alias-test`); expect(res.status).toBe(200); From 063bb89eecdaa4d84ad9b937be39db9acb01553c Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:47:00 +0800 Subject: [PATCH 2/7] trigger ci From 5c5e7e93e76469a5c26af5436f754929531da077 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:47:53 +0800 Subject: [PATCH 3/7] fix deps --- pnpm-lock.yaml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00e8348b5..16a457c0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -896,6 +896,31 @@ importers: specifier: 'catalog:' version: 0.1.12(@types/node@25.2.3)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3) + tests/fixtures/app-with-src: + dependencies: + '@vitejs/plugin-rsc': + specifier: 'catalog:' + version: 0.5.20(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + react-server-dom-webpack: + specifier: 'catalog:' + version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vinext: + specifier: workspace:* + version: link:../../../packages/vinext + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.1.12 + version: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3)' + devDependencies: + vite-plus: + specifier: 'catalog:' + version: 0.1.12(@types/node@25.2.3)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3))(esbuild@0.27.3)(jiti@2.6.1)(typescript@5.9.3) + tests/fixtures/cf-app-basic: dependencies: '@cloudflare/vite-plugin': From 5f20d8e78e9ce55c45a564c1073fa100bfb87cdb Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:59:45 +0800 Subject: [PATCH 4/7] tweaks --- playwright.config.ts | 5 ++--- .../instrumentation-client.spec.ts} | 0 2 files changed, 2 insertions(+), 3 deletions(-) rename tests/e2e/{app-router/instrumentation-client-src.spec.ts => app-with-src/instrumentation-client.spec.ts} (100%) diff --git a/playwright.config.ts b/playwright.config.ts index b36b2bb7e..774392915 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -107,9 +107,8 @@ const projectServers = { timeout: 30_000, }, }, - "app-router-src": { - testDir: "./tests/e2e", - testMatch: ["**/app-router/instrumentation-client-src.spec.ts"], + "app-with-src": { + testDir: "./tests/e2e/app-with-src", use: { baseURL: "http://localhost:4180" }, server: { command: "npx tsc -p ../../../packages/vinext/tsconfig.json && npx vp dev --port 4180", diff --git a/tests/e2e/app-router/instrumentation-client-src.spec.ts b/tests/e2e/app-with-src/instrumentation-client.spec.ts similarity index 100% rename from tests/e2e/app-router/instrumentation-client-src.spec.ts rename to tests/e2e/app-with-src/instrumentation-client.spec.ts From b6a2cacb69565c2314e12afb81cc83ebface39aa Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:54:29 +0800 Subject: [PATCH 5/7] tweaks --- .../client/instrumentation-client-state.ts | 13 ++++++ .../src/client/instrumentation-client.ts | 7 ++- packages/vinext/src/index.ts | 43 ++++++++----------- packages/vinext/src/shims/link.tsx | 14 +++--- packages/vinext/src/shims/navigation.ts | 11 +---- playwright.config.ts | 6 +-- tests/fixtures/app-basic/vite.config.ts | 2 +- tests/fixtures/app-with-src/vite.config.ts | 2 +- tests/fixtures/pages-basic/vite.config.ts | 2 +- 9 files changed, 48 insertions(+), 52 deletions(-) diff --git a/packages/vinext/src/client/instrumentation-client-state.ts b/packages/vinext/src/client/instrumentation-client-state.ts index d44eed92c..656a7bff9 100644 --- a/packages/vinext/src/client/instrumentation-client-state.ts +++ b/packages/vinext/src/client/instrumentation-client-state.ts @@ -2,6 +2,12 @@ import type { ClientInstrumentationHooks } from "./instrumentation-client.js"; let clientInstrumentationHooks: ClientInstrumentationHooks | null = null; +export function normalizeClientInstrumentationHooks( + hooks: ClientInstrumentationHooks, +): ClientInstrumentationHooks | null { + return typeof hooks.onRouterTransitionStart === "function" ? hooks : null; +} + export function setClientInstrumentationHooks( hooks: ClientInstrumentationHooks | null, ): ClientInstrumentationHooks | null { @@ -12,3 +18,10 @@ export function setClientInstrumentationHooks( export function getClientInstrumentationHooks(): ClientInstrumentationHooks | null { return clientInstrumentationHooks; } + +export function notifyAppRouterTransitionStart( + href: string, + navigationType: "push" | "replace" | "traverse", +): void { + clientInstrumentationHooks?.onRouterTransitionStart?.(href, navigationType); +} diff --git a/packages/vinext/src/client/instrumentation-client.ts b/packages/vinext/src/client/instrumentation-client.ts index ded1760ef..5f04fc73a 100644 --- a/packages/vinext/src/client/instrumentation-client.ts +++ b/packages/vinext/src/client/instrumentation-client.ts @@ -1,10 +1,13 @@ import * as instrumentationClientHooks from "private-next-instrumentation-client"; -import { setClientInstrumentationHooks } from "./instrumentation-client-state.js"; +import { + normalizeClientInstrumentationHooks, + setClientInstrumentationHooks, +} from "./instrumentation-client-state.js"; export interface ClientInstrumentationHooks { onRouterTransitionStart?: (href: string, navigationType: "push" | "replace" | "traverse") => void; } export const clientInstrumentationHooks = setClientInstrumentationHooks( - instrumentationClientHooks as ClientInstrumentationHooks | null, + normalizeClientInstrumentationHooks(instrumentationClientHooks as ClientInstrumentationHooks), ); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index a738d9a5d..676693447 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2286,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) { @@ -2419,15 +2429,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }; } else if (hasCloudflarePlugin) { - const pagesEntries = hasPagesDir - ? [toRelativeFileEntry(root, pagesDir) + "/**/*.{tsx,ts,jsx,js}"] - : []; - const optimizeEntries = [ - ...pagesEntries, - ...[instrumentationPath, instrumentationClientPath].flatMap((entry) => - entry ? [toRelativeFileEntry(root, entry)] : [], - ), - ]; // Pages Router on Cloudflare Workers: add a client environment // so the multi-environment build produces client JS bundles // alongside the worker. Without this, only the worker is built @@ -2435,7 +2436,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { viteConfig.environments = { client: { consumer: "client", - optimizeDeps: optimizeEntries.length > 0 ? { entries: optimizeEntries } : undefined, + optimizeDeps: + pagesOptimizeEntries.length > 0 ? { entries: pagesOptimizeEntries } : undefined, build: { manifest: true, ssrManifest: true, @@ -2449,22 +2451,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }; } - if (!hasAppDir) { - const pagesEntries = hasPagesDir - ? [toRelativeFileEntry(root, pagesDir) + "/**/*.{tsx,ts,jsx,js}"] - : []; - const optimizeEntries = [ - ...pagesEntries, - ...[instrumentationPath, instrumentationClientPath].flatMap((entry) => - entry ? [toRelativeFileEntry(root, entry)] : [], - ), - ]; - if (optimizeEntries.length > 0) { - viteConfig.optimizeDeps = { - ...viteConfig.optimizeDeps, - entries: optimizeEntries, - }; - } + if (pagesOptimizeEntries.length > 0) { + viteConfig.optimizeDeps = { + ...viteConfig.optimizeDeps, + entries: pagesOptimizeEntries, + }; } return viteConfig; diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 20c4c869d..abe6ce5e6 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -21,7 +21,7 @@ import React, { // Import shared RSC prefetch utilities from navigation shim (relative path // so this resolves both via the Vite plugin and in direct vitest imports) import { toRscUrl, getPrefetchedUrls, storePrefetchResponse } from "./navigation.js"; -import { getClientInstrumentationHooks } from "../client/instrumentation-client-state.js"; +import { notifyAppRouterTransitionStart } from "../client/instrumentation-client-state.js"; import { isDangerousScheme } from "./url-safety.js"; import { resolveRelativeHref, @@ -123,13 +123,6 @@ function scrollToHash(hash: string): void { } } -function onRouterTransitionStart( - href: string, - navigationType: "push" | "replace" | "traverse", -): void { - getClientInstrumentationHooks()?.onRouterTransitionStart?.(href, navigationType); -} - // --------------------------------------------------------------------------- // Prefetching infrastructure // --------------------------------------------------------------------------- @@ -478,7 +471,7 @@ const Link = forwardRef(function Link( // App Router: push/replace history state, then fetch RSC stream. // Await the RSC navigate so scroll-to-top happens after the new // content is committed to the DOM (prevents flash of old page at top). - onRouterTransitionStart(absoluteFullHref, replace ? "replace" : "push"); + notifyAppRouterTransitionStart(absoluteFullHref, replace ? "replace" : "push"); if (replace) { window.history.replaceState(null, "", absoluteFullHref); } else { @@ -491,6 +484,9 @@ const Link = forwardRef(function Link( if (mountedRef.current) setPending(false); } } else { + // Next.js only consumes onRouterTransitionStart in the App Router. + // Pages Router still executes instrumentation-client side effects + // during startup, but it does not invoke the named export on navigation. // Pages Router: use the Router singleton try { const routerModule = await import("next/router"); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 60522f18f..428520e7f 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -11,7 +11,7 @@ // would throw at link time for missing bindings. With `import * as React`, the // bindings are just `undefined` on the namespace object and we can guard at runtime. import * as React from "react"; -import { getClientInstrumentationHooks } from "../client/instrumentation-client-state.js"; +import { notifyAppRouterTransitionStart } from "../client/instrumentation-client-state.js"; import { toBrowserNavigationHref, toSameOriginAppPath } from "./url-utils.js"; import { stripBasePath } from "../utils/base-path.js"; import { ReadonlyURLSearchParams } from "./readonly-url-search-params.js"; @@ -513,13 +513,6 @@ function restoreScrollPosition(state: unknown): void { } } -function onRouterTransitionStart( - href: string, - navigationType: "push" | "replace" | "traverse", -): void { - getClientInstrumentationHooks()?.onRouterTransitionStart?.(href, navigationType); -} - /** * Navigate to a URL, handling external URLs, hash-only changes, and RSC navigation. */ @@ -545,7 +538,7 @@ async function navigateImpl( } const fullHref = toBrowserNavigationHref(normalizedHref, window.location.href, __basePath); - onRouterTransitionStart(fullHref, mode); + notifyAppRouterTransitionStart(fullHref, mode); // Save scroll position before navigating (for back/forward restoration) if (mode === "push") { diff --git a/playwright.config.ts b/playwright.config.ts index 774392915..b061a086a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,7 +10,7 @@ const projectServers = { testDir: "./tests/e2e/pages-router", use: { baseURL: "http://localhost:4173" }, server: { - command: "npx tsc -p ../../../packages/vinext/tsconfig.json && npx vp dev --port 4173", + command: "npx vp run vinext#build && npx vp dev --port 4173", cwd: "./tests/fixtures/pages-basic", port: 4173, reuseExistingServer: !process.env.CI, @@ -22,7 +22,7 @@ const projectServers = { testMatch: ["**/app-router/**/*.spec.ts", "**/og-image.spec.ts"], use: { baseURL: "http://localhost:4174" }, server: { - command: "npx tsc -p ../../../packages/vinext/tsconfig.json && npx vp dev --port 4174", + command: "npx vp run vinext#build && npx vp dev --port 4174", cwd: "./tests/fixtures/app-basic", port: 4174, reuseExistingServer: !process.env.CI, @@ -111,7 +111,7 @@ const projectServers = { testDir: "./tests/e2e/app-with-src", use: { baseURL: "http://localhost:4180" }, server: { - command: "npx tsc -p ../../../packages/vinext/tsconfig.json && npx vp dev --port 4180", + command: "npx vp run vinext#build && npx vp dev --port 4180", cwd: "./tests/fixtures/app-with-src", port: 4180, reuseExistingServer: !process.env.CI, diff --git a/tests/fixtures/app-basic/vite.config.ts b/tests/fixtures/app-basic/vite.config.ts index ed29d31dd..d0a0fd505 100644 --- a/tests/fixtures/app-basic/vite.config.ts +++ b/tests/fixtures/app-basic/vite.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "vite"; -import vinext from "../../../packages/vinext/src/index.js"; +import vinext from "vinext"; export default defineConfig({ plugins: [vinext({ appDir: import.meta.dirname })], diff --git a/tests/fixtures/app-with-src/vite.config.ts b/tests/fixtures/app-with-src/vite.config.ts index 11a52049a..b16df5458 100644 --- a/tests/fixtures/app-with-src/vite.config.ts +++ b/tests/fixtures/app-with-src/vite.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "vite"; -import vinext from "../../../packages/vinext/src/index.js"; +import vinext from "vinext"; export default defineConfig({ plugins: [vinext({ appDir: `${import.meta.dirname}/src` })], diff --git a/tests/fixtures/pages-basic/vite.config.ts b/tests/fixtures/pages-basic/vite.config.ts index 85e419efc..88f1c2014 100644 --- a/tests/fixtures/pages-basic/vite.config.ts +++ b/tests/fixtures/pages-basic/vite.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "vite-plus"; -import vinext from "../../../packages/vinext/src/index.js"; +import vinext from "vinext"; export default defineConfig({ plugins: [vinext()], From faf073a4a827c1b5fd1a14417b3c8e39443f2057 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:12:30 +0800 Subject: [PATCH 6/7] tweaks --- packages/vinext/src/index.ts | 2 +- packages/vinext/src/plugins/instrumentation-client.ts | 2 ++ packages/vinext/src/server/app-browser-entry.ts | 4 ++-- tests/fixtures/app-basic/instrumentation-client.ts | 8 +++++--- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 676693447..b06cf8644 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2451,7 +2451,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }; } - if (pagesOptimizeEntries.length > 0) { + if (pagesOptimizeEntries.length > 0 && !hasCloudflarePlugin) { viteConfig.optimizeDeps = { ...viteConfig.optimizeDeps, entries: pagesOptimizeEntries, diff --git a/packages/vinext/src/plugins/instrumentation-client.ts b/packages/vinext/src/plugins/instrumentation-client.ts index 9a5b0a778..3a3c926f9 100644 --- a/packages/vinext/src/plugins/instrumentation-client.ts +++ b/packages/vinext/src/plugins/instrumentation-client.ts @@ -29,6 +29,8 @@ export function createInstrumentationClientTransformPlugin( 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", diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index b9a0cd5d9..0f53e6f47 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -12,7 +12,7 @@ import { import { flushSync } from "react-dom"; import { hydrateRoot } from "react-dom/client"; import "../client/instrumentation-client.js"; -import { getClientInstrumentationHooks } from "../client/instrumentation-client-state.js"; +import { notifyAppRouterTransitionStart } from "../client/instrumentation-client-state.js"; import { PREFETCH_CACHE_TTL, getPrefetchCache, @@ -265,7 +265,7 @@ async function main(): Promise { }; window.addEventListener("popstate", () => { - getClientInstrumentationHooks()?.onRouterTransitionStart?.(window.location.href, "traverse"); + notifyAppRouterTransitionStart(window.location.href, "traverse"); const pendingNavigation = window.__VINEXT_RSC_NAVIGATE__?.(window.location.href) ?? Promise.resolve(); window.__VINEXT_RSC_PENDING__ = pendingNavigation; diff --git a/tests/fixtures/app-basic/instrumentation-client.ts b/tests/fixtures/app-basic/instrumentation-client.ts index 169327c75..006886ce1 100644 --- a/tests/fixtures/app-basic/instrumentation-client.ts +++ b/tests/fixtures/app-basic/instrumentation-client.ts @@ -5,9 +5,11 @@ const instrumentationWindow = window as Window & { instrumentationWindow.__INSTRUMENTATION_CLIENT_EXECUTED_AT = performance.now(); -const start = performance.now(); -while (performance.now() - start < 20) { - // Intentionally block for 20ms to verify slow-execution logging in dev. +if (window.location.pathname.startsWith("/instrumentation-client")) { + const start = performance.now(); + while (performance.now() - start < 20) { + // Intentionally block for 20ms to verify slow-execution logging in dev. + } } export function onRouterTransitionStart(href: string, navigationType: string): void { From 215900e8e29e87e74cf915261733c289e399cadc Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:53:44 +0800 Subject: [PATCH 7/7] tweaks --- packages/vinext/src/client/instrumentation-client-state.ts | 2 +- packages/vinext/src/plugins/instrumentation-client.ts | 2 ++ packages/vinext/src/shims/navigation.ts | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/client/instrumentation-client-state.ts b/packages/vinext/src/client/instrumentation-client-state.ts index 656a7bff9..7ac572b68 100644 --- a/packages/vinext/src/client/instrumentation-client-state.ts +++ b/packages/vinext/src/client/instrumentation-client-state.ts @@ -5,7 +5,7 @@ let clientInstrumentationHooks: ClientInstrumentationHooks | null = null; export function normalizeClientInstrumentationHooks( hooks: ClientInstrumentationHooks, ): ClientInstrumentationHooks | null { - return typeof hooks.onRouterTransitionStart === "function" ? hooks : null; + return Object.values(hooks).some((value) => typeof value === "function") ? hooks : null; } export function setClientInstrumentationHooks( diff --git a/packages/vinext/src/plugins/instrumentation-client.ts b/packages/vinext/src/plugins/instrumentation-client.ts index 3a3c926f9..c6588f07d 100644 --- a/packages/vinext/src/plugins/instrumentation-client.ts +++ b/packages/vinext/src/plugins/instrumentation-client.ts @@ -18,6 +18,8 @@ export function createInstrumentationClientTransformPlugin( 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; diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 428520e7f..7ed5faff2 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -538,6 +538,8 @@ async function navigateImpl( } const fullHref = toBrowserNavigationHref(normalizedHref, window.location.href, __basePath); + // Match Next.js: App Router reports navigation start before dispatching, + // including hash-only navigations that short-circuit after URL update. notifyAppRouterTransitionStart(fullHref, mode); // Save scroll position before navigating (for back/forward restoration)