feat: close Next.js 16 file-routing parity gaps (#848)#849
Merged
Conversation
6 tasks
Collaborator
Author
Pre-merge self-review summaryRan 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
Round 2 (deep on the fixes): 1 doc-scoping fix
Round 3 (convergence): CLEAN
Final low-effort diff pass: no actionable findings
VerificationFull 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. |
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
6b173f5 to
4105a70
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #848.
Closes four Next.js 16 file-routing parity gaps so Next-trained agents' muscle memory transfers. Committed per gap.
Landed
params/searchParams(sync +awaitboth work, via a non-enumerablethen)forbidden()/unauthorized()throwers + boundary files (403/401, nearest-wins)global-error/global-not-foundroot-only boundaries, plus a FIX making nestednot-foundnearest-winsinstrumentation/instrumentation-client+setOnError(composes withopts.onError)Note on Gap 3's behavior change: nearest-wins for
not-foundis long-standing Next App Router behavior (since Next 13), NOT a Next 16 change. webjs parsed nestednot-foundfiles 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 nestednot-found.tsnow renders the nearest instead of root); a before/after counterfactual test locks it.global-not-foundis the newer Next-16-era convention.Deferred / rejected (see #848):
template.{js,ts}(low value in webjs, high client-router risk), themiddleware->proxyrename (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