Skip to content

refactor(router): discovery plugin as phase modules (S15) - #1243

Merged
ivogt merged 1 commit into
reorg/stackfrom
reorg/s15-discovery-phases
Oct 23, 2026
Merged

ivogt merged 1 commit into
reorg/stackfrom
reorg/s15-discovery-phases

Conversation

@ivogt

@ivogt ivogt commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

devcorner: rango-t146 · session rango-s272

Problem

createRouterDiscoveryPlugin (src/vite/router-discovery.ts:527 on the base, d9cbaa0) was one closure of 2,146 lines. Its configureServer hook alone ran :643-2298; buildStart ran :2303-2409, buildApp post :2423-2441, buildEnd :2446-2455 and generateBundle :2547-2653. Three variables of plugin-instance lifetime (viteCommand, viteMode, transitionWhenOverlay) lived in the closure instead of on DiscoveryState, so no code outside the closure could see the state the hooks shared. This is S15 of the staged reorganisation (plan rango-d195 section 3; target rango-d194 section 3.6). It is a move only: nothing a consumer writes, sees or configures changes.

Before / after (internal shape)

Before:

export function createRouterDiscoveryPlugin(entryPath, opts): Plugin {
  const s = createDiscoveryState(entryPath, opts);
  let viteCommand: "serve" | "build" = "build";
  let viteMode = "production";
  let transitionWhenOverlay = null;
  return {
    configureServer(server) { /* 1,655 lines */ },
    async buildStart() { /* 106 lines */ },
    buildApp: { order: "post", async handler(builder) { /* ... */ } },
    async buildEnd(error) { /* ... */ },
    generateBundle(_options, bundle) { /* 106 lines */ },
    // configResolved, transform, handleHotUpdate, resolveId, load, closeBundle
  };
}

After:

export function createRouterDiscoveryPlugin(entryPath, opts): Plugin {
  const s = createDiscoveryState(entryPath, opts);
  return {
    configureServer(server) { devServerPhase(s, server); },
    buildStart() { return buildDiscoveryPhase(s, this); },
    buildApp: { order: "post", handler(builder) { return postBuildPhase(s, builder); } },
    buildEnd(error) { return buildEndPhase(s, error); },
    generateBundle(_options: any, bundle: any) { bundleScanPhase(s, this, bundle); },
    // configResolved, transform, handleHotUpdate, resolveId, load, closeBundle: unchanged
  };
}

Code-change summary

30 files, +2,557 / -2,458. Source (src/vite/): +2,526 / -2,431, so the move costs 95 lines of module headers, imports and JSDoc.

File Lines What it holds
src/vite/router-discovery.ts 2,672 -> 273 The plugin: hook wiring, configResolved, transform, handleHotUpdate, resolveId/load and closeBundle, unchanged.
src/vite/discovery/dev-server-phase.ts (new) 1,776 devServerPhase(s, server): the configureServer body, verbatim. Also isFileOutsideApp, the overlay helpers and the /__rsc_shell boot-race injection set.
src/vite/discovery/build-discovery-phase.ts (new) 204 buildDiscoveryPhase (buildStart), postBuildPhase (buildApp post), buildEndPhase (buildEnd) and resetPrerenderCollection.
src/vite/discovery/bundle-scan-phase.ts (new) 128 bundleScanPhase (generateBundle).
src/vite/temp-rsc-server.ts (new) 223 createTempRscServer and the cloudflare:* Node loader-hook registration.
src/vite/discovery/build-env.ts (new) 148 resolveBuildEnv, acquireBuildEnv, releaseBuildEnv, wranglerProxyOptions.
src/vite/discovery/state.ts +15 viteCommand, viteMode, transitionWhenOverlay fields; the defaults are the closure's ("build", "production", null).

