feat(instrumentation): support nextConfig.instrumentationClientInject#1416
feat(instrumentation): support nextConfig.instrumentationClientInject#1416Divkix wants to merge 9 commits into
Conversation
…cloudflare#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.
commit: |
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.
- 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
|
/bigbonk review |
There was a problem hiding this comment.
Review: feat(instrumentation): support nextConfig.instrumentationClientInject
CI is fully green. The overall approach — precomputing the virtual module once in config() and serving it via a thin resolveId/load pair — is clean and follows the repo's existing virtual-module conventions. I reviewed the Next.js source (next-instrumentation-client-loader.ts, create-compiler-aliases.ts) to verify behavioral parity.
There are a few issues worth addressing before merge, ranging from a behavioral gap to some minor improvements.
Summary of issues
-
Behavioral gap:
clientInjectModulenot cleared when config reloads —clientInjectModuleis set wheninstrumentationClientInject.length > 0but never cleared back tonullwhen the config changes to an empty array (e.g., HMR ofnext.config). Minor in practice since config changes typically restart the dev server, but worth noting. -
Missing input validation on inject specifiers — Empty strings and non-string values in the array could produce broken imports. Next.js validates via
z.array(z.string())in its config schema. -
Hook composition diverges slightly from Next.js — Next.js uses
mod && mod.onRouterTransitionStart && mod.onRouterTransitionStart(...)(short-circuit AND chain), while vinext usestypeof ... === "function". Functionally equivalent for well-behaved modules, but thetypeofcheck is arguably more defensive. -
Generated code exports an untyped function — The composed
onRouterTransitionStart(url, type)has no type annotations. Not a runtime concern since this is generated code consumed by Vite's pipeline, but worth noting for consistency with theClientInstrumentationHookstype.
Overall this is a solid first implementation. The issues above are minor enough that they could be addressed in a follow-up.
| // nothing that could shadow what the alias resolves. | ||
| if (injects.length === 0) { | ||
| return "export {};"; | ||
| } |
There was a problem hiding this comment.
When injects is empty, this returns "export {};" regardless of whether the user has an instrumentation-client file. The PR description says this is intentional ("the alias already handles the user file or empty-module"), which is correct — the resolve.alias at index.ts:1261 maps private-next-instrumentation-client to the user file or empty-module and takes effect when this plugin returns null from resolveId.
However, this means there's an asymmetry in the hook composition path: when injects are present, the composed onRouterTransitionStart fans out to all modules including the user file. When injects are empty, the user's onRouterTransitionStart flows directly through the alias. Both paths work, but it's worth documenting this explicitly in the function's JSDoc (the current doc hints at it but doesn't state the two resolution paths clearly).
| if (nextConfig.instrumentationClientInject.length > 0) { | ||
| clientInjectModule = generateInstrumentationClientInjectModule( | ||
| nextConfig.instrumentationClientInject, | ||
| instrumentationClientPath, |
There was a problem hiding this comment.
The module string is only computed when instrumentationClientInject.length > 0, but clientInjectModule is never reset to null on subsequent config re-evaluations. In Vite's multi-environment build pipeline, config() can fire multiple times. If nextConfig is already set (the if (!nextConfig) guard at line 1042), this code path is still reached because instrumentationPath/instrumentationClientPath/middlewarePath are intentionally re-evaluated outside that guard.
This is fine today because nextConfig is only resolved once (the guard), so instrumentationClientInject won't change across calls. But if the guard is ever removed (e.g., to support config HMR), clientInjectModule would need an explicit reset:
| instrumentationClientPath, | |
| clientInjectModule = nextConfig.instrumentationClientInject.length > 0 | |
| ? generateInstrumentationClientInjectModule( | |
| nextConfig.instrumentationClientInject, | |
| instrumentationClientPath, | |
| ) | |
| : null; |
This makes the assignment unconditional and self-clearing, which is safer against future refactors.
| trailingSlash: config.trailingSlash ?? false, | ||
| output: output === "export" || output === "standalone" ? output : "", | ||
| pageExtensions, | ||
| instrumentationClientInject: Array.isArray(config.instrumentationClientInject) |
There was a problem hiding this comment.
Minor: this validates that instrumentationClientInject is an array, but doesn't validate that each element is a string. Next.js uses Zod (z.array(z.string())) in config-schema.ts to reject non-string elements. Consider filtering:
| instrumentationClientInject: Array.isArray(config.instrumentationClientInject) | |
| instrumentationClientInject: Array.isArray(config.instrumentationClientInject) | |
| ? (config.instrumentationClientInject as unknown[]).filter( | |
| (x): x is string => typeof x === 'string' | |
| ) | |
| : [], |
This is consistent with how optimizePackageImports is handled a few lines above (line 1048).
| } | ||
|
|
||
| lines.push(""); | ||
| lines.push("export function onRouterTransitionStart(url, type) {"); |
There was a problem hiding this comment.
Nit: the generated function signature has no type annotations. This is fine since it's a virtual module consumed by Vite (not user-facing code), and Vite will treat it as JS regardless. But for consistency with ClientInstrumentationHooks, you could annotate the parameters in the generated code:
export function onRouterTransitionStart(url: string, type: string) {
Not blocking — just a polish suggestion.
| ); | ||
| expect(code).toBe("export {};"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Good test coverage for the pure code-generation function. One edge case worth adding: what happens when an inject specifier contains characters that could break the generated import (e.g., a specifier with quotes or backslashes)? JSON.stringify handles this correctly, but a test would document the contract:
it("escapes special characters in specifier paths", () => {
const code = generateInstrumentationClientInjectModule(
['./path/with"quote.js'],
null,
);
expect(code).toContain('from "./path/with\\"quote.js"');
});Also, there's no integration-level test that verifies the Vite plugin wiring end-to-end (i.e., that the virtual module is actually served when instrumentationClientInject is configured). The unit tests cover generateInstrumentationClientInjectModule in isolation, which is great, but a fixture-based test would catch issues like the resolve.alias vs resolveId ordering concern. This could be a follow-up.
|
Review submitted successfully. Here's a summary of the feedback I posted: PR #1416 adds support for All CI checks pass. I left 5 inline comments covering:
|
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 <divkix@divkix.me>
e9c0784 to
2b44231
Compare
|
/bigbonk review |
There was a problem hiding this comment.
Re-review: feat(instrumentation): support nextConfig.instrumentationClientInject
The previous review's feedback has been addressed — input validation filters non-strings, the JSDoc documents the dual resolution paths, and the special-character escaping test was added. The clientInjectModule assignment was already self-clearing via the ternary (I think the original comment misread the code slightly; the assignment is unconditional since it's outside the if (!nextConfig) guard).
I did a deeper pass comparing against the Next.js reference implementation (next-instrumentation-client-loader.ts) and the Next.js E2E tests (test/e2e/instrumentation-client-hook/). The implementation is correct and the plugin wiring is sound — enforce: "pre" ensures the resolveId hook runs before vite:resolve applies the resolve.alias fallback, which is the right ordering.
Two remaining items worth discussing, neither blocking:
-
ESM import ordering vs CJS require ordering — Next.js generates
require()calls (CJS), which execute synchronously in source order. This PR generates staticimport * as ...statements (ESM). The ES module spec evaluates same-level static imports in source order, and Vite/Rolldown preserve this, so in practice the side-effect ordering is correct. But it's a semantic difference worth being aware of if a future edge case surfaces around circular dependencies or async module evaluation. -
Missing integration test — The unit tests cover the code-generation function thoroughly. There's no integration test that verifies the virtual module is actually served through Vite's plugin pipeline when
instrumentationClientInjectis configured. The previous review noted this and suggested it as a follow-up, which seems reasonable.
LGTM with the two inline comments below.
| const lines: string[] = []; | ||
|
|
||
| for (let i = 0; i < injects.length; i++) { | ||
| lines.push(`import * as __vinj_${i} from ${JSON.stringify(injects[i])};`); |
There was a problem hiding this comment.
Next.js resolves inject specifiers against the project root before emitting require() calls (via this.resolve(rootContext, spec) in the webpack loader). Here, bare specifiers and relative paths are left for Vite's resolver to handle at import resolution time, which should work correctly since the virtual module's importer context is the project root. Just flagging the difference — if a user reports that relative paths in instrumentationClientInject resolve against the wrong directory, this is the place to look.
| } | ||
|
|
||
| lines.push(""); | ||
| lines.push("export function onRouterTransitionStart(url: string, type: string) {"); |
There was a problem hiding this comment.
Nit from the previous review that's still applicable — the generated function has TypeScript type annotations (url: string, type: string) but this is loaded as a virtual module via the load hook, which Vite treats as JavaScript by default (no id.endsWith('.ts') to trigger the TS transform). This works today because Vite 8 / Rolldown processes all modules through OXC regardless, but if you wanted to be explicit you could either:
- Drop the TS annotations from the generated code (make it plain JS), or
- Return
{ code, map: null }with an explicitlang: 'ts'or append.tsto the resolved ID
Not blocking — the current behavior is correct under Vite 8.
|
Review submitted. Here's a summary of the feedback: PR #1416 adds support for I verified against the Next.js reference implementation (
I left 2 non-blocking inline comments:
Overall: LGTM. The integration test gap (no fixture-based test for the full plugin pipeline) is the most notable missing piece, but fine as a follow-up. |
…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.
CI E2E builds failed because INSTRUMENTATION_CLIENT_EMPTY_MODULE pointed at empty-module.ts, which is not shipped in dist. Prefer .js when present.
Summary
Adds support for the
nextConfig.instrumentationClientInjectconfig option introduced in Next.js. This lets users inject client bootstrap modules (for side effects and an optionalonRouterTransitionStarthook) ahead of the user'sinstrumentation-clientfile, without modifying source files.Closes #1326.
Changes
generateInstrumentationClientInjectModule()generates ESM with side-effect imports in array order, then the user'sinstrumentation-clientfile last (orvinext/client/empty-modulewhen absent). A single composedonRouterTransitionStartfans out to every module's hook.instrumentationClientInjecttoNextConfigandResolvedNextConfigwith default[].vinext:instrumentation-client-injectplugin withresolveId+loadhooks. The virtual module string is precomputed once inconfig()to avoid rebuilding on every HMR cycle.Verification
tsc --noEmitclean (exit 0)\0-prefixed IDs, same asvirtual:vinext-*modules)instrumentationClientInjectis a transparent passthrough (existingresolve.aliascontinues to handle the user file or empty-module)Behavioral parity with Next.js
instrumentation-clientfile runs lastonRouterTransitionStartfires in config order across all modules