From 489bea8fea76327bb7b5d9901fd36a2931d89433 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Thu, 21 May 2026 15:34:25 -0700 Subject: [PATCH 1/6] feat(instrumentation): support nextConfig.instrumentationClientInject (#1326) Add support for the Next.js instrumentationClientInject config option that lets users inject client bootstrap modules for side effects and an optional onRouterTransitionStart hook ahead of the user's instrumentation-client file. Changes: - New generateInstrumentationClientInjectModule() generates a virtual ESM module with side-effect imports in array order + composed hook fan-out. - Accept instrumentationClientInject in NextConfig / ResolvedNextConfig. - New vinext:instrumentation-client-inject Vite plugin with resolveId/load hooks; virtual module string is precomputed once in config() for efficiency. - Unit tests cover empty injects, single/multi specifiers, hook ordering, and empty-module fallback when no user file exists. --- .../client/instrumentation-client-inject.ts | 53 +++++++++++++++++++ packages/vinext/src/config/next-config.ts | 11 ++++ packages/vinext/src/index.ts | 33 ++++++++++++ tests/instrumentation.test.ts | 50 +++++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 packages/vinext/src/client/instrumentation-client-inject.ts diff --git a/packages/vinext/src/client/instrumentation-client-inject.ts b/packages/vinext/src/client/instrumentation-client-inject.ts new file mode 100644 index 000000000..a40e5a13e --- /dev/null +++ b/packages/vinext/src/client/instrumentation-client-inject.ts @@ -0,0 +1,53 @@ +/** + * Generate a virtual ESM module that implements the Next.js + * `instrumentationClientInject` contract for client bootstrap. + * + * When `injects` is empty, this is a transparent passthrough (or no-op + * when no user file exists). Otherwise it generates side-effect imports + * for each inject in array order, then the user's instrumentation-client + * file last, and exports a single composed `onRouterTransitionStart` that + * fans out to every module's hook. + * + * @param injects - Module specifiers from `nextConfig.instrumentationClientInject` + * @param userPath - Absolute path to the user's `instrumentation-client` file, + * or `null` when the file doesn't exist + */ +export function generateInstrumentationClientInjectModule( + injects: readonly string[], + userPath: string | null, +): string { + const EMPTY_MODULE = "vinext/client/empty-module"; + + // No injects: Next.js keeps the current transparent passthrough. + // The alias already handles the user file or empty-module, so emit + // nothing that could shadow what the alias resolves. + if (injects.length === 0) { + return "export {};"; + } + + const lines: string[] = []; + + for (let i = 0; i < injects.length; i++) { + lines.push(`import * as __vinj_${i} from ${JSON.stringify(injects[i])};`); + } + + const lastIndex = injects.length; + lines.push(`import * as __vinj_${lastIndex} from ${JSON.stringify(userPath ?? EMPTY_MODULE)};`); + + const hookCalls: string[] = []; + for (let i = 0; i <= lastIndex; i++) { + hookCalls.push( + ` if (typeof __vinj_${i}.onRouterTransitionStart === \"function\") {`, + ` __vinj_${i}.onRouterTransitionStart(url, type);`, + ` }`, + ); + } + + lines.push(""); + lines.push("export function onRouterTransitionStart(url, type) {"); + lines.push(...hookCalls); + lines.push(`}`); + lines.push(""); + + return lines.join("\n"); +} diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 33384c609..e33986a19 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -211,6 +211,12 @@ export type NextConfig = { output?: "export" | "standalone"; /** File extensions treated as routable pages/routes (Next.js pageExtensions) */ pageExtensions?: string[]; + /** + * Module specifiers that are required for side effects on the client before + * hydration, in array order, ahead of the user's `instrumentation-client.{ts,js}`. + * Each entry may be a bare npm package name or a path relative to the project root. + */ + instrumentationClientInject?: string[]; /** Extra origins allowed to access the dev server. */ allowedDevOrigins?: string[]; /** Maximum age in seconds for stale ISR entries before blocking regeneration. */ @@ -290,6 +296,7 @@ export type ResolvedNextConfig = { trailingSlash: boolean; output: "" | "export" | "standalone"; pageExtensions: string[]; + instrumentationClientInject: string[]; cacheComponents: boolean; redirects: NextRedirect[]; rewrites: { @@ -951,6 +958,7 @@ export async function resolveNextConfig( buildId, deploymentId, sassOptions: null, + instrumentationClientInject: [], }; detectNextIntlConfig(root, resolved); return resolved; @@ -1130,6 +1138,9 @@ export async function resolveNextConfig( trailingSlash: config.trailingSlash ?? false, output: output === "export" || output === "standalone" ? output : "", pageExtensions, + instrumentationClientInject: Array.isArray(config.instrumentationClientInject) + ? (config.instrumentationClientInject as string[]) + : [], cacheComponents: config.cacheComponents ?? false, redirects, rewrites, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index d275cc833..e78d6466a 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -93,6 +93,7 @@ import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js" import { dataUrlCssPlugin } from "./plugins/css-data-url.js"; import { createRscClientReferenceLoadersPlugin } from "./plugins/rsc-client-reference-loaders.js"; import { createInstrumentationClientTransformPlugin } from "./plugins/instrumentation-client.js"; +import { generateInstrumentationClientInjectModule } from "./client/instrumentation-client-inject.js"; import { createMiddlewareServerOnlyPlugin } from "./plugins/middleware-server-only.js"; import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js"; import { createOgInlineFetchAssetsPlugin, ogAssetsPlugin } from "./plugins/og-assets.js"; @@ -423,6 +424,9 @@ const VIRTUAL_APP_BROWSER_ENTRY = "virtual:vinext-app-browser-entry"; const RESOLVED_APP_BROWSER_ENTRY = "\0" + VIRTUAL_APP_BROWSER_ENTRY; const VIRTUAL_ROOT_PARAMS = "virtual:vinext-root-params"; const RESOLVED_ROOT_PARAMS = "\0" + VIRTUAL_ROOT_PARAMS; +/** Virtual module for composed instrumentation-client bootstrap. */ +const VIRTUAL_INSTRUMENTATION_CLIENT = "private-next-instrumentation-client"; +const RESOLVED_INSTRUMENTATION_CLIENT = "\0" + VIRTUAL_INSTRUMENTATION_CLIENT; /** Image file extensions handled by the vinext:image-imports plugin. * Shared between the Rolldown hook filter and the transform handler regex. */ const IMAGE_EXTS = "png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?"; @@ -639,6 +643,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let middlewarePath: string | null = null; let instrumentationPath: string | null = null; let instrumentationClientPath: string | null = null; + let clientInjectModule: string | null = null; let hasCloudflarePlugin = false; let warnedInlineNextConfigOverride = false; let hasNitroPlugin = false; @@ -1057,6 +1062,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { instrumentationPath = findInstrumentationFile(root, fileMatcher); instrumentationClientPath = findInstrumentationClientFile(root, fileMatcher); middlewarePath = findMiddlewareFile(root, fileMatcher); + if (nextConfig.instrumentationClientInject.length > 0) { + clientInjectModule = generateInstrumentationClientInjectModule( + nextConfig.instrumentationClientInject, + instrumentationClientPath, + ); + } if (env?.command === "build") { await writeRouteTypes(); } @@ -2298,6 +2309,28 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Stub node:async_hooks in client builds — see src/plugins/async-hooks-stub.ts asyncHooksStubPlugin, createInstrumentationClientTransformPlugin(() => instrumentationClientPath), + // Generate a virtual `private-next-instrumentation-client` module when + // `nextConfig.instrumentationClientInject` is non-empty. Side-effect imports + // run in array order, ending with the user's `instrumentation-client` file + // (or empty-module), and a single composed `onRouterTransitionStart` fans + // out to every module's hook. + { + name: "vinext:instrumentation-client-inject", + enforce: "pre", + + resolveId(id) { + if (id !== VIRTUAL_INSTRUMENTATION_CLIENT) return null; + // The module was generated in config() if there are injects to compose. + // When empty, resolve.alias handles passthrough to the user file or empty-module. + return clientInjectModule !== null ? RESOLVED_INSTRUMENTATION_CLIENT : null; + }, + + load(id) { + if (id !== RESOLVED_INSTRUMENTATION_CLIENT) return null; + // Deterministic output precomputed once in config(). + return clientInjectModule; + }, + }, // 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/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index dbda1f8cd..fe85ebf64 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -6,6 +6,7 @@ import { findInstrumentationClientFile, findInstrumentationFile, } from "../packages/vinext/src/server/instrumentation.js"; +import { generateInstrumentationClientInjectModule } from "../packages/vinext/src/client/instrumentation-client-inject.js"; import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js"; // The runInstrumentation/reportRequestError describe blocks re-import via @@ -342,3 +343,52 @@ describe("reportRequestError", () => { expect(onRequestError).toHaveBeenCalledOnce(); }); }); + +describe("generateInstrumentationClientInjectModule", () => { + it("returns passthrough when injects is empty", () => { + const code = generateInstrumentationClientInjectModule([], null); + expect(code).toBe("export {};"); + }); + + it("generates a single import for one inject entry", () => { + const code = generateInstrumentationClientInjectModule(["./inject-a.js"], null); + expect(code).toContain('import * as __vinj_0 from "./inject-a.js"'); + expect(code).toContain("export function onRouterTransitionStart(url, type)"); + expect(code).toContain("typeof __vinj_0.onRouterTransitionStart === \"function\""); + expect(code).toContain("\n __vinj_0.onRouterTransitionStart(url, type);\n"); + }); + + it("generates imports in config order with user file last", () => { + const code = generateInstrumentationClientInjectModule( + ["./inject-a.js", "some-npm-pkg"], + "/project/instrumentation-client.ts", + ); + expect(code).toContain('import * as __vinj_0 from "./inject-a.js"'); + expect(code).toContain('import * as __vinj_1 from "some-npm-pkg"'); + expect(code).toContain('import * as __vinj_2 from "/project/instrumentation-client.ts"'); + }); + + it("falls back to empty-module when user file is absent", () => { + const code = generateInstrumentationClientInjectModule(["./inject-a.js"], null); + expect(code).toContain('import * as __vinj_1 from "vinext/client/empty-module"'); + }); + + it("composes hook calls for every module in array order", () => { + const code = generateInstrumentationClientInjectModule( + ["./inject-a.js", "./inject-b.js"], + "/project/instrumentation-client.ts", + ); + // Each module should have its own hook-check-and-call + expect(code).toContain("typeof __vinj_0.onRouterTransitionStart === \"function\""); + expect(code).toContain("__vinj_0.onRouterTransitionStart(url, type)"); + expect(code).toContain("typeof __vinj_1.onRouterTransitionStart === \"function\""); + expect(code).toContain("__vinj_1.onRouterTransitionStart(url, type)"); + expect(code).toContain("typeof __vinj_2.onRouterTransitionStart === \"function\""); + expect(code).toContain("__vinj_2.onRouterTransitionStart(url, type)"); + }); + + it("exports empty object when injects is empty and user file is present", () => { + const code = generateInstrumentationClientInjectModule([], "/project/instrumentation-client.ts"); + expect(code).toBe("export {};"); + }); +}); From 63396971ba4d347deecac76d0479e4868b7b0186 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Thu, 21 May 2026 15:45:57 -0700 Subject: [PATCH 2/6] test(instrumentation): fix oxfmt formatting and assertion indentation The test expected 2-space indent for the generated code, but the implementation produces 4-space indent within if blocks. Also fixes oxfmt quote style and line wrapping. --- tests/instrumentation.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index fe85ebf64..433a07f06 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -354,8 +354,8 @@ describe("generateInstrumentationClientInjectModule", () => { const code = generateInstrumentationClientInjectModule(["./inject-a.js"], null); expect(code).toContain('import * as __vinj_0 from "./inject-a.js"'); expect(code).toContain("export function onRouterTransitionStart(url, type)"); - expect(code).toContain("typeof __vinj_0.onRouterTransitionStart === \"function\""); - expect(code).toContain("\n __vinj_0.onRouterTransitionStart(url, type);\n"); + expect(code).toContain('typeof __vinj_0.onRouterTransitionStart === "function"'); + expect(code).toContain("\n __vinj_0.onRouterTransitionStart(url, type);\n"); }); it("generates imports in config order with user file last", () => { @@ -379,16 +379,19 @@ describe("generateInstrumentationClientInjectModule", () => { "/project/instrumentation-client.ts", ); // Each module should have its own hook-check-and-call - expect(code).toContain("typeof __vinj_0.onRouterTransitionStart === \"function\""); + expect(code).toContain('typeof __vinj_0.onRouterTransitionStart === "function"'); expect(code).toContain("__vinj_0.onRouterTransitionStart(url, type)"); - expect(code).toContain("typeof __vinj_1.onRouterTransitionStart === \"function\""); + expect(code).toContain('typeof __vinj_1.onRouterTransitionStart === "function"'); expect(code).toContain("__vinj_1.onRouterTransitionStart(url, type)"); - expect(code).toContain("typeof __vinj_2.onRouterTransitionStart === \"function\""); + expect(code).toContain('typeof __vinj_2.onRouterTransitionStart === "function"'); expect(code).toContain("__vinj_2.onRouterTransitionStart(url, type)"); }); it("exports empty object when injects is empty and user file is present", () => { - const code = generateInstrumentationClientInjectModule([], "/project/instrumentation-client.ts"); + const code = generateInstrumentationClientInjectModule( + [], + "/project/instrumentation-client.ts", + ); expect(code).toBe("export {};"); }); }); From 1ae9ab4c4907aa85ab0bf265b136bde35a119acd Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Thu, 21 May 2026 15:57:38 -0700 Subject: [PATCH 3/6] fix(ci): lint warnings and missing field in test helper - Replace unnecessary escaped quotes inside template literals to fix eslint(no-useless-escape) warnings in instrumentation-client-inject.ts - Add missing instrumentationClientInject field to makeResolved() test helper in next-config.test.ts to fix TS2322 type errors --- packages/vinext/src/client/instrumentation-client-inject.ts | 2 +- tests/next-config.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/client/instrumentation-client-inject.ts b/packages/vinext/src/client/instrumentation-client-inject.ts index a40e5a13e..3eef4bb29 100644 --- a/packages/vinext/src/client/instrumentation-client-inject.ts +++ b/packages/vinext/src/client/instrumentation-client-inject.ts @@ -37,7 +37,7 @@ export function generateInstrumentationClientInjectModule( const hookCalls: string[] = []; for (let i = 0; i <= lastIndex; i++) { hookCalls.push( - ` if (typeof __vinj_${i}.onRouterTransitionStart === \"function\") {`, + ` if (typeof __vinj_${i}.onRouterTransitionStart === "function") {`, ` __vinj_${i}.onRouterTransitionStart(url, type);`, ` }`, ); diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index efe01549b..4f6561b45 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -925,6 +925,7 @@ describe("detectNextIntlConfig", () => { buildId: "test-build-id", deploymentId: undefined, sassOptions: null, + instrumentationClientInject: [], ...overrides, }; } From 2b44231eaa9ec3f3aee54c7a18d89d1efe06ec98 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Fri, 22 May 2026 04:18:17 -0700 Subject: [PATCH 4/6] Address review feedback for instrumentationClientInject. Make clientInjectModule self-clearing on config re-eval, filter non-string inject specifiers, document dual resolution paths, and add escaping coverage. Signed-off-by: Divanshu Chauhan --- .../client/instrumentation-client-inject.ts | 19 +++++++++++++------ packages/vinext/src/config/next-config.ts | 4 +++- packages/vinext/src/index.ts | 13 +++++++------ tests/instrumentation.test.ts | 7 ++++++- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/vinext/src/client/instrumentation-client-inject.ts b/packages/vinext/src/client/instrumentation-client-inject.ts index 3eef4bb29..37ac1572b 100644 --- a/packages/vinext/src/client/instrumentation-client-inject.ts +++ b/packages/vinext/src/client/instrumentation-client-inject.ts @@ -2,11 +2,18 @@ * Generate a virtual ESM module that implements the Next.js * `instrumentationClientInject` contract for client bootstrap. * - * When `injects` is empty, this is a transparent passthrough (or no-op - * when no user file exists). Otherwise it generates side-effect imports - * for each inject in array order, then the user's instrumentation-client - * file last, and exports a single composed `onRouterTransitionStart` that - * fans out to every module's hook. + * Resolution follows two paths depending on whether injects are configured: + * + * **Empty injects (`injects.length === 0`):** Returns `export {}` and the + * plugin does not serve a virtual module. The `resolve.alias` for + * `private-next-instrumentation-client` resolves directly to the user's + * `instrumentation-client` file (or `vinext/client/empty-module` when absent), + * so the user's `onRouterTransitionStart` is used as-is with no composition. + * + * **Non-empty injects:** The plugin serves this generated module via + * `resolveId`/`load`. It side-effect-imports each inject in config order, then + * the user's file last, and exports a single composed `onRouterTransitionStart` + * that fans out to every module's hook. * * @param injects - Module specifiers from `nextConfig.instrumentationClientInject` * @param userPath - Absolute path to the user's `instrumentation-client` file, @@ -44,7 +51,7 @@ export function generateInstrumentationClientInjectModule( } lines.push(""); - lines.push("export function onRouterTransitionStart(url, type) {"); + lines.push("export function onRouterTransitionStart(url: string, type: string) {"); lines.push(...hookCalls); lines.push(`}`); lines.push(""); diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index e33986a19..5039bbe4c 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -1139,7 +1139,9 @@ export async function resolveNextConfig( output: output === "export" || output === "standalone" ? output : "", pageExtensions, instrumentationClientInject: Array.isArray(config.instrumentationClientInject) - ? (config.instrumentationClientInject as string[]) + ? (config.instrumentationClientInject as unknown[]).filter( + (x): x is string => typeof x === "string", + ) : [], cacheComponents: config.cacheComponents ?? false, redirects, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index e78d6466a..c02404036 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1062,12 +1062,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { instrumentationPath = findInstrumentationFile(root, fileMatcher); instrumentationClientPath = findInstrumentationClientFile(root, fileMatcher); middlewarePath = findMiddlewareFile(root, fileMatcher); - if (nextConfig.instrumentationClientInject.length > 0) { - clientInjectModule = generateInstrumentationClientInjectModule( - nextConfig.instrumentationClientInject, - instrumentationClientPath, - ); - } + clientInjectModule = + nextConfig.instrumentationClientInject.length > 0 + ? generateInstrumentationClientInjectModule( + nextConfig.instrumentationClientInject, + instrumentationClientPath, + ) + : null; if (env?.command === "build") { await writeRouteTypes(); } diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index 433a07f06..c0e2a9ce0 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -353,7 +353,7 @@ describe("generateInstrumentationClientInjectModule", () => { it("generates a single import for one inject entry", () => { const code = generateInstrumentationClientInjectModule(["./inject-a.js"], null); expect(code).toContain('import * as __vinj_0 from "./inject-a.js"'); - expect(code).toContain("export function onRouterTransitionStart(url, type)"); + expect(code).toContain("export function onRouterTransitionStart(url: string, type: string)"); expect(code).toContain('typeof __vinj_0.onRouterTransitionStart === "function"'); expect(code).toContain("\n __vinj_0.onRouterTransitionStart(url, type);\n"); }); @@ -394,4 +394,9 @@ describe("generateInstrumentationClientInjectModule", () => { ); expect(code).toBe("export {};"); }); + + it("escapes special characters in specifier paths", () => { + const code = generateInstrumentationClientInjectModule(['./path/with"quote.js'], null); + expect(code).toContain('from "./path/with\\"quote.js"'); + }); }); From dd39e209b3247bab8589d90c32ec87557a4fa885 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Fri, 22 May 2026 10:58:42 -0700 Subject: [PATCH 5/6] test(instrumentation): add plugin pipeline integration tests and fix inject resolution Add client-environment tests for instrumentationClientInject virtual module wiring, omit the alias when injects are active so resolveId/load run, reload next.config when root changes, and pre-resolve relative inject paths. Share empty-module path via INSTRUMENTATION_CLIENT_EMPTY_MODULE and simplify tests. --- .../client/instrumentation-client-inject.ts | 30 ++- packages/vinext/src/index.ts | 48 +++-- tests/instrumentation.test.ts | 188 ++++++++++++++++-- tests/next-config.test.ts | 23 +++ 4 files changed, 245 insertions(+), 44 deletions(-) diff --git a/packages/vinext/src/client/instrumentation-client-inject.ts b/packages/vinext/src/client/instrumentation-client-inject.ts index 37ac1572b..814a8150b 100644 --- a/packages/vinext/src/client/instrumentation-client-inject.ts +++ b/packages/vinext/src/client/instrumentation-client-inject.ts @@ -1,3 +1,11 @@ +import path from "node:path"; + +/** Absolute path to the vinext empty-module fallback for composed client instrumentation. */ +export const INSTRUMENTATION_CLIENT_EMPTY_MODULE = path.join( + import.meta.dirname, + "empty-module.ts", +); + /** * Generate a virtual ESM module that implements the Next.js * `instrumentationClientInject` contract for client bootstrap. @@ -7,7 +15,7 @@ * **Empty injects (`injects.length === 0`):** Returns `export {}` and the * plugin does not serve a virtual module. The `resolve.alias` for * `private-next-instrumentation-client` resolves directly to the user's - * `instrumentation-client` file (or `vinext/client/empty-module` when absent), + * `instrumentation-client` file (or {@link INSTRUMENTATION_CLIENT_EMPTY_MODULE} when absent), * so the user's `onRouterTransitionStart` is used as-is with no composition. * * **Non-empty injects:** The plugin serves this generated module via @@ -15,19 +23,21 @@ * the user's file last, and exports a single composed `onRouterTransitionStart` * that fans out to every module's hook. * + * **Specifier resolution:** Next.js webpack loader resolves every inject against + * the project root at build time (`this.resolve(rootContext, spec)`) and emits + * `require(resolvedPath)`. Vinext pre-resolves `./` and `../` in the plugin + * `config()` hook; bare specifiers rely on Vite resolution at bundle time. + * * @param injects - Module specifiers from `nextConfig.instrumentationClientInject` * @param userPath - Absolute path to the user's `instrumentation-client` file, * or `null` when the file doesn't exist + * @param emptyModulePath - Absolute path to the empty-module fallback */ export function generateInstrumentationClientInjectModule( injects: readonly string[], userPath: string | null, + emptyModulePath: string = INSTRUMENTATION_CLIENT_EMPTY_MODULE, ): string { - const EMPTY_MODULE = "vinext/client/empty-module"; - - // No injects: Next.js keeps the current transparent passthrough. - // The alias already handles the user file or empty-module, so emit - // nothing that could shadow what the alias resolves. if (injects.length === 0) { return "export {};"; } @@ -38,11 +48,11 @@ export function generateInstrumentationClientInjectModule( lines.push(`import * as __vinj_${i} from ${JSON.stringify(injects[i])};`); } - const lastIndex = injects.length; - lines.push(`import * as __vinj_${lastIndex} from ${JSON.stringify(userPath ?? EMPTY_MODULE)};`); + const userSlot = injects.length; + lines.push(`import * as __vinj_${userSlot} from ${JSON.stringify(userPath ?? emptyModulePath)};`); const hookCalls: string[] = []; - for (let i = 0; i <= lastIndex; i++) { + for (let i = 0; i <= userSlot; i++) { hookCalls.push( ` if (typeof __vinj_${i}.onRouterTransitionStart === "function") {`, ` __vinj_${i}.onRouterTransitionStart(url, type);`, @@ -51,7 +61,7 @@ export function generateInstrumentationClientInjectModule( } lines.push(""); - lines.push("export function onRouterTransitionStart(url: string, type: string) {"); + lines.push("export function onRouterTransitionStart(url, type) {"); lines.push(...hookCalls); lines.push(`}`); lines.push(""); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index a6259b177..eb9676263 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -93,7 +93,10 @@ import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js" import { dataUrlCssPlugin } from "./plugins/css-data-url.js"; import { createRscClientReferenceLoadersPlugin } from "./plugins/rsc-client-reference-loaders.js"; import { createInstrumentationClientTransformPlugin } from "./plugins/instrumentation-client.js"; -import { generateInstrumentationClientInjectModule } from "./client/instrumentation-client-inject.js"; +import { + generateInstrumentationClientInjectModule, + INSTRUMENTATION_CLIENT_EMPTY_MODULE, +} from "./client/instrumentation-client-inject.js"; import { createMiddlewareServerOnlyPlugin } from "./plugins/middleware-server-only.js"; import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js"; import { createOgInlineFetchAssetsPlugin, ogAssetsPlugin } from "./plugins/og-assets.js"; @@ -441,7 +444,7 @@ const VIRTUAL_ROOT_PARAMS = "virtual:vinext-root-params"; const RESOLVED_ROOT_PARAMS = "\0" + VIRTUAL_ROOT_PARAMS; /** Virtual module for composed instrumentation-client bootstrap. */ const VIRTUAL_INSTRUMENTATION_CLIENT = "private-next-instrumentation-client"; -const RESOLVED_INSTRUMENTATION_CLIENT = "\0" + VIRTUAL_INSTRUMENTATION_CLIENT; +const RESOLVED_INSTRUMENTATION_CLIENT = `\0${VIRTUAL_INSTRUMENTATION_CLIENT}.mjs`; /** Image file extensions handled by the vinext:image-imports plugin. * Shared between the Rolldown hook filter and the transform handler regex. */ const IMAGE_EXTS = "png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?"; @@ -654,6 +657,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let hasAppDir = false; let hasPagesDir = false; let nextConfig: ResolvedNextConfig; + /** Tracks which project root {@link nextConfig} was loaded for (guards multi-env / test setups). */ + let nextConfigRoot: string | undefined; let fileMatcher: ReturnType; let middlewarePath: string | null = null; let instrumentationPath: string | null = null; @@ -1054,7 +1059,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Note: fileMatcher, instrumentationPath, etc. are intentionally set // outside this guard — they are cheap and deterministic, and keeping // them here ensures they reflect the final resolved root on every call. - if (!nextConfig) { + // Reload when the project root changes. Vite's multi-environment config() + // can fire once per environment; the first call may see a different root + // (e.g. Vitest's workspace root) before the final createServer({ root }). + if (!nextConfig || nextConfigRoot !== root) { + nextConfigRoot = root; const phase = env?.command === "build" ? PHASE_PRODUCTION_BUILD : PHASE_DEVELOPMENT_SERVER; let rawConfig: NextConfig | null; @@ -1077,13 +1086,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { instrumentationPath = findInstrumentationFile(root, fileMatcher); instrumentationClientPath = findInstrumentationClientFile(root, fileMatcher); middlewarePath = findMiddlewareFile(root, fileMatcher); - clientInjectModule = - nextConfig.instrumentationClientInject.length > 0 - ? generateInstrumentationClientInjectModule( - nextConfig.instrumentationClientInject, - instrumentationClientPath, - ) - : null; + const instrumentationClientInjects = nextConfig.instrumentationClientInject.map((spec) => + spec.startsWith("./") || spec.startsWith("../") ? path.resolve(root, spec) : spec, + ); + clientInjectModule = instrumentationClientInjects.length + ? generateInstrumentationClientInjectModule( + instrumentationClientInjects, + instrumentationClientPath, + INSTRUMENTATION_CLIENT_EMPTY_MODULE, + ) + : null; if (env?.command === "build") { await writeRouteTypes(); } @@ -1277,8 +1289,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { "instrumentation-client", ), "vinext/html": path.resolve(__dirname, "server", "html"), - "private-next-instrumentation-client": - instrumentationClientPath ?? path.resolve(__dirname, "client", "empty-module"), + ...(clientInjectModule === null + ? { + "private-next-instrumentation-client": + instrumentationClientPath ?? INSTRUMENTATION_CLIENT_EMPTY_MODULE, + } + : {}), }).flatMap(([k, v]) => k.startsWith("next/") ? [ @@ -2333,25 +2349,17 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Stub node:async_hooks in client builds — see src/plugins/async-hooks-stub.ts asyncHooksStubPlugin, createInstrumentationClientTransformPlugin(() => instrumentationClientPath), - // Generate a virtual `private-next-instrumentation-client` module when - // `nextConfig.instrumentationClientInject` is non-empty. Side-effect imports - // run in array order, ending with the user's `instrumentation-client` file - // (or empty-module), and a single composed `onRouterTransitionStart` fans - // out to every module's hook. { name: "vinext:instrumentation-client-inject", enforce: "pre", resolveId(id) { if (id !== VIRTUAL_INSTRUMENTATION_CLIENT) return null; - // The module was generated in config() if there are injects to compose. - // When empty, resolve.alias handles passthrough to the user file or empty-module. return clientInjectModule !== null ? RESOLVED_INSTRUMENTATION_CLIENT : null; }, load(id) { if (id !== RESOLVED_INSTRUMENTATION_CLIENT) return null; - // Deterministic output precomputed once in config(). return clientInjectModule; }, }, diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index c0e2a9ce0..505dd3312 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -2,6 +2,9 @@ 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 { pathToFileURL } from "node:url"; +import { createServer } from "vite-plus"; +import vinext from "../packages/vinext/src/index.js"; import { findInstrumentationClientFile, findInstrumentationFile, @@ -9,6 +12,80 @@ import { import { generateInstrumentationClientInjectModule } from "../packages/vinext/src/client/instrumentation-client-inject.js"; import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js"; +const RESOLVED_INSTRUMENTATION_CLIENT = "\0private-next-instrumentation-client.mjs"; +const ROOT_NODE_MODULES = path.resolve(import.meta.dirname, "..", "node_modules"); + +function getLoadedCode(loaded: unknown): string { + return typeof loaded === "string" ? loaded : ((loaded as { code?: string })?.code ?? ""); +} + +function setupInjectProject(options: { + instrumentationClientInject: string[]; + injectFiles?: Record; + userClientSource?: string; +}): string { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-instr-client-inject-")); + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify({ name: "test-project", type: "module" }), + ); + fs.mkdirSync(path.join(tmpDir, "app"), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, "app", "layout.tsx"), + "export default function Layout({ children }) { return {children}; }\n", + ); + fs.writeFileSync( + path.join(tmpDir, "app", "page.tsx"), + "export default function Page() { return
home
; }\n", + ); + fs.writeFileSync( + path.join(tmpDir, "next.config.mjs"), + `export default { instrumentationClientInject: ${JSON.stringify(options.instrumentationClientInject)} };\n`, + ); + for (const [filename, source] of Object.entries(options.injectFiles ?? {})) { + fs.writeFileSync(path.join(tmpDir, filename), source); + } + if (options.userClientSource !== undefined) { + fs.writeFileSync(path.join(tmpDir, "instrumentation-client.js"), options.userClientSource); + } + try { + fs.symlinkSync(ROOT_NODE_MODULES, path.join(tmpDir, "node_modules"), "junction"); + } catch { + fs.symlinkSync(ROOT_NODE_MODULES, path.join(tmpDir, "node_modules"), "dir"); + } + return tmpDir; +} + +type InjectClientContainer = NonNullable< + Awaited>["environments"]["client"] +>["pluginContainer"]; + +async function withInjectClientServer( + options: { + instrumentationClientInject: string[]; + injectFiles?: Record; + userClientSource?: string; + }, + run: (ctx: { tmpDir: string; container: InjectClientContainer }) => Promise, +): Promise { + const tmpDir = setupInjectProject(options); + const testServer = await createServer({ + root: tmpDir, + configFile: false, + plugins: [vinext({ appDir: tmpDir })], + server: { port: 0 }, + logLevel: "silent", + }); + try { + const client = testServer.environments.client; + if (!client) throw new Error("client environment missing"); + await run({ tmpDir, container: client.pluginContainer }); + } finally { + await testServer.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + // The runInstrumentation/reportRequestError describe blocks re-import via // vi.resetModules() to get fresh module-level state (_onRequestError). // findInstrumentationFile is a pure function — no reset needed. @@ -344,16 +421,107 @@ describe("reportRequestError", () => { }); }); +// Ported from Next.js: packages/next/src/build/webpack/loaders/next-instrumentation-client-loader.ts +// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/next-instrumentation-client-loader.ts +describe("instrumentationClientInject plugin pipeline", () => { + const INJECT_A = `export function onRouterTransitionStart() { + globalThis.__injectOrder = globalThis.__injectOrder ?? []; + globalThis.__injectOrder.push("a"); +} +`; + const INJECT_B = `export function onRouterTransitionStart() { + globalThis.__injectOrder = globalThis.__injectOrder ?? []; + globalThis.__injectOrder.push("b"); +} +`; + const USER_CLIENT = `export function onRouterTransitionStart() { + globalThis.__injectOrder = globalThis.__injectOrder ?? []; + globalThis.__injectOrder.push("user"); +} +`; + const SIDE_EFFECT_ONLY = "globalThis.__sideEffect = true;\n"; + + it("does not intercept private-next-instrumentation-client when injects is empty", async () => { + await withInjectClientServer( + { instrumentationClientInject: [], userClientSource: USER_CLIENT }, + async ({ container }) => { + const resolved = await container.resolveId("private-next-instrumentation-client"); + expect(resolved).toBeTruthy(); + expect(resolved!.id).not.toBe(RESOLVED_INSTRUMENTATION_CLIENT); + expect(resolved!.id.replace(/\\/g, "/")).toContain("instrumentation-client.js"); + }, + ); + }); + + it("serves and composes the virtual module in inject order", async () => { + await withInjectClientServer( + { + instrumentationClientInject: ["./inject-a.js", "./inject-b.js"], + injectFiles: { "inject-a.js": INJECT_A, "inject-b.js": INJECT_B }, + userClientSource: USER_CLIENT, + }, + async ({ tmpDir, container }) => { + const resolved = await container.resolveId("private-next-instrumentation-client"); + expect(resolved?.id).toBe(RESOLVED_INSTRUMENTATION_CLIENT); + + const code = getLoadedCode(await container.load(resolved!.id)); + expect(code.indexOf("inject-a.js")).toBeGreaterThanOrEqual(0); + expect(code.lastIndexOf("inject-b.js")).toBeGreaterThan(code.indexOf("inject-a.js")); + expect(code.indexOf("import * as __vinj_2 from")).toBeGreaterThan( + code.lastIndexOf("inject-b.js"), + ); + expect(code).toContain("export function onRouterTransitionStart(url, type)"); + + const entryPath = path.join(tmpDir, ".vinext-composed-instrumentation-client.mjs"); + fs.writeFileSync(entryPath, code); + delete (globalThis as { __injectOrder?: string[] }).__injectOrder; + const mod = (await import(pathToFileURL(entryPath).href)) as { + onRouterTransitionStart?: (url: string, type: string) => void; + }; + mod.onRouterTransitionStart?.("/x", "push"); + expect((globalThis as { __injectOrder?: string[] }).__injectOrder).toEqual([ + "a", + "b", + "user", + ]); + }, + ); + }); + + it("allows side-effect-only inject modules without onRouterTransitionStart", async () => { + await withInjectClientServer( + { + instrumentationClientInject: ["./inject-side.js"], + injectFiles: { "inject-side.js": SIDE_EFFECT_ONLY }, + }, + async ({ tmpDir, container }) => { + const resolved = await container.resolveId("private-next-instrumentation-client"); + const entryPath = path.join(tmpDir, ".vinext-composed-instrumentation-client.mjs"); + fs.writeFileSync(entryPath, getLoadedCode(await container.load(resolved!.id))); + + delete (globalThis as { __sideEffect?: boolean }).__sideEffect; + const mod = (await import(pathToFileURL(entryPath).href)) as { + onRouterTransitionStart?: (url: string, type: string) => void; + }; + expect((globalThis as { __sideEffect?: boolean }).__sideEffect).toBe(true); + expect(() => mod.onRouterTransitionStart?.("/x", "push")).not.toThrow(); + }, + ); + }); +}); + describe("generateInstrumentationClientInjectModule", () => { - it("returns passthrough when injects is empty", () => { - const code = generateInstrumentationClientInjectModule([], null); - expect(code).toBe("export {};"); + it("returns passthrough when injects is empty (userPath ignored)", () => { + expect(generateInstrumentationClientInjectModule([], null)).toBe("export {};"); + expect( + generateInstrumentationClientInjectModule([], "/project/instrumentation-client.ts"), + ).toBe("export {};"); }); it("generates a single import for one inject entry", () => { const code = generateInstrumentationClientInjectModule(["./inject-a.js"], null); expect(code).toContain('import * as __vinj_0 from "./inject-a.js"'); - expect(code).toContain("export function onRouterTransitionStart(url: string, type: string)"); + expect(code).toContain("export function onRouterTransitionStart(url, type)"); expect(code).toContain('typeof __vinj_0.onRouterTransitionStart === "function"'); expect(code).toContain("\n __vinj_0.onRouterTransitionStart(url, type);\n"); }); @@ -370,7 +538,8 @@ describe("generateInstrumentationClientInjectModule", () => { it("falls back to empty-module when user file is absent", () => { const code = generateInstrumentationClientInjectModule(["./inject-a.js"], null); - expect(code).toContain('import * as __vinj_1 from "vinext/client/empty-module"'); + expect(code).toContain("import * as __vinj_1 from"); + expect(code).toContain("empty-module"); }); it("composes hook calls for every module in array order", () => { @@ -378,7 +547,6 @@ describe("generateInstrumentationClientInjectModule", () => { ["./inject-a.js", "./inject-b.js"], "/project/instrumentation-client.ts", ); - // Each module should have its own hook-check-and-call expect(code).toContain('typeof __vinj_0.onRouterTransitionStart === "function"'); expect(code).toContain("__vinj_0.onRouterTransitionStart(url, type)"); expect(code).toContain('typeof __vinj_1.onRouterTransitionStart === "function"'); @@ -387,14 +555,6 @@ describe("generateInstrumentationClientInjectModule", () => { expect(code).toContain("__vinj_2.onRouterTransitionStart(url, type)"); }); - it("exports empty object when injects is empty and user file is present", () => { - const code = generateInstrumentationClientInjectModule( - [], - "/project/instrumentation-client.ts", - ); - expect(code).toBe("export {};"); - }); - it("escapes special characters in specifier paths", () => { const code = generateInstrumentationClientInjectModule(['./path/with"quote.js'], null); expect(code).toContain('from "./path/with\\"quote.js"'); diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index a22cdc04c..17980aa59 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -832,6 +832,29 @@ describe("resolveNextConfig hashSalt", () => { }); }); +describe("resolveNextConfig instrumentationClientInject", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTempDir(); + fs.writeFileSync(path.join(tmpDir, "package.json"), `{ "type": "module" }\n`); + }); + + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("loads instrumentationClientInject from next.config.mjs", async () => { + fs.writeFileSync( + path.join(tmpDir, "next.config.mjs"), + `export default { instrumentationClientInject: ["./inject-a.js", "./inject-b.js"] };\n`, + ); + const raw = await loadNextConfig(tmpDir); + const resolved = await resolveNextConfig(raw, tmpDir); + expect(resolved.instrumentationClientInject).toEqual(["./inject-a.js", "./inject-b.js"]); + }); +}); + describe("resolveNextConfig expireTime", () => { it("defaults to the Next.js route expire fallback", async () => { const resolved = await resolveNextConfig(null); From bf0e1eeb54a8a4f20ea569550322cb691d290e14 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Fri, 22 May 2026 11:06:11 -0700 Subject: [PATCH 6/6] fix(instrumentation): resolve empty-module.js in production builds CI E2E builds failed because INSTRUMENTATION_CLIENT_EMPTY_MODULE pointed at empty-module.ts, which is not shipped in dist. Prefer .js when present. --- .../src/client/instrumentation-client-inject.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/client/instrumentation-client-inject.ts b/packages/vinext/src/client/instrumentation-client-inject.ts index 814a8150b..2f5b9c9ac 100644 --- a/packages/vinext/src/client/instrumentation-client-inject.ts +++ b/packages/vinext/src/client/instrumentation-client-inject.ts @@ -1,9 +1,16 @@ +import fs from "node:fs"; import path from "node:path"; +/** Resolve empty-module next to this file (.js in dist, .ts in source). */ +function resolveInstrumentationClientEmptyModule(dir: string): string { + const jsPath = path.join(dir, "empty-module.js"); + if (fs.existsSync(jsPath)) return jsPath; + return path.join(dir, "empty-module.ts"); +} + /** Absolute path to the vinext empty-module fallback for composed client instrumentation. */ -export const INSTRUMENTATION_CLIENT_EMPTY_MODULE = path.join( +export const INSTRUMENTATION_CLIENT_EMPTY_MODULE = resolveInstrumentationClientEmptyModule( import.meta.dirname, - "empty-module.ts", ); /**