Skip to content

Shim next/root-params#941

Merged
james-elicx merged 4 commits intocloudflare:mainfrom
Divkix:fix/806-shim-next-root-params
Apr 28, 2026
Merged

Shim next/root-params#941
james-elicx merged 4 commits intocloudflare:mainfrom
Divkix:fix/806-shim-next-root-params

Conversation

@Divkix
Copy link
Copy Markdown
Contributor

@Divkix Divkix commented Apr 28, 2026

Summary

  • Add generated next/root-params runtime exports based on App Router root layout params.
  • Track root layout params in the RSC request context separately from leaf route params.
  • Add scanner, runtime, and generated-entry regression coverage.

Fixes #806

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 28, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@941

commit: b48bb10

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Shim next/root-params

Clean first contribution — the scanner, shim, generated entry wiring, and tests all hang together well. The overall design (scan root layout params at build time, generate a virtual module with named exports, set per-request state in the RSC entry) matches the Next.js approach.

A few issues to address before merging:

Must fix

  1. Root params state is never cleaned up on error/early-return paths. setNavigationContext(null) is called in ~30 places throughout the generated entry for cleanup on error, early return, not-found, redirect, etc. __setRootParams is only set once and never nulled out. When requests run outside the unified ALS scope (SSR environment, standalone ALS fallback), this leaks state between requests. This needs parity with the setNavigationContext(null) cleanup pattern.

  2. RESOLVED_ROOT_PARAMS virtual module is not invalidated on file changes in dev. When a new layout or dynamic segment is added during development, the file watcher invalidates the app route cache and the RSC entry module (lines 2048–2051 of index.ts), but does not invalidate the RESOLVED_ROOT_PARAMS module. The next/root-params virtual module will serve stale exports until a full dev server restart. Add an invalidateRootParamsModule() call alongside invalidateRscEntryModule() in both the add and unlink watcher handlers.

Should fix

  1. Synthetic parallel-slot routes don't set rootParamNames. synthesizeParallelSlotRoutes (around line 422 of app-router.ts) builds new route objects that inherit most fields from the parent route but don't copy rootParamNames. The field is optional and the entry template handles undefined via ?? [], so this won't crash, but it means parallel-slot routes silently lose their root params. Should propagate rootParamNames: parentRoute.rootParamNames in the synthetic route.

  2. runWithRootParamsContext is exported but never used. The other shims follow a pattern where runWith*Context wraps the per-request scope. If the unified request context is always used in the RSC entry (which it is), this function is dead code. Either use it or remove it to keep the API surface minimal.

Nits

  1. See inline comments below.

searchParams: url.searchParams,
params,
});
__setRootParams(__pickRootParams(params, route.rootParamNames));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State leak: __setRootParams is called here but never cleaned up on error/early-return paths. Compare with setNavigationContext(null) which is called in ~30 places throughout this file for cleanup. Every path that calls setNavigationContext(null) should also call __setRootParams(null) to prevent leaking root params state to the next request when running outside the unified ALS scope.

At minimum, you need cleanup on:

  • All early-return/error paths after this line (route handler errors, page render errors, ISR fallback paths, etc.)
  • The final cleanup at the end of _handleRequest

The unified request context handles this for the common path (since the whole context is scoped to the ALS run), but the standalone ALS fallback in root-params.ts would leak.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68a8755. Root params now clear through generated __clearRequestContext(), and clearing navigation context also clears root params so early-return/error paths do not leak fallback state.

instrumentationPath,
);
}
if (id === RESOLVED_ROOT_PARAMS) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HMR gap: This virtual module is generated at load() time based on the current routes, but it's never invalidated when the file watcher detects new/removed files. The RSC entry gets invalidated at line 2050 (invalidateRscEntryModule()), but the root-params module doesn't.

When a developer adds app/[lang]/layout.tsx during a dev session, the next/root-params module will still serve the old (empty) exports until a full restart.

Add invalidation alongside the RSC entry invalidation:

function invalidateRootParamsModule() {
  for (const env of Object.values(server.environments)) {
    const mod = env.moduleGraph.getModuleById(RESOLVED_ROOT_PARAMS);
    if (mod) env.moduleGraph.invalidateModule(mod);
  }
}

Then call it in the add/unlink watcher handlers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68a8755. App route add/unlink watcher handling now invalidates the root params virtual module across Vite environments alongside the RSC entry.

layoutTreePositions,
isDynamic,
params,
rootParamNames: computeRootParamNames(segments, layoutTreePositions),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: computeRootParamNames is only relevant when the first layout is NOT at the app root (position > 0). That's the uncommon case — most apps have app/layout.tsx at position 0 and get an empty array. The logic is correct, just noting this is a niche feature that primarily applies to i18n patterns like app/[lang]/layout.tsx.

return _als.getStore() ?? _fallbackState;
}

