Repository navigation
refactor(router): handler stages; dispatch built from them (S14) - #1242
Merged
Merged
Conversation
Lift the stages around rendering out of the createRSCHandler closure into rsc/request-stages.ts, which imports no @vitejs/plugin-rsc: classify, guardRequest, partialRedirectResponse, runRequestPasses (the rewrite passes and the global middleware chain), serveResponseRoute and timeoutResponse, finalizeRequestResponse (redirect interception, onResponse drain, rewrite header, open-redirect guard, stream idle), createErrorReporter and reportBackgroundErrors. createRSCHandler runs them around its Flight executor; HandlerContext extends the stage context. dispatch drops its hand-kept copy of the same orchestration and runs the passes, the response route, the partial redirect and the finalization, keeping its previewMatch matching, its throw on an RSC route, its telemetry lifecycle and its documented boundaries. It loads the stages lazily, so the testing barrel's eager graph is unchanged; a module-graph test pins both graphs plugin-rsc-free.
49 of 50 tasks
guardRequest was always awaited, which put a microtask between classification and execution on every request, including the full, partial and response plans it answers synchronously. Split it: terminalPlanResponse (the action-rewrite check, the partial redirect and the reloads) is synchronous, and coreRequestHandler awaits checkRequestOrigin itself, only for the modes ORIGIN_CHECK_PHASE_BY_MODE gates, as before the stages moved; originRejectedResponse reports a rejection through onError and telemetry. The guard adds no microtask on any request kind. request-microtasks.rsc-test.tsx pins the hops through createRSCHandler, observed from route code: a document to its route middleware (7) and from its global chain to the response (9), a response route to its handler (8) and to the response (22).
dispatch() now runs the handler's request stages, and in a few edge cases its answer moved to production's. List them in an Unreleased CHANGELOG entry, and make the testing skill's redirect line precise: when a global middleware matches the path, any 3xx leaving the chain on a partial or action request becomes a 204 + X-RSC-Redirect; without one, an action request's 3xx goes out raw, as production sends it.
request-stages.bench.tsx times one request, call to body read, per route: createRSCHandler for a json route behind global middleware, a text route, a document, a Flight partial, a 308 and a 404 document; dispatch for json, text, 308 and 404. 200 warmup and 2,000 timed requests per route. It runs under vitest.rsc.config.ts; the main config's benchmark exclude keeps a bare `vitest bench` from loading it without the react-server condition.
Brings in S16 (#1248, wire-params.ts WIRE), S5 (#1245, deriveRenderPass), S15 (#1243, discovery phases), #1246 and #1247. Conflicts, each resolved by keeping S14's structure and applying S16's intent to it: - rsc/handler.ts imports: kept the request-stages.js import; dropped the classifyRequest, RequestPlan and INTERNAL_RANGO_DEBUG imports S14 had already moved out; added S16's WIRE import, still read by the code that stayed in the handler (warmup, loader refuse, redirect tracking mode, isPartialAction). - rsc/handler.ts response tail: kept the finalizeRequestResponse call; the inline partial/action interception S16 rewrote lives in request-stages.ts and now reads WIRE.partial and WIRE.action. - rsc/handler.ts terminal plans: kept the terminalPlanResponse call; the action-rewrite check, partial redirect and version-mismatch log S16 rewrote live in request-stages.ts and now read WIRE.action, WIRE.partial and WIRE.version. - testing/dispatch.ts imports: added WIRE; dropped applyStreamIdleTimeout, which the stages now call. - testing/dispatch.ts isPartial: kept S14's shape (the reverse and isAction moved into the stages) with S16's WIRE.partial. - tools/internals-baseline.json: took the stack's version; check-internals --update leaves it unchanged (rsc-param-literal 4, all in use-loader.tsx). request-stages.ts has no `_rsc_*` literal left. The handler and stages together make the same searchParams calls as S16's handler.ts: has() on action x3, partial x3, loader and warmup; get() on loader and version.
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-t143 · session rango-s295
Problem
testing/dispatch.tsre-implemented, by hand, the orchestration insidecreateRSCHandler(rsc/handler.ts) for non-Flight routes: the global middleware passes around the 308/404, the response route under itsrenderStartMsdeadline, the timeout report, the partial/action redirect interception, the onResponse drain, the rewrite header, the open-redirect guard and the stream-idle watchdog. Its comments said it worked "exactly as handleResponseRoute does"; it drifted once (#1128). With two copies, a fix in the handler could leavedispatchfrom@rangojs/router/testinganswering differently from production, and no test would fail.This is S14 of the internals reorganisation (plan rango-d195 section 3, architecture rango-d194 section 3.7). The stages move out of the handler closure into one module, and both callers run it.
Before / after (internal shape)
Before:
After:
Code-change summary
src/rsc/request-stages.ts(new, 730 lines). Most of it is code moved verbatim fromhandler.ts, comments included.src/rsc/handler.tsgoes from 1,630 to 1,253 lines.coreRequestHandleris nowclassify, thenterminalPlanResponse, then base's origin guard shape:checkRequestOriginawaited only for the modesORIGIN_CHECK_PHASE_BY_MODEgates, withoriginRejectedResponsereporting a rejection, thenexecuteRequest.serveResponseRoute.runRequestPasses. The response tail callsfinalizeRequestResponse; the handler's Server-Timing runs in itsbeforeGuardhook and the span attributes run after it.executeRequestbranches),executeRenderWithMiddleware,createRedirectFlightResponse, and request setup.src/testing/dispatch.tsgoes from 1,023 to 571 lines.matchDispatchPass(findMatch + previewMatch, with the RSC-route throw before middleware), its 308/404, its telemetry lifecycle, and its public options and errors.createRedirectFlightResponseis the no-state 204 + X-RSC-Redirect. It does not call onError for a response-route handler's own throw; that is now explicit throughreportResponseRouteError: () => {}.import(), which replaces its two lazy imports ofcache-scopeandresponse-cache-serve.dispatchnow loads 11 modules instead of 4:request-stages,request-classificationandresponse-route-handleramong them, none of them plugin-rsc. It was 12 at 4b4634c; the guard split droppedorigin-guard.tsfrom the stages' imports. Measured with a Vite module runner importingsrc/testing/index.ts, then dispatching once.src/rsc/handler-context.ts:HandlerContextextends the stages'RequestStageContext, so the handler passes its existing context bundle to the stages.src/rsc/response-route-handler.ts:handleResponseRoutenow takes only the members it uses. It reports a handler's throw throughreportResponseRouteErrorwhen that is set; the handler does not set it, so production still usescallOnError(error, "handler", …).cache/cache-scope.ts,cache/handle-snapshot.ts,rsc/json-route-result.ts,rsc/response-cache-serve.ts.tools/internals-baseline.json: no change againstreorg/stack. Since the merge onto S16, the code that moved readsWIRE.*, sorequest-stages.tshas no_rsc_*literal andrsc-param-literalstays at 4 (see "Updated onto reorg/stack after S16").src/rsc/__tests__/request-stages.bench.tsx(new, 135 lines): the hot-path timing bench (see Hot path timing). It runs only undervitest benchwithvitest.rsc.config.ts;vitest.config.tsgains abenchmark.excludefor it, so a barevitest benchunder the main config does not load it without the react-server condition.Production behaviour does not change: each stage is the handler's code, moved, run in the same order. These differences cannot be observed from outside:
classify: +1 on every request.runRequestPasses: +1 on every request.serveResponseRoute: +1 on a response route.terminalPlanResponseis synchronous, and the handler awaitscheckRequestOriginonly for the modesORIGIN_CHECK_PHASE_BY_MODEgates, as base did. 4b4634c always awaitedguardRequest, which added one more hop on every request. An async wrapper around the origin check would still add one on action, loader and PE-form requests, so the handler awaitscheckRequestOriginitself andoriginRejectedResponseis synchronous.Headersiterates sorted by name, so the order does not show.Microtask turns from
handler(request)to its resolved Response, counted by a microtask that re-queues itself. The counts are deterministic. Measured withcreateRSCHandlerand a real router in the rsc Vitest project, warm, without a global middleware on the path:GET /page/1)?_rsc_partial)path.json)?_rsc_loader, origin passes; unknown loader, 404)With a global middleware on the path, every count is 7 higher on all three revisions; the differences are the same.
Test plan
pnpm run typecheck: 16/16 tasks.pnpm run test:unit:all:dev-reload-handoff.test.ts, "hands its request to the router the reload re-created". It passes alone, takes the same time on head as on base when run alone and alternated, and the third full run passed. It times out on base under the same load.src/testing/__tests__dispatch suites (dispatch.test.ts,dispatch-*.test.ts,telemetry-emission,rewrite-userland,async-include-dispatch) and every consumer dogfood suite are unchanged.src/rsc/__tests__/request-microtasks.rsc-test.tsx. It pins the microtask hops on the request path. It does not test private internals: it drives the publiccreateRSCHandler(@rangojs/router/rsc) with a real router and observes from route code. It counts only router code.next()to the response, so React's render in between is left out. It pins 7 and 9.handler.tsandrequest-stages.tsit fails withexpected { toRouteMiddleware: 8, … } to deeply equal { toRouteMiddleware: 7, … }andexpected { toHandler: 9, toResponse: 23 } to deeply equal { toHandler: 8, toResponse: 22 }. On base it reads 6 and 8 for the document, and 7 and 19 for the response route.src/testing/__tests__/testing-module-graph.test.ts. It starts a fresh Vite module runner and imports the./testingexport's entry, checking that the export maps to./src/testing/index.ts. It then dispatches a cachedpath.jsonroute behind global middleware, plus a partial request to a trailing-slash redirect. It asserts thatrunner.evaluatedModulesholds no@vitejs/plugin-rscmodule, both after the import and after the dispatches. Shown red before green: with a staticimport "@vitejs/plugin-rsc/rsc/server"added torequest-stages.tsit fails withdispatch /api/data failed; plugin-rsc modules evaluated: …/@vitejs/plugin-rsc/dist/rsc/server.js. With that import added to the barrel it also fails. It passes without either.packages/rangojs-routerwith--no-depsand one grep:response-handler|content-negotiation|redirect guard|global middleware Response short-circuit|throw rewrite|Semantic matrix. Checked with--listfirst: it selects only those six files.--project=dev: 121 passed.--project=production: 117 passed.response-cache, fromtests/cloudflare-basic, on 4b4634c:src/rsc/__tests__/request-stages.bench.tsx, ran once withvitest bench --run --config vitest.rsc.config.ts: all 10 cases ran, each route answering its expected status (200, 308 or 404; checked before timing).vitest listpicks it up in neither the main nor the rsc config, sotest:unit:alldoes not run it.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(51 pass),check-doc-links,check-docs-api,check-skills,generate-skills-catalog --check,generate-route-types --check, andcheck-internals. No routes changed and no*.gen.tsdiff.Hot path timing
Quiet machine (load average under 4), base 7b38ae4 against head 3901965. 6 iterations, each running 2,000 timed requests per route on each side, alternating which side goes first. Each cell is the median of the run medians, in µs. The last column is a rerun with 10 iterations on a busier machine.
GET /api/data)GET /ping)GET /page/1)?_rsc_partial)GET /old)GET /missing)/api/data)/ping)/old)/missing)No regression on the document and Flight paths beyond run-to-run spread; the two extra microtask turns cost no measurable time; dispatch is faster.
The routes and the per-request measurement are committed as
packages/rangojs-router/src/rsc/__tests__/request-stages.bench.tsx: one request per sample, timed from the call to the end of its body read, 200 warmup and 2,000 timed requests per route. Run it frompackages/rangojs-routerwith./node_modules/.bin/vitest bench --run --config vitest.rsc.config.ts src/rsc/__tests__/request-stages.bench.tsx. To compare two revisions, run it in both checkouts, alternating, several times each.Updated onto reorg/stack after S16
After this PR was approved,
reorg/stackgained S16 (#1248), S5 (#1245), S15 (#1243), #1246 and #1247. This branch merges it in one merge commit, not a rebase, so the reviewed commits stay. S16 replaced every_rsc_*string literal withWIRE.*fromsrc/wire-params.ts, including in code this PR moved out ofhandler.tsanddispatch.ts, so three files conflicted. Each conflict keeps this PR's structure and applies S16's change to it:rsc/handler.tsimportsrequest-stages.jsimport. DroppedclassifyRequest,RequestPlanandINTERNAL_RANGO_DEBUG, which moved to the stages. Kept S16'sWIREimport for the readers that stayed in the handler.rsc/handler.tsresponse tailfinalizeRequestResponsecall. Its partial/action interception, inrequest-stages.ts, readsWIRE.partialandWIRE.action.rsc/handler.tsterminal plansterminalPlanResponsecall. Its action-rewrite check, partial redirect and version-mismatch log readWIRE.action,WIRE.partialandWIRE.version.testing/dispatch.tsimportsWIRE. DroppedapplyStreamIdleTimeout, which the stages call.testing/dispatch.tsisPartialisActionlive in the stages) withWIRE.partial.tools/internals-baseline.jsoncheck-internals --updateleaves it unchanged.request-stages.tshas no_rsc_*literal left. The names still spelled inrequest-stages.ts(one) andhandler.ts(two) are in comments, which the rule does not count and S16 also left. The handler and the stages together make the samesearchParamscalls as S16'shandler.ts:has()onWIRE.actionthree times,WIRE.partialthree times,WIRE.loaderandWIRE.warmup, andget()onWIRE.loaderandWIRE.version. Against the pre-merge head, the merge changes this PR's files only by those literal-to-WIREreplacements and threeWIREimports.Checks run on the merge:
pnpm run typecheck: 16/16 tasks.pnpm run test:unit:all:check-internals: OK,rsc-param-literal4 (all inuse-loader.tsx),request-context-object-create18,internal-vi-mock327,browser-import-into-server1.src/browser/__tests__/wire-urls.test.ts,testing-module-graph.test.ts, and the dispatch suites (dispatch.test.ts,dispatch-*.test.ts,async-include-dispatch.test.ts): 9 files, 115 passed.request-microtasks.rsc-test.tsx: 2 passed, with its counts unchanged (7 and 9 for a document, 8 and 22 for a response route).oxlint --deny-warnings,oxfmt --check ., and the CI lint scripts:check-e2e-bucketing,check-e2e-parity --strict,check-e2e-console-baseline,check-suspense-contract,check-no-external-store,node --test tools/*.test.mjs(53 pass),check-doc-links,check-docs-api,check-skills,generate-skills-catalog --check,generate-route-types --check. No*.gen.tsdiff.packages/rangojs-router,--project=dev --project=production --no-depswith one grep:response-handler|content-negotiation|redirect-guard|global-mw-response|rewrite|semantic-matrix.--listfirst: it selects those six files plus oneurl-hookstest whose title contains "rewrite".Practices for the merge:
oxlintandoxfmt --checkare clean; nothing suppressed.classifyRequest,RequestPlan,INTERNAL_RANGO_DEBUGinhandler.ts;applyStreamIdleTimeoutindispatch.ts) are not carried over.WIREis imported only where it is read.WIREreplacements and imports; nothing else moved.feature-map.mdandfeature-file-map.mdmerged without conflict, and S16's wire-protocol row names the module and its writer, not its readers, so it needs no change.Practices
oxlint --deny-warningsandoxfmt --check .over the repo. Both are clean, and nothing is suppressed.dispatch.tsandhandler.tsthat the stages replace, the closurecallOnErrorandhandleTimeoutResponse, and the imports they left unused. In the review fixes,guardRequestis gone, and so isClassifyContext.router.originCheck, which no stage reads once the handler awaits the origin check.execution-model.md,feature-map.md,security-checklist.md,router/instrument.ts).createCacheScopeinjection inresponse-cache-serve.ts, becausersc/__tests__/response-cache-serve.test.tsinjects a stub through it. Alsorsc/json-route-result.ts, which still has its callerresponse-route-handler.ts.test:unit:all(see the timeout note in the test plan), the targeted e2e listed above in dev and production on 4b4634c, and the CI lint scripts.createRSCHandlerand to dispatch, and reviewed the review-fix commits again; that pass found nothing. Reviewed the bench commit by hand: its header named only the document and Flight requests as React-rendered; the 404 document is too, now named.phase === "handler"without timeout metadata. It is now the explicitreportResponseRouteErrorseam.serveResponseRoutetook eight positional parameters; it now takes an options object.partialRedirectResponsewas typedany; it is now generic.import()per call; it now keeps the loaded module.guardRequesthop, the PR body's module-graph claim, the missing table rows, and the consumer note.createResponseWithMergedHeaders, which drains the callbacks.matchDispatchPassbuildsResponseRouteMatchfrom previewMatch's fields next toresponseRouteMatch(plan). The two take different input shapes.beforeGuardup is not needed for this step.docs/internal/feature-file-map.md,feature-map.md,security-checklist.mdandexecution-model.md, and therouter/instrument.tscomment. Added a## Unreleasedentry topackages/rangojs-router/CHANGELOG.md, and made one line ofskills/testing/response-routes.mdprecise (see Notes). The bench needs no doc change: the internal maps do not list benches, and its run command is in its header and under Hot path timing.Notes
Where dispatch now answers differently, it answers as production does, because the stage is production's code. No test pins any of these cases; the dispatch suites pass unchanged. The
## Unreleasedentry inpackages/rangojs-router/CHANGELOG.mdlists them for consumers.?_rsc_actionrequest (not partial), no global middleware on the final pass, response is a 3xx: a trailing-slash redirect, or a response route or its route middleware returning a redirect?_rsc_partialrequest to a trailing-slash path, with a global middleware that reads the response afternext()renderStartMstimeout with no global middlewareonTimeoutresponseVary: AcceptVary: AcceptonTimeoutthrowing[dispatch] onTimeout callback error:[RSC] onTimeout callback error:request.timeoutcarried the visitor's request and URLrequest.timeoutthen carries a differentrequestIdfrom dispatch's ownrequest.start/request.endrequest.timeoutrouteKeyrouteKey_reportedErrors)skills/testing/response-routes.mdsaid that every 3xx on an action request becomes a 204. It now says that when a global middleware matches the path, any 3xx leaving the chain on a partial or action request becomes a 204. Without one, an action request's 3xx goes out raw, as in production.Recorded on the ticket and not changed here (rango-t143, comment c1):
router.previewMatch, because the dispatch suites drive stub routers that supply onlyfindMatchandpreviewMatch(dispatch.test.ts, "Vary: Accept on negotiated response routes"). As a result, dispatch still lacks production's_rsc_vand_rsc_ridreloads and the action-rewrite check: it runs neitherclassifynorterminalPlanResponse. Review rango-t143-c2 proposes a follow-up step (S14b) that moves dispatch onto them, with the stub-router tests rewritten onto real routers, sequenced before the S22 pilot (rango-t150).ctx.paramsandrouteNamebefore the global chain; production sets them after it.reportResponseRouteError), which a later step could drop.