Why these locations:

  • The dev and build phases both use the temp server and the buildEnv helpers. If those had stayed in router-discovery.ts, the phases would import the plugin file and the plugin file would import the phases, a cycle. Two leaf modules avoid it.
  • temp-rsc-server.ts sits in src/vite/, not src/vite/discovery/. ensureCloudflareProtocolLoaderRegistered resolves ./plugins/cloudflare-protocol-loader-hook.mjs against its own import.meta.url. That relative path is correct from src/vite/ and from the published bundle dist/vite/index.js, which ships the hook at dist/vite/plugins/. From src/vite/discovery/ it would point to the wrong place.
  • buildApp post moved with buildEnd. buildStart's finally defers closing the kept temp server and releasing the buildEnv as a pair. The buildApp post hook releases that pair on success and buildEnd releases it on abort, so all three now sit in one module.

Edits inside the moved code, all mechanical:

  • References to the three closure variables now read s.viteCommand, s.viteMode and s.transitionWhenOverlay. The overlay helpers sendTransitionWhenOverlay and showTransitionWhenError take s as a parameter.
  • opts?.preset and opts?.staticRouteTypesGeneration read s.opts?.…. s.opts is the same opts object and is never reassigned.
  • this became an explicit context: Rolldown.PluginContext parameter in buildStart and generateBundle.
  • Comments that located code by its position in the old file ("the buildStart hook below", ./discovery/gate-state.ts, "our handleRouteFileChange") now name the new module.

Checked token for token (whitespace and trailing commas ignored, the substitutions above applied): every moved body (configureServer, buildStart, buildApp, buildEnd, generateBundle), createTempRscServer with the loader hook, the build-env helpers, isFileOutsideApp, modulesForFile, TempServerResult, injectedShellNotReadyPaths and resetPrerenderCollection match the base. The only other difference is the export keyword on acquireBuildEnv and releaseBuildEnv.

Not touched: virtual module ids and contents, codegen output, the HMR protocol and the plugin options.

Dist comparison (production builds)

Method: I ran pnpm build-router at the base (d9cbaa0) and at the head, and kept each dist/vite/index.js. That bundle is the only router artifact the app builds load: the runtime resolves to src/, and src/ outside src/vite/ is unchanged. Then I ran vite build in packages/rangojs-router/e2e/test-app and tests/cloudflare-basic three times: base (b1), base again (b2) and head (h1). All runs used the same checkout path and RANGO_ENCRYPTION_KEY pinned to one key. Without the pinned key, __vite_rsc_encryption_key.js differs on every build.

The apps' dist is not byte-reproducible even on one commit:

  • Fixture handlers render Date.now() / ISO timestamps and random tokens (stok-…, :<base36>) into Prerender, Static and PPR shell payloads.
  • Inline-action bound args are encrypted with a random IV.
  • The __pr-/__ps-/__st-<hash> asset names, the three build manifests, buildVersion and the ROUTER_VERSIONS table in deferred-resolution-*.js all derive from those payloads.

So the comparison has two parts. Files byte-identical in all three builds are compared by raw hash. The files that vary between two base builds are compared by hash after masking those values.

App Files (b1 / b2 / h1) Byte-identical in all 3 Digest of sorted sha256 path list of those (b1 = b2 = h1) Vary between two base builds Digest of sorted masked-hash list of those (b1 = b2 = h1)
e2e/test-app 453 / 453 / 453 421 87404ad20177e9f5 32 01ed57a020535801
cloudflare-basic 270 / 270 / 270 219 7315665b6972bd7e 51 4de6025a8b72df40

Base vs head differs raw in exactly as many path entries as base vs base: 60 (test-app) and 98 (cloudflare-basic), the same manifests, payload assets and deferred-resolution-* chunk. After masking, every file matches by manifest key in b1/b2, b1/h1 and b2/h1: 0 mismatches.

Masked hashes of the files that vary between builds (h1; b1 and b2 lists are identical)

test-app (32 files):

02e22ccdfacbb27a  rsc/assets/__pr-<h>.js
095a03b8af26f89d  rsc/assets/__pr-<h>.js
0d53393f6a44b3ad  rsc/assets/__ps-<h>.js
13e7b5764ffaab48  rsc/assets/__pr-<h>.js
17995df8a96be8ae  rsc/assets/__pr-<h>.js
1a4cda0b4161ac6f  rsc/assets/__pr-<h>.js
29096bab7acaa83e  rsc/assets/__pr-<h>.js
290d8037fee7baae  rsc/assets/__st-<h>.js
308d35b790931ff6  rsc/assets/__pr-<h>.js
420474cb6d5fe714  rsc/assets/__pr-<h>.js
4e7468add3201e3d  rsc/assets/__ps-<h>.js
4fd82aeb44f4eb11  rsc/assets/__ps-<h>.js
52a19f24081bc428  rsc/assets/__pr-<h>.js
5dbb2c26d9f0c1f0  rsc/assets/deferred-resolution-B2x5vkZq.js
7331afdb42bee191  rsc/assets/__pr-<h>.js
78e42204dcd1ea55  rsc/__prerender-manifest.js
8263418208e052c8  rsc/assets/__st-<h>.js
880291726b59fed4  rsc/assets/__ps-<h>.js
9242b60a4af67778  rsc/assets/__pr-<h>.js
a4cb2ad3191c3cbe  rsc/assets/__pr-<h>.js
a7962b57298a00b8  rsc/assets/__pr-<h>.js
ba90018854884844  rsc/assets/__st-<h>.js
c4159b2ee807b79f  rsc/assets/__pr-<h>.js
cf886645485c67b3  rsc/assets/__ps-<h>.js
db61762d8c57a8d5  rsc/assets/__ps-<h>.js
ddf9db47751a7391  rsc/__shell-manifest.js
e03981153d20c3a1  rsc/assets/__pr-<h>.js
e90d1384425c5118  rsc/assets/__ps-<h>.js
e9a3db5dd7546fbe  rsc/assets/__pr-<h>.js
ed5619417bba32b4  rsc/assets/__pr-<h>.js
f740b7c2c104ff35  rsc/__static-manifest.js
f78f7bea657aa378  rsc/assets/__pr-<h>.js

cloudflare-basic (51 files):

0a88a3caacee19e5  rsc/assets/__pr-<h>.js
1619ce7274094e06  rsc/assets/__pr-<h>.js
16a4b3ae02cd7c45  rsc/assets/__pr-<h>.js
29182a84379b5a7d  rsc/assets/__pr-<h>.js
2b478ab6b264daba  rsc/assets/__pr-<h>.js
2ed5af6bb6e9ac82  rsc/assets/__pr-<h>.js
3b88e69220c89412  rsc/assets/__st-<h>.js
478e1bdbf50e1b9d  rsc/assets/__pr-<h>.js
496444c756d34c87  rsc/assets/__pr-<h>.js
4aecc1763d7c3d0b  rsc/assets/deferred-resolution-B0Hhttsd.js
4f1a1b3e9ea4a459  rsc/assets/__pr-<h>.js
506326c795df7780  rsc/assets/__pr-<h>.js
5268f7dddff70f3c  rsc/assets/__pr-<h>.js
5e509d7526af1484  rsc/assets/__pr-<h>.js
60d1d0a43720e72f  rsc/assets/__pr-<h>.js
62c960137b7e68cb  rsc/assets/__pr-<h>.js
7684b916038916a7  rsc/assets/__pr-<h>.js
774b342a18644552  rsc/__static-manifest.js
7f1a173a06908710  rsc/assets/__st-<h>.js
81449dabaee4ad7d  rsc/assets/__pr-<h>.js
8200d2a1246cd40b  rsc/assets/__pr-<h>.js
83b4b557cdd1e7c0  rsc/assets/__st-<h>.js
8c0fb7e05aba77a2  rsc/assets/__ps-<h>.js
91a08dd8e0efee75  rsc/assets/__st-<h>.js
983cfc2e0e934fea  rsc/assets/__st-<h>.js
9c976b182c41d53a  rsc/assets/__pr-<h>.js
9e92c7efd3418c4c  rsc/assets/__pr-<h>.js
9ea4c617059e9dab  rsc/assets/__ps-<h>.js
9fdcdf43f6d659c0  rsc/assets/__pr-<h>.js
a0adad4888f19469  rsc/assets/__pr-<h>.js
a1df125ce134b6f2  rsc/assets/__pr-<h>.js
a845e7d1e0f1fc43  rsc/assets/__pr-<h>.js
ade790906284bdad  rsc/assets/__pr-<h>.js
b46aa1c0e43ad5c7  rsc/__prerender-manifest.js
b4ec7f38b0f395c7  rsc/assets/__pr-<h>.js
be8aeb0e3daa48f2  rsc/assets/__pr-<h>.js
bf5077b1b6ddc573  rsc/assets/__ps-<h>.js
c01d3b1e7918e50f  rsc/assets/__pr-<h>.js
c2a9b97f9f8bef81  rsc/assets/__pr-<h>.js
d43b229d82bc69af  rsc/assets/__pr-<h>.js
d4e50a8ee92beb2a  rsc/__shell-manifest.js
d62bb20225975f5e  rsc/assets/__pr-<h>.js
d9514876d40502ff  rsc/assets/__pr-<h>.js
e1c1973f3373b07e  rsc/assets/__ps-<h>.js
e21e28338cf2d18e  rsc/assets/__pr-<h>.js
e7fcc58c0e8e0189  rsc/assets/__ps-<h>.js
e90d1384425c5118  rsc/assets/__ps-<h>.js
edac514f0521046c  rsc/assets/__pr-<h>.js
f2c3745307c0136b  rsc/assets/__pr-<h>.js
f52c104d55a55973  rsc/assets/__pr-<h>.js
f570211fa04dc9a2  rsc/assets/__ps-<h>.js

