Repository navigation
fix(router): dispatch() classifies through the shared request stages (S14b) - #1252
Merged
Merged
Conversation
…ages (S14b) dispatch matched through router.previewMatch, so it skipped production's terminal plans and set route params before the global chain. It now classifies each pass with the shared classify stage and answers terminalPlanResponse inside the global chain, as createRSCHandler does: - a `_rsc_v` version mismatch and a `_rsc_rid` app switch reload; - an action a rewrite moved to a response route, a redirect or an unmatched path throws production's error; - params, route name and the classified snapshot are recorded inside the chain by recordPlanRoute, a stage executeRequest now runs too, and not on a trailing-slash redirect; - a rewritten pass's request takes the visitor's telemetry id (setRequestId, from onHop), so its request.timeout matches dispatch's own request.start and request.end. classify's not-found fallback carries `unmatched: true`. The component-route throw stays before any middleware. previewMatch drops the response-route fields no caller reads any more. The negotiation tests build a real router instead of a previewMatch stub. New userland suite: testing/__tests__/dispatch-request-plans.test.ts.
49 of 50 tasks
ivogt
added a commit
that referenced
this pull request
Oct 23, 2026
Brings in S10b (#1254), S6 (#1250), S9 (#1244) and S14b (#1252). Conflicts: docs/internal/feature-file-map.md and feature-map.md. Both keep reorg/stack's text plus this branch's two facts: the cache row's CFCacheStore sentence (readL1, readKv, the family descriptors, the read bench) and the "Cloudflare store family reads" bullet, placed after the Vercel store bullets.
This was referenced Oct 23, 2026
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.
devcorner: rango-t159 · session rango-s300
dispatch()now classifies each request with the production handler'sclassifystage and answers its terminal plans inside the global middleware chain. A stale or cross-app navigation reloads, a rewritten action fails with production's error, and global middleware sees the route only afternext(), all asrouter.fetchdoes. Follow-up to S14 (#1242). It must land before the S22 pilot (rango-t150).Problem
S14 moved the handler's request stages into
rsc/request-stages.ts, buttesting/dispatch.tsstill matched throughrouter.previewMatch, because its suites used a stub router. That left four cases where a test passed underdispatch()but production answered differently:_rsc_v(version mismatch), or another router's_rsc_rid(app switch), never reloaded. On a component routedispatch()threw "does not render RSC routes", and on an unmatched path it answered 404. Production sendsX-RSC-Reload.?_rsc_action) that global middleware rewrote to a response route was served by that route. Production throwsA rewrite of a server action or form post must target a page route; … is a response route.ctx.routeNamebeforenext(). Production sets them inside the chain, inexecuteRequest, so they areundefinedthere.request.timeoutcarried the pass request's telemetry id. Without a request-id header that id differed fromdispatch()'s ownrequest.start/request.end. This regressed in S14.A test moved from e2e into
dispatch()(the S22 plan) could therefore pass where production reloads, throws, or reads the route later.Before / after
The same test, written against
dispatch():Before:
After (as
router.fetchanswers):Consumer example
Semantics
dispatch()_rsc_vis not the router's document version, component routeX-RSC-Reloadat the visitor's URLX-RSC-ReloadX-RSC-Reload(an action with a same-originRefererreloads to the referer)_rsc_partialwith another router's_rsc_rid, component route or unmatched pathX-RSC-Reload_rsc_partialto a response route (stale_rsc_v, foreign_rsc_rid, or neither)X-RSC-Reload_rsc_actiona rewrite moved to a response route, a redirect or an unmatched pathA rewrite of a server action or form post must target a page route; …ctx.routeName/ request-contextparamsbeforenext()undefined/{}next()next()on a trailing-slash redirect (308)undefined/{}request.timeoutid ≠request.start(since S14)request.start/request.end(as in 0.27.0)_rsc_v/_rsc_riddispatch()options, errors,DispatchOptionscreateRSCHandlerbehaviour is unchanged.Code change
Internal shape before:
After:
src/testing/dispatch.ts:matchDispatchPass(findMatch + previewMatch) is replaced byclassifyPass:stages.classify, then the RSC-route throw for any plan an exhaustiveANSWERS_MODEtable (Record<RequestPlan["mode"], boolean>) marks as rendering a component, unlessisUnmatchedPlan. The core runsterminalPlanResponseandrecordPlanRoute, then the 308,serveResponseRouteor the 404.onHopgives a rewritten pass's request the visitor's telemetry id. The stub-shapedpreviewMatchmember is gone fromDispatchableRouter;findMatchtakes the classify type.src/rsc/request-stages.ts:recordPlanRoute(params, route name and_classifiedRoute, skipped on a redirect), moved out ofexecuteRequest.isUnmatchedPlan.classify's not-found fallback setsunmatched: true.partialRedirectResponseis no longer exported; its only caller isterminalPlanResponse.src/rsc/handler.ts:executeRequestcallsrecordPlanRoute. This is a synchronous move, so it adds no microtask.src/router/request-classification.ts:FullRenderPlan.unmatched?: true.src/router/telemetry.ts:setRequestId(request, id).src/router/preview-match.ts,match-handlers.ts,router-interfaces.ts:previewMatchno longer returnsresponseType,handler,negotiatedormanifestEntry.dispatchwas their last reader.prerender/build-shell-capture.tsreads onlyrouteKey,paramsandrouteMiddleware, and a winning variant's route middleware is kept.request.start, as it did. The plan is classified once per pass and reused by the core.previewMatchran at the same point before this change.Source, without tests: 8 files, +158 / −203.
Test plan
src/testing/__tests__/dispatch-request-plans.test.ts, through@rangojs/router/testing(dispatch,setBuildVersions). Each test is red on the base (4a77cd3) and green here. On the base all 6 failed: the two reload tests threw "does not render RSC routes", the two timing tests saw a route name where production hasundefined, the action test resolved 200 instead of rejecting, and the timeout test gotrequestIdt-2againstrequest.start'st-1._rsc_vreload, and global middleware runs around it._rsc_ridreload.request.start,request.timeoutandrequest.end.dispatch.test.ts"Vary: Accept on negotiated response routes":createRouterwith json and text routes on one path replaces thepreviewMatchstub, and a stalepreviewMatchtest title is renamed. The adapted describe passes on the base too.pnpm run test:unit:all: router 566 files / 9,354 passed (5 skipped, 6 todo); router rsc 84 / 1,067 passed (3 expected fail); cloudflare-basic 18 / 90 + 13 / 39; vite-rsc-demo 6 / 27 + 1 / 1; mini 8 / 25 + 2 / 3; shared-e2e 4 / 20. This includestesting-module-graph.test.ts(the testing barrel and a dispatch load no@vitejs/plugin-rsc) andrequest-microtasks.rsc-test.tsx, whose counts are unchanged.packages/rangojs-router, dev and production,--grep "response-handler|content-negotiation|redirect-guard|rewrite", checked with--listfirst: 86 dev + 79 prod tests acrossresponse-handler,content-negotiation,redirect-guard,rewrite, plus thesemantic-matrixrewrite rows and oneurl-hookstitle.redirect-guardtests whose fixturebeforeAlltimed out at 30 s (e2e/fixture.ts:216). They passed on retry, and a--retries=0rerun of devredirect-guardpassed 9 of 9.pnpm run typecheck(16 of 16),oxlint --deny-warnings,oxfmt --check.check-e2e-bucketing,check-e2e-parity --strict,check-e2e-console-baseline,check-suspense-contract,check-no-external-store,node --test tools/*.test.mjs(53),check-doc-links,check-docs-api,check-skills,generate-skills-catalog --check,generate-route-types --check, andcheck-internals. All OK.Practices
oxlint --deny-warningsandoxfmt --checkare clean. Formatting fixes from oxfmt were applied, and no rule was silenced.matchDispatchPassand theDispatchPasstype.previewMatchmember fromDispatchableRouter.previewMatch'sresponseType,handler,negotiatedandmanifestEntryfields, with their two type copies. Every reader was searched across src, tests, e2e and docs; the only remaining caller, build-shell-capture, reads none of them.partialRedirectResponseis no longer exported.EntryDataimports.previewMatchitself, because build-shell-capture uses it.test:unit:allonce, then the targeted e2e in dev and production.recordPlanRoutestage, the explicitunmatchedmarker, the exhaustive mode table,setRequestIdfromonHop, theRequestStagesalias, and thepreviewMatchtrim. Left, with reasons:DispatchableRouterstill declares its stage fields by hand rather than deriving them fromStageRouter. That duplication predates this change.pluginRscMockfollows this directory's convention.classifyRequestsignals a 404 with a throw and catch. Changing that changes the production path.isSSR) partition, as the handler does.## Unreleasedhas a new entry, "Changed:dispatch()classifies a request as production does". S14's timeout bullet is corrected, since therequestIdagain matchesrequest.startandrequest.end. Also updated:skills/testing/response-routes.md(stages, reloads, the rewritten-action rule, therouteNametiming, therequestIdon timeouts),docs/testing.md(dispatch section) anddocs/internal/feature-map.md(testing entry, timeout entry).node tools/check-skills.mjspasses. No source file was added or removed, sofeature-file-map.mdis unchanged.Notes
getRequestIdkeys byRequestobject (router/telemetry.ts), andapplyRewritebuilds a newRequestper hop (rsc/rewrite-passes.ts).router/logging.tskeeps a second per-Request registry. A header-less rewritten request therefore gets a new id per pass in production too. This PR does not change that, because it would change production telemetry. I have not shown an event pair within one production request that splits across passes, so it is recorded here and not filed as a defect.handleResponseRoute's parameter is still namedpreview(rsc/response-route-handler.ts). That naming predates this change.request-microtasks.rsc-test.tsxis unchanged. Dispatch does onefindMatchper pass, where it used to do two.rebaseRequestContextresetsparams,routeName,reverseand_classifiedRouteon every hop (server/request-context.ts).id: routerId ?? ""for a router without an id: every routercreateRouterbuilds has one (RangoInternal.id: string), andensureRouterTriealready returns early without one.request.timeoutid differs from production: by design, it is ticket item 4. Production emits norequest.start/request.endfor a response route, anddispatch()'s own lifecycle events need one id (see the first note).request.start, while on a rewrite target it emitsrequest.error: unchanged from before, sincepreviewMatchran at the same two points.unmatched?: truewidens the production plan type for one reader: kept. It makes classify's 404 fallback explicit where it was anull as anythe types hid. The handler ignores it.previewMatch/resolveRoutecomments namingmanifestEntryandresponseType: still accurate, becausenegotiateRoutereads both.