export function runWithRootParamsContext<T>(fn: () => Promise<T>): Promise<T>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runWithRootParamsContext is exported but never imported or called anywhere in the codebase. The RSC entry uses the unified request context (which already includes rootParams: null via createRequestContext), and the standalone ALS is only used as a fallback.

Suggest removing it to keep the API surface minimal, or if you intend to use it for SSR-side isolation, wire it up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68a8755. Removed the unused runWithRootParamsContext export and kept root params scoped through the unified request context fallback path.

return /^[$A-Z_a-z][$\w]*$/.test(name);
}

function generateRootParamsModule(rootParamNames: Iterable<string>): string {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the generated module creates top-level named function exports. Next.js does the same thing (the placeholder next/root-params.js is compiler-replaced with getter calls). This is a good match.

One thing to consider: Next.js gates rootParams behind experimental.rootParams: true (or cacheComponents: true). This implementation is always-on, which is fine for now but worth documenting as a known divergence.

return Object.assign(Promise.resolve(plain), plain);
}

function __pickRootParams(params, rootParamNames) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the AGENTS.md guidance about keeping generated entries thin: __pickRootParams is a small utility, but it's the kind of logic that could live in the runtime root-params.ts module and be imported. Currently it's inlined in the generated template string.

Not blocking, but if you're doing a follow-up pass, consider moving this to shims/root-params.ts as an exported pickRootParams function and importing it in the entry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68a8755. Root param selection moved out of generated entry code into the runtime pickRootParams shim, with generated-code and runtime tests covering the delegation.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 28, 2026

Review submitted. Here's a summary of the findings:

Must fix (2):

  1. State leak__setRootParams is set once per request but never cleaned up on error/early-return paths. setNavigationContext(null) has ~30 cleanup callsites; root params needs the same treatment to prevent cross-request leaks outside the unified ALS scope.
  2. No HMR invalidation — The virtual:vinext-root-params module isn't invalidated when the file watcher detects new/removed files in the app directory. Adding a new [lang]/layout.tsx during dev won't update the next/root-params exports until a full restart.

Should fix (2):
3. Parallel-slot routes lose root params — Synthetic routes from synthesizeParallelSlotRoutes don't copy rootParamNames from the parent route.
4. Dead coderunWithRootParamsContext is exported but never imported anywhere.

Nits: Inline suggestions about moving __pickRootParams out of the generated entry template and documenting that this is always-on (vs Next.js gating it behind experimental.rootParams).

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

if (ctx === null) __setRootParams(null);
}

function __clearRequestContext() {
Copy link
Copy Markdown
Collaborator

@james-elicx james-elicx Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i kid you not, i was thinking about how something like this would be much nicer about an hour ago. nice one!

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Shim next/root-params (post-fixup)

The previous review's four issues have all been addressed in 68a8755:

  1. State leak (fixed)__clearRequestContext() now consolidates all cleanup (setHeadersContext, setNavigationContext, __setRootParams) into a single function, and every early-return/error path uses it. The setNavigationContext(null) wrapper also clears root params as a belt-and-suspenders measure.

  2. HMR invalidation (fixed)invalidateRootParamsModule() is called in both add and unlink watcher handlers alongside invalidateRscEntryModule().

  3. Parallel-slot routes (fixed)synthesizeParallelSlotRoutes now propagates rootParamNames: parentRoute.rootParamNames, with a dedicated test ("preserves root layout params on synthetic parallel slot routes").

  4. Dead code (fixed)runWithRootParamsContext removed.

  5. pickRootParams moved to runtime (fixed) — Extracted from the generated entry template to shims/root-params.ts, imported by the entry.

The overall approach is solid:

  • Scanner computes root param names at build time based on segments before the first layout
  • Virtual module (next/root-params) generates per-param named exports using getRootParam
  • Per-request state flows through the unified ALS context with a global fallback
  • Test coverage is good: scanner unit tests, generated-entry assertions, runtime getter test, and parallel-slot propagation

One minor observation below, but nothing blocking.

function __clearRequestContext() {
setHeadersContext(null);
setNavigationContext(null);
__setRootParams(null);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: __setRootParams(null) is called twice when __clearRequestContext() runs — once via setNavigationContext(null) (line 473) and once directly (line 479). This is harmless (idempotent set to null), but it's slightly misleading to read. If the intent is that setNavigationContext(null) always clears root params as a side effect, the explicit call on line 479 is redundant. If the intent is defense-in-depth (in case someone later removes the side-effect from the wrapper), then a comment would help future readers understand why.

Not blocking — just noting the redundancy.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 28, 2026

Review submitted. All four issues from the previous review have been addressed. The PR is approved with one minor nit about the redundant __setRootParams(null) call in __clearRequestContext() — it's called both explicitly and via the setNavigationContext(null) wrapper side-effect. Harmless, but worth a comment for future readers.

github run

@james-elicx james-elicx merged commit db57d45 into cloudflare:main Apr 28, 2026
24 checks passed
@Divkix Divkix deleted the fix/806-shim-next-root-params branch April 28, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shim next/root-params module with runtime getters

2 participants