Test plan

  • pnpm build-router; production builds of test-app and cloudflare-basic on base and head. Dist comparison above.
  • node tools/generate-route-types.mjs --check: current (1,172 source files, 16 apps, 235 per-module files, 0 stale). node tools/check-bundle-guards.mjs: green across 3 apps. git status --porcelain | grep '\.gen\.ts': empty.
  • pnpm --filter @rangojs/router test:hmr-routes-local (port offset 71): 20 passed, 1 flaky. an inline transition({ when }) edit republishes the hoisted predicate timed out in writeFileAndAwaitHmr and passed on retry. See Notes.
  • pnpm --filter @rangojs/router test:hmr-local (port offset 71): dev-warmup + hmr-basename + hmr-prerender 9 passed (includes all 6 prerender-hmr tests); hmr-routes 21 passed.
  • Router e2e route-types.test.ts, dev and production projects (port offset 71): 12 passed (9 dev, 3 production).
  • cloudflare-basic test:e2e:hmr-local (--project=hmr, port offset 71). CI skips it, and it is the only suite that drives the Cloudflare branch of the moved dev phase: the temp server, forceCloudflareWorkerReload and the readiness probe. Result: hmr.test.ts 7 passed and hmr-cold-discovery-recovery 1 passed; hmr-burst-save 2 tests skipped. hmr-route-mutations had no clean run, on base or on head. See Notes.
  • pnpm run typecheck: 16/16 tasks.
  • pnpm run test:unit:all: router 560 files / 9,234 passed (5 skipped, 6 todo); router rsc 81 files / 1,054 passed (3 expected fail); mini 25 + 3; vite-rsc-demo 27 + 1; cloudflare-basic 90 + 39; shared-e2e 20. 0 failures.
  • oxlint --deny-warnings: 0 warnings, 0 errors. oxfmt --check .: clean.
  • CI lint scripts, all OK: 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 (448 identifiers), check-skills, generate-skills-catalog --check, generate-route-types --check, check-internals (counts unchanged: 21 / 327 / 71 / 1).

Practices

  • Lint: ran oxlint --deny-warnings and oxfmt --check . on the whole repo; 0 findings after oxfmt formatted the new files and the edited doc tables.
  • Dead code: the move leaves nothing unused. createTempRscServer, resolveBuildEnv and isFileOutsideApp have callers in their new modules, and the three unit tests now import them from there. No re-exports were kept for the old path. Kept on purpose: the pre-existing export { VIRTUAL_ROUTES_MANIFEST_ID } in router-discovery.ts, which nothing imports from that path. It predates this step, and removing it is outside a move.
  • Tests: the unit suites that cover the moved code pass unchanged: dev-discovery-recovery, temp-rsc-server-realm, build-env-auto-root, route-watcher-scope, bundle-postprocess and the rest of src/vite, 62 files / 1,040 tests. Test edits are import paths, one fixture's three new required DiscoveryState fields and comments. No new behaviour, so no new test. The HMR suites ran locally as listed, because CI skips them.
  • Code review: reviewed the diff for correctness. No findings. Each moved body was checked token for token against the base, as described above. No simplification was applied: a step that moves code does not also rewrite it.
  • Docs: docs/internal/feature-file-map.md lists the five new files under the Vite plugin row. It also repoints the PPR-shell, progressiveChunkSize, dev-discovery-handshake and clientUrls entries. docs/internal/feature-map.md names the phase layout under Dev and HMR and repoints wranglerProxyOptions and the transition overlay. manifests.md, prerender-api-design.md and react-compiler.md name the new homes of isFileOutsideApp, the dev devServerOrigin branch and createTempRscServer. Comments in src/vite/, unit tests and e2e files that named router-discovery.ts for moved code now name the phase module.

