Skip to content

fix(router): matrix-critical review follow-ups — #568 trie-suffix, #569.6 build-seam, #570 cleanups, #567 gaps - #575

Merged
ivogt merged 3 commits into
mainfrom
fix/matrix-critical-followups
Jun 13, 2026
Merged

ivogt merged 3 commits into
mainfrom
fix/matrix-critical-followups

Conversation

@ivogt

@ivogt ivogt commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

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-matrix run (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 ./build trie/fallback-ref exports

./build re-exported realm-crossing pipeline internals (buildRouteTrie, buildPerRouterTrie, collectFallbackClientRefs, TrieNode, TrieLeaf) indistinguishable from intended public API. A naive move to a ./build/internal subpath was tried and reverted in #573 (the RSC module-runner couldn't resolve the subpath, so discovery got undefined and 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 imports generate-route-types.js. They are pure transforms over generateManifestFull's output and realm-independent (collectFallbackClientRefs keys on the global-registry Symbol.for("react.client.reference")), so the Node plugin realm detects client references in runner-realm boundary trees fine. generateManifestFull (which runs user handlers) stays on runner.import.

// discover-routers.ts — before: everything via the runner barrel
const buildMod = await rscEnv.runner.import("@rangojs/router/build");
buildMod.buildRouteTrie(...); buildMod.collectFallbackClientRefs(...);

// after: trie/refs from source; only generateManifestFull stays on the runner
import { buildRouteTrie, buildPerRouterTrie } from "../../build/route-trie.js";
import { collectFallbackClientRefs } from "../../build/collect-fallback-refs.js";
const buildMod = await rscEnv.runner.import("@rangojs/router/build"); // generateManifestFull only

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 mini clientChunks e2e (dev + production) — mini-default-error still lands in app-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.

  • optionalParams all-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 the TrieLeaf.op build; migrated the test assertions (the sibling regex.test / params / constraints assertions already pin optional handling). Shrinks the serialized trie by one key per optional-param route.
  • dead loaderPromises param. Removed from resolveSegmentWithRevalidation / resolveAllSegmentsWithRevalidation (signatures, the RouterContext member 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's call[4] guard confirms.

#568 — longest-suffix-wins for overlapping trie suffix params (consumer-observable)

walkTrie iterates 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.

path("/:file.js", Js)         // declared first
path("/:file.min.js", MinJs)  // more specific

// before: GET /app.min.js  -> :file.js   (file = "app.min")  ✗
// after:  GET /app.min.js  -> :file.min.js (file = "app")     ✓

Fix: a build-time sortSuffixParams pass in route-trie.ts rebuilds each node's xp keys 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 through JSON.parse, so dev and production match identically.

Request Routes (declared .js first) Before After
/app.min.js :file.js, :file.min.js :file.js (file:"app.min") :file.min.js (file:"app")
/app.js same :file.js (file:"app") :file.js (file:"app") — unchanged
/archive.tar.gz :f.gz, :f.tar.gz, :f declaration-dependent :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-app suffix-overlap (.data/.v2.data) and cloudflare-basic suffix-params (.html/.archive.html). Generated router.named-routes.gen.ts regenerated for both. matching-and-lazy-discovery.md M4 updated.


#567 — review correctness gaps (fix + tests)

Gap 1: build-time intercept dropped ctx.use handle data

During build-time intercept prerendering, _currentSegmentId was 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 — a useHandle() consumer in the intercepted modal saw nothing.

intercept("@modal", ".detail", async (ctx) => {
  const push = ctx.use(InterceptCrumbs);
  push(`modal-handle:${ctx.params.slug}`); // before: dropped from the artifact
  return <Modal>…</Modal>;
});

Fix: pin (buildCtx)._currentSegmentId to 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 in prerender-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 fetch rejection) still ran finalizeAction(), which marked the client cache stale and broadcast a cross-tab invalidate — even though nothing mutated.

Terminal Before After
serialize throw (never sent) invalidate + broadcast no invalidate
fetch rejects (server unreachable) invalidate + broadcast no invalidate
response arrives (2xx/4xx/5xx) invalidate invalidate (unchanged)
keepClientCache() suppressed suppressed (unchanged)
version-mismatch reload no invalidate no invalidate (unchanged)

Fix: a per-invocation responseReceived flag, 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 keeps responseReceived true (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-maxage in the shared store

parseCacheControl keyed storage purely on s-maxage, so a contradictory-but-legal Cache-Control: private, s-maxage=60 was written to the shared edge store and replayed to every client. Fix: a tokenized RFC-7234 veto — if private or no-store is present as a standalone directive token, return null before considering s-maxage. Covers both store-write paths. Pinned by two new unit cases (private, s-maxage and no-store, s-maxage → zero shared-store writes); unit-only (store-internal, no consumer-observable HTTP surface for well-formed responses).


Test plan

Notes

  • Adversarially verified the five matrix-critical edits (missed-read / positional-arg / serialization-order / realm / handle-keying failure modes) before commit.
  • Docs updated in-PR: matching-and-lazy-discovery.md (M4 longest-suffix rule), feature-map.md + feature-file-map.md (trie builders now internal-only).
  • Deferred to a focused PR: Matrix-critical refactors from review (fresh/revalidation unification, trie suffix order, loader-cache rebind) #568 XL refactors (matrix-critical, multi-commit, one needs a bug-vs-intentional design decision on the orphan defaultOverride divergence).

ivogt added 3 commits June 13, 2026 22:47
….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.
@pkg-pr-new

pkg-pr-new Bot commented Jun 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@575

commit: 47892cb

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