Repository navigation
fix(router): matrix-critical review follow-ups — #568 trie-suffix, #569.6 build-seam, #570 cleanups, #567 gaps - #575
Merged
Merged
Conversation
….6)
Drop buildRouteTrie/buildPerRouterTrie/collectFallbackClientRefs and the
TrieNode/TrieLeaf types from the public ./build barrel; they were realm-crossing
pipeline internals indistinguishable from intended API. The only build-time
consumer (vite/discovery/discover-routers.ts) now imports them via a direct
relative source path instead of runner.import("@rangojs/router/build") -- they
are pure transforms over generateManifestFull's output and realm-independent
(collectFallbackClientRefs keys on the global-registry react.client.reference
symbol). generateManifestFull stays on the runner. Pinned by a negative-surface
unit test and the mini clientChunks dev+prod e2e.
…n fields (#568, #570) #568: overlapping trie suffix params resolved by declaration order, not specificity -- /app.min.js could match :file.js instead of :file.min.js. A build-time sortSuffixParams pass orders each node's xp keys longest-suffix-first (stable), so the live matcher picks the most specific suffix. Allocation-free on the match path; serialized prod trie preserves order through JSON.parse. Consumer-observable -> dual-mode e2e on test-app + cloudflare-basic, gen files regenerated, M4 doc updated. #570 (behavior-preserving, matrix-critical): remove the write-only optionalParams field (CompiledPattern/RouteMatchResult/TrieMatchResult/TrieLeaf.op) read nowhere in production, and the dead loaderPromises param threaded through the *WithRevalidation resolution path. Fresh-path loaderPromises stays (still live). Semantic matrix green (61/61, no row changes).
…-invalidation, document cache (#567) g1: build-time intercept prerender left _currentSegmentId on the route's segment, so a handle pushed from an intercept handler landed in the wrong bucket and was dropped from the baked artifact. Pin it to the intercept slot id before resolving handler/layout (mirrors fresh.ts parallel-slot pinning). Dev+prod e2e; production proves the handle survives the baked artifact. g2: a server action failing BEFORE reaching the server (serialize throw / fetch reject) still marked the client cache stale + broadcast cross-tab. Gate the automatic invalidation on a responseReceived flag set in the fetch .then(); the fence is still always released. Unit-pinned (e2e exemption documented). g3: document cache stored Cache-Control: private, s-maxage in the shared store. Add an RFC-7234 tokenized veto in parseCacheControl (private/no-store -> null before s-maxage). Unit-pinned.
commit: |
This was referenced Jun 13, 2026
Closed
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.
Summary
Second wave of review follow-ups deferred from #573 — the matrix-critical batch plus the build-seam decision (#569.6). These were held back from the first follow-up PR because each either sits on a semantic-matrix-critical path (trie matching / segment resolution) or needed a design decision. Everything here is rebased on the post-#573 main and gated by a full
semantic-matrixrun (61/61 green, no row changes).Deliberately excluded (a separate focused PR): the #568 XL internal refactors (fresh/revalidation unification, etc.) — scoping showed the "byte-identical" premise is false and one sub-item needs a bug-vs-intentional design call, so they want their own runway.
#569.6 — internalize the
./buildtrie/fallback-ref exports./buildre-exported realm-crossing pipeline internals (buildRouteTrie,buildPerRouterTrie,collectFallbackClientRefs,TrieNode,TrieLeaf) indistinguishable from intended public API. A naive move to a./build/internalsubpath was tried and reverted in #573 (the RSC module-runner couldn't resolve the subpath, so discovery gotundefinedand the mini app-fallback chunk regressed).This time: stop reaching them through the runner barrel at all. The only build-time consumer (
vite/discovery/discover-routers.ts) now imports them via a direct relative source path, exactly as it already importsgenerate-route-types.js. They are pure transforms overgenerateManifestFull's output and realm-independent (collectFallbackClientRefskeys on the global-registrySymbol.for("react.client.reference")), so the Node plugin realm detects client references in runner-realm boundary trees fine.generateManifestFull(which runs user handlers) stays onrunner.import.A consumer can no longer mistake the trie builders for public API. Pinned by a new negative-surface unit test (
build/__tests__/public-surface.test.ts) and the miniclientChunkse2e (dev + production) —mini-default-errorstill lands inapp-fallback.#570 — matrix-critical cleanups (behavior-preserving)
Both touch the matching trie / revalidation path, so they ran the semantic matrix (green, no row changes). Internal only; no consumer-observable change.
optionalParamsall-or-nothing removal. The set was computed and threaded through three layers (CompiledPattern,RouteMatchResult,TrieMatchResult+TrieLeaf.op) but read nowhere in production. Removed every definition, write, and theTrieLeaf.opbuild; migrated the test assertions (the siblingregex.test/params/constraintsassertions already pin optional handling). Shrinks the serialized trie by one key per optional-param route.loaderPromisesparam. Removed fromresolveSegmentWithRevalidation/resolveAllSegmentsWithRevalidation(signatures, theRouterContextmember type,segment-wrappers, the runtime call site, and two test call sites). The fresh-path occurrences (resolveAllSegments/resolveSegment/setupLoaderAccess/ctx.loaderPromises) are genuinely live and stay —background-revalidation.test.ts'scall[4]guard confirms.#568 — longest-suffix-wins for overlapping trie suffix params (consumer-observable)
walkTrieiterates a node's suffix-param map (node.xp) in object order and returns the first suffix the segment ends with, so overlapping suffixes resolved by declaration order, not specificity.Fix: a build-time
sortSuffixParamspass inroute-trie.tsrebuilds each node'sxpkeys sorted by descending suffix length (stable sort → equal-length suffixes keep declaration order). Allocation-free on the match hot path; the serialized production trie preserves the order throughJSON.parse, so dev and production match identically..jsfirst)/app.min.js:file.js,:file.min.js:file.js(file:"app.min"):file.min.js(file:"app")/app.js:file.js(file:"app"):file.js(file:"app") — unchanged/archive.tar.gz:f.gz,:f.tar.gz,:f:f.tar.gz(f:"archive")The regex fallback still resolves by declaration order — a documented M4 divergence (the trie is the live matcher). Pinned by
trie-matching.test.ts(both declaration orders, 3-way overlap, multi-dot regression, stable-tiebreak key order),trie-regex-parity.test.ts(new divergence row),dev-prod-trie-parity.test.ts(overlapping pair), and dual-mode e2e on both apps: test-appsuffix-overlap(.data/.v2.data) and cloudflare-basicsuffix-params(.html/.archive.html). Generatedrouter.named-routes.gen.tsregenerated for both.matching-and-lazy-discovery.mdM4 updated.#567 — review correctness gaps (fix + tests)
Gap 1: build-time intercept dropped
ctx.usehandle dataDuring build-time intercept prerendering,
_currentSegmentIdwas left on the route's segment id, so a handle pushed from an intercept handler (ctx.use(Handle)) landed in the wrong bucket and was dropped from the baked artifact — auseHandle()consumer in the intercepted modal saw nothing.Fix: pin
(buildCtx)._currentSegmentIdto the intercept slot id (${parentEntry.shortCode}.${intercept.slotName}) at the top of the intercept loop, before resolving the handler/layout, and reuse it as the pushed segment id — mirroring the parallel-slot pinning the runtime path already does (fresh.ts). Pinned by dev + production e2e inprerender-intercept.test.ts; the production case proves the handle survives the baked artifact.Gap 2: server-action over-invalidated on pre-dispatch failure
A server action that failed before reaching the server (arg serialization throw, or a
fetchrejection) still ranfinalizeAction(), which marked the client cache stale and broadcast a cross-tabinvalidate— even though nothing mutated.keepClientCache()Fix: a per-invocation
responseReceivedflag, set as the first statement in the fetch.then(), gates the automatic invalidation. The action fence is still always released. A failed Flight decode after the response arrived keepsresponseReceivedtrue (the mutation may have committed). Pinned by two new unit cases (serialize throw + fetch reject → no invalidate, fence released); the existing response-arrived test is the positive counterpart. E2e deliberately skipped (cross-tab BroadcastChannel under a simulated mid-action network drop is impractical/flaky in Playwright; the bridge's decision is fully unit-reachable).Gap 3: document cache stored
private, s-maxagein the shared storeparseCacheControlkeyed storage purely ons-maxage, so a contradictory-but-legalCache-Control: private, s-maxage=60was written to the shared edge store and replayed to every client. Fix: a tokenized RFC-7234 veto — ifprivateorno-storeis present as a standalone directive token, returnnullbefore considerings-maxage. Covers both store-write paths. Pinned by two new unit cases (private, s-maxageandno-store, s-maxage→ zero shared-store writes); unit-only (store-internal, no consumer-observable HTTP surface for well-formed responses).Test plan
pnpm run typecheck— 12/12 packagespnpm run test:unit:all(repo root) — router (216 + 3 RSC) + every consumer dogfood suite, 0 failurespnpm run lint(oxlint) /pnpm run format(oxfmt) — cleansemantic-matrix— 61/61 (dev + production), no row changes (matrix-critical items behavior-preserving)clientChunksdev + production —mini-default-errorinapp-fallback(build-seam realm oracle)suffix-overlap+prerender-interceptdev + production — 21/21suffix-paramsdev + production — 8/8 (incl. new.archive.htmllongest-wins)public-surface(Pre-1.0 public-API decisions from review (router.use, useAction types, buildEnv, plugin name, ...) #569.6), trie longest-wins + parity rows (Matrix-critical refactors from review (fresh/revalidation unification, trie suffix order, loader-cache rebind) #568), document-cache veto (e2e coverage gaps from review: prerender Passthrough+loading(), redirect under loading() #567 g3), server-action pre-dispatch (e2e coverage gaps from review: prerender Passthrough+loading(), redirect under loading() #567 g2)router.named-routes.gen.tsregenerated + committed for test-app + cloudflare-basicNotes
matching-and-lazy-discovery.md(M4 longest-suffix rule),feature-map.md+feature-file-map.md(trie builders now internal-only).defaultOverridedivergence).