Notes

  • HMR flake: route-types-hmr › an inline transition({ when }) edit republishes the hoisted predicate fails on base and on head, so this step did not cause it.
    • Failure: Timed out waiting for HMR after writing …/urls/blog.tsx. sawServerSignal=false …, with page.waitForFunction timing out in waitForHydration.
    • Full hmr-routes runs: head 5 runs gave 1 flaky, clean, clean, 1 flaky, and 1 run that failed on isolated-server beforeAll start timeouts at load average 43. Base 5 runs were clean.
    • Alternating base/head runs of the three transition tests (--retries=0, 6 pairs): the same test failed once on base (base-2), with the identical error. Head failed once (head-2) on a beforeAll server-start timeout. Every other run passed.
    • Other worktrees were running builds and e2e on this machine at the same time (load average 10-43).
  • Gap, flagged: tests/cloudflare-basic hmr-route-mutations.test.ts never passed locally on either side, so the Cloudflare HMR route-mutation path of the moved code has no green local run.
    • Base (d9cbaa0), 3 runs: failed at expectServed after the route add (:264), at the webServer 60 s start timeout, and at the clientUrls route-shape test (:230).
    • Head, 4 runs: failed at the clientUrls test, at the route add (:264, twice) and at "converges an open page". The furthest run passed 4 of 14 tests first.
    • Each failure is expectServed timing out after 30 s: the gen file is rewritten, but workerd does not serve the new route shape in time. Load average was 8-31.
    • Recorded as finding rango-t146-c1. It needs a run on a quiet machine before this lands.
  • Three runtime comments still say the dev /__rsc_prerender and /__rsc_shell endpoints are in vite/router-discovery.ts: src/rsc/shell-serve.ts:57, src/rsc/shell-serve.ts:90 and src/rsc/shell-build-manifest.ts:200. These comments end up in the RSC server bundle, so rewording them would change the app dist bytes and break the comparison above. They point to the plugin file, which wires configureServer to devServerPhase. They can be reworded in a later commit with its own dist check.
  • configResolved, resolveId/load, transform, handleHotUpdate and closeBundle stay inline in the plugin. They are config capture or short wiring, and this step names only the three phase modules. rango-d194's configPhase / virtualModules split stays open for a later step.
  • The configureServer body's own locals stay function locals of devServerPhase, with the same per-dev-server lifetime as before: the temp server handles, the registries, the gate, the debounce timer and the prerender memo. Only the plugin closure's variables became DiscoveryState fields.

createRouterDiscoveryPlugin's hooks now each call a phase module over
DiscoveryState: discovery/dev-server-phase.ts (configureServer),
discovery/build-discovery-phase.ts (buildStart, buildApp post, buildEnd)
and discovery/bundle-scan-phase.ts (generateBundle). The temp RSC server
moves to vite/temp-rsc-server.ts and the buildEnv helpers to
discovery/build-env.ts, so the phases import them without a cycle back
to the plugin file. The plugin closure's viteCommand, viteMode and
transitionWhenOverlay become DiscoveryState fields. No logic change.
@ivogt
ivogt merged commit 520b84d into reorg/stack Oct 23, 2026
19 checks passed
@ivogt
ivogt deleted the reorg/s15-discovery-phases branch October 23, 2026 17:59
ivogt added a commit that referenced this pull request Oct 23, 2026
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.
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.

1 participant