Skip to content

feat: close Next.js 16 file-routing parity gaps (#848)#849

Merged
vivek7405 merged 8 commits into
mainfrom
feature/nextjs-16-routing-parity
Jul 8, 2026
Merged

feat: close Next.js 16 file-routing parity gaps (#848)#849
vivek7405 merged 8 commits into
mainfrom
feature/nextjs-16-routing-parity

Conversation

@vivek7405

@vivek7405 vivek7405 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Closes #848.

Closes four Next.js 16 file-routing parity gaps so Next-trained agents' muscle memory transfers. Committed per gap.

Landed

  • Gap 1: async params / searchParams (sync + await both work, via a non-enumerable then)
  • Gap 2: forbidden() / unauthorized() throwers + boundary files (403/401, nearest-wins)
  • Gap 3: global-error / global-not-found root-only boundaries, plus a FIX making nested not-found nearest-wins
  • Gap 4: instrumentation / instrumentation-client + setOnError (composes with opts.onError)
  • Docs (AGENTS.md, agent-docs, docs site) + scaffold rule files + gallery demo

Note on Gap 3's behavior change: nearest-wins for not-found is long-standing Next App Router behavior (since Next 13), NOT a Next 16 change. webjs parsed nested not-found files but only ever rendered the root one, so this is a latent bug fix aligned to established Next semantics. It is the one visible behavior change in the PR (an app with a nested not-found.ts now renders the nearest instead of root); a before/after counterfactual test locks it. global-not-found is the newer Next-16-era convention.

Deferred / rejected (see #848): template.{js,ts} (low value in webjs, high client-router risk), the middleware->proxy rename (webjs keeps Remix/Koa-style chainable per-segment middleware), parallel + intercepting routes.

Tests: unit + integration (handle() harness) for every gap, Bun parity scripts (node 26 + bun 1.3), plus full node suite (2941 pass), e2e (pass), browser (pass), scaffold generate/boot/check (32 pass).

Draft until the self-review loop converges.

https://claude.ai/code/session_013fyU1eSR1fqaaHV8p7r6eN

@vivek7405

Copy link
Copy Markdown
Collaborator Author

Pre-merge self-review summary

Ran a fresh-context self-review loop (3 rounds, each an independent reviewer with no implementation context) plus a final low-effort diff pass. The loop converged: the last round was clean.

Round 1 (broad): 3 code fixes + doc constraints

  • page-action forbidden()/unauthorized() returned a generic 500 instead of the 403/401 boundary. Fixed: the no-JS page-action write path now renders the same boundary as the page-render path via shared ssrForbidden / ssrUnauthorized helpers.
  • setOnError could cross-contaminate across concurrent boots (module-global handoff). Fixed: moved to an AsyncLocalStorage scoped per runInstrumentation call, so two createRequestHandlers racing in one process can never capture each other's sink.
  • Documented global-error's bare-document constraint (no importmap/hydration, keep it static, use cspNonce() for inline styles under CSP) and that instrumentation-client always ships a boot script.
  • Not a bug: forbidden()/unauthorized() thrown from a 'use server' RPC action return a generic 500, which is consistent with the existing notFound()/redirect() behavior on that path (pre-existing; the message is a framework constant, not sensitive). Left consistent.

Round 2 (deep on the fixes): 1 doc-scoping fix

  • The docs implied forbidden()/unauthorized() yield 403/401 from an RPC action. Scoped the docs to the page-render + page-action paths (RPC actions return an ActionResult for auth failures). No code change. Everything else in focus (ALS correctness, shared-helper call sites, cache/elision interaction, instrumentation-client dedupe, thenable-param poisoning) verified clean.

Round 3 (convergence): CLEAN

  • Docs internally consistent post-fix, no leftover debugging/TODO/skips, exports and .d.ts in lockstep, 90 focused tests pass.

Final low-effort diff pass: no actionable findings

  • One benign asymmetry noted only: the global-error arm returns a plain buffered 500 without streaming/frame markers, which is correct by design (it renders its own full <html>). No failure scenario.

Verification

Full node suite 2944 pass / 0 fail. e2e (Node + Bun) pass. browser pass. Bun parity all 4 cross-runtime scripts (node 26 + bun 1.3). scaffold generate/boot/check 32 pass. All 10 CI checks green.

Merge-ready. Closes #848 is accurate after template was removed from that issue's scope.

vivek7405 added 8 commits July 8, 2026 23:47
Next 16 renamed middleware->proxy; webjs deliberately keeps middleware
(Remix/Koa-style chainable per-segment). Document the distinction and
webjs's per-segment advantage over Next's single-root proxy. Also fix
section 1, which wrongly implied 'use server' is unsupported: it IS
supported as the RPC directive on .server.ts files, just not a
component annotation.

Refs #848
Next 15/16 turned params/searchParams into Promises (await params). webjs
kept them as plain sync objects, so a Next-trained agent's `const { id } =
await params` failed. Make them BOTH: sync-readable and awaitable, via a
non-enumerable `then` (makeThenable) so spread / JSON / Object.keys are
unaffected and only await sees it.

Wired at every routing-context construction site: the page/layout/loading/
error/metadata ctx (ssr.js), the page-action ctx (page-action.js), the
route.ts handler ctx (api.js), and the WS handler ctx (websocket.js). The
route() action-merge adapter is intentionally left alone (its merged arg is
an action input, not an awaitable page context).

Types: params/searchParams are Awaitable<T> in PageProps/LayoutProps/
RouteHandlerContext. Scaffold gallery dynamic-route demo teaches the
await form. Tested unit (incl. the non-enumerable-then counterfactual +
spread poisoning guard), integration through the real SSR pipeline, and
Bun parity (verified on node 26 + bun 1.3).

Refs #848
Next 15/16 have forbidden()/unauthorized() control-flow throws plus
forbidden.{js,ts}/unauthorized.{js,ts} boundary files. Add the webjs
equivalents, mirroring notFound(): new Symbol.for sentinels + isForbidden/
isUnauthorized guards in core nav.js, exported from @webjsdev/core.

Router parses forbidden/unauthorized stems and projects each onto the page's
ancestor chain (nearest-wins, like error boundaries). The SSR catch renders
the nearest boundary at 403/401 via a shared ssrBoundaryHtml helper (a
default page when none exists), and isControlFlowThrow now recognizes them so
the RPC/stream error paths pass the sentinels through un-sanitized.

Tested: nav unit (throwers + guards + the forbidden!=unauthorized
counterfactual), SSR integration (403/401 render, nearest-wins, default
fallback), and Bun parity (node 26 + bun 1.3). Docs: Public API table, app
tree, core module map, scaffold rule files.

Refs #848
…t-wins (#848)

Two root-only boundaries mirroring Next 15/16: global-error.{js,ts} (the
app-wide catch-all, tried after nested error boundaries, rendering its own
full <html> document verbatim at 500) and global-not-found.{js,ts} (404 for
an unmatched-anywhere URL).

BEHAVIOR FIX (the one deliberate change in this PR): a thrown notFound()
during a page render now renders the NEAREST not-found in the page's chain
(innermost wins), then a root global-not-found, then the default. Previously
it always rendered the bare default page, ignoring even a root not-found.js.
The per-dir notFounds map is now projected onto each page like error/loading.

Router parses the stems (global-* root-only) and returns them on the route
table; dev.js threads them into ssrOpts and the unmatched-404 fallback, and
adds forbidden/unauthorized/global-* to the browser-servable gate. Tested:
router unit, SSR integration (nearest-wins with a before/after counterfactual,
global-not-found, global-error single-<html> verbatim render), and Bun parity
(node 26 + bun 1.3).

Refs #848
Next parity for instrumentation.{js,ts} + instrumentation-client.{js,ts}.

- instrumentation.js (app root): register() runs ONCE at boot (after env
  validation, before the route table), the place to start APM / OTel. New leaf
  runInstrumentation loader mirrors the readiness loader; fail-open on a throw.
- setOnError (exported from @webjsdev/server): register()-installed error sink
  that COMPOSES with the createRequestHandler({ onError }) option (both fire).
  reportError now iterates a sinks array. The handoff singleton is cleared per
  boot so a sink never leaks across apps in one process.
- instrumentation-client.js (app root): imported FIRST in the client boot
  <script type=module>, so it runs before app modules. Resolved at the app root
  (sibling of app/, like env.js), threaded onto the route table + the
  browser-servable gate.

Tested: unit (register default/named export, setOnError composition, fail-open,
no-leak-across-boots, absent no-op), integration (client-first boot injection,
served 200), and Bun parity (node 26 + bun 1.3). Docs: app tree, observability,
scaffold rule files. index.d.ts declares the two new exports (drift test green).

Refs #848
…arams on docs site (#848)

Sync the docs site (docs.webjs.dev) with the Next 16 parity work: the
error-handling page gains forbidden.ts/unauthorized.ts and the root-only
global-error.ts/global-not-found.ts boundaries; the routing page notes that
params/searchParams are both sync-readable and awaitable.

Refs #848
…setOnError (#848)

Self-review findings:
- page-action (the no-JS write path) now renders the 403/401 boundary for a
  forbidden()/unauthorized() thrown from a page `action`, matching the
  page-render path, instead of falling through to a generic 500. Shared
  ssrForbidden/ssrUnauthorized helpers exported from ssr.js so both paths behave
  identically.
- setOnError is race-safe across concurrent boots: the register()->sink handoff
  moved from a module global to an AsyncLocalStorage scoped per runInstrumentation
  call, so two createRequestHandler()s racing in one process cannot capture each
  other's sink (the prior JSDoc overclaimed no-leak; now it holds under races).
- Documented global-error's bare-document constraint (no importmap/hydration,
  keep it static; cspNonce() for inline styles under CSP) and that
  instrumentation-client always ships a boot script.

Tests: concurrent-boot no-cross-contamination, page-action forbidden/unauthorized
403/401. Bun parity re-verified (ALS is runtime-sensitive).

Refs #848
…RPC actions (#848)

Review round 2: the docs implied forbidden()/unauthorized() thrown from a
'use server' RPC action yields 403/401, but on that path a control-flow throw
is a generic 500 (consistent with notFound()/redirect()). Scope the docs to the
page/layout render and page-action write paths where the boundary actually
renders, and tell RPC actions to return an ActionResult for auth failures
instead. No code change: the RPC behavior matches notFound()/redirect() and is
intentionally left consistent.

Refs #848
@vivek7405 vivek7405 force-pushed the feature/nextjs-16-routing-parity branch from 6b173f5 to 4105a70 Compare July 8, 2026 18:17
@vivek7405 vivek7405 merged commit 09c416a into main Jul 8, 2026
10 checks passed
@vivek7405 vivek7405 deleted the feature/nextjs-16-routing-parity branch July 8, 2026 18:23
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.

Close Next.js 16 file-routing parity gaps (async params, forbidden/unauthorized, global-error/not-found, instrumentation)

1 participant