Skip to content

fix(router): review follow-ups — #567 dual-mode e2e, #569 pre-1.0 API decisions, #570 cleanup - #573

Merged
ivogt merged 6 commits into
mainfrom
fix/review-followups
Jun 13, 2026
Merged

ivogt merged 6 commits into
mainfrom
fix/review-followups

Conversation

@ivogt

@ivogt ivogt commented Jun 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Follow-up work deferred from the multi-agent review stabilization PR (#571, now merged), tracked across #567, #569, and #570. Rebased onto the post-#571 main.

This bundles three things a consumer can feel — new public API surface (#569), mandated dual-mode e2e for two correctness fixes (#567), and internal cleanup (#570). It deliberately excludes the matrix-critical items (see Deferred) so they can land in a focused, semantic-matrix-gated PR.


#569 — pre-1.0 public-API decisions (9 of 10; #6 deferred)

Each was an owner decision. Userland before/after for the consumer-facing ones, then a semantics table for the full set.

1. router.use(pattern, mw) now types ctx.params

// before — ctx.params is Record<string,string>, key not known-present
router.use("/users/:id", (ctx) => { ctx.params.id /* string, untyped key */ });

// after — pattern-generic overload via ExtractParams
router.use("/users/:id", (ctx) => { ctx.params.id /* ^? string (known-present) */ });
router.use(globalMw); // bare form unchanged, stays generic

2. useAction() return type is now nameable

// before — only @internal TrackedActionState existed; consumers couldn't annotate it
const state = useAction(addToCart);

// after
import { useAction, type ActionState } from "@rangojs/router/client";
const wrap = (a: ServerActionFunction): ActionState => useAction(a);

3. router.prefetch() source-scope option (parity with <Link>)

router.prefetch("/product/1");                    // before (global-scoped only)
router.prefetch("/product/1", { key: ":source" }); // after — matches <Link prefetchKey=":source">

7. discovery plugin option (completes the half-wired scanFilter)

// vite.config.ts — restrict / filter which files route discovery scans
rango({ discovery: { include: ["src/routes/**"], exclude: ["**/*.draft.*"] } });

10. redirect() in actions — drop the as any

// before (our own example actions did this)
export async function checkout(): Promise<void> { return redirect("/done") as any; }

// after — throw keeps a narrow return type and needs no cast (chosen over widening the type)
export async function checkout(): Promise<void> { throw redirect("/done"); }

Decision table

# Decision Outcome
1 router.use param typing Added pattern-generic overload (ExtractParams<P>); bare form unchanged
2 useAction return type Exported stable ActionState / ActionLifecycleState; TrackedActionState stays @internal
3 imperative prefetch scope Added prefetch(url, { key: ":source" })
4 buildEnv: "auto" Moved to per-preset: CF allows "auto", Node rejects it at the type level
5 poke plugin name Renamed vite-plugin-poke → @rangojs/router:poke
6 ./build internals Deferred — attempted the ./build/internal move; reverted (CI caught it). The Vite discovery pipeline imports buildRouteTrie/buildPerRouterTrie/collectFallbackClientRefs at build time via runner.import("@rangojs/router/build"), and the RSC runner doesn't resolve a ./build/internal subpath in that realm. Needs runner subpath resolution or a different seam.
7 scanFilter Completed: public discovery: { include, exclude } option compiled into state.scanFilter
8 mergeHandlerUse validation gap Documented the explicit-branch asymmetry (no behavior change)
9 profile registry Trimmed dead global _profiles/setCacheProfiles/getCacheProfile; kept request-scoped resolveCacheProfiles
10 return redirect() in actions Prefer throw redirect() (chosen over widening the type); return-form test actions now throw; skill guidance strengthened

#10 nuance: a form-bound action (<form action={fn}>) returning a redirect keeps an intrinsic, now-documented as any — React's form-action type is () => void | Promise<void> and can't express a Response return; throw is the clean path.


#567 — dual-mode e2e for two landed fixes (M16, M9)

The fixes shipped in #571 with unit coverage but lacked the mandated dev + production e2e.

  • M16 — Prerender + loading() + ctx.passthrough(): the loading() boundary defers the build handler, so the passthrough sentinel arrives Promise-wrapped; detectPrerenderPassthrough awaits it. New fixture pre-renders baked, skips skip via ctx.passthrough(). Production asserts the manifest holds baked but not skip (count === 1) — the regression guard.
  • M9 — sync redirect() under loading(): short-circuits to a real HTTP redirect. Verified for hard + soft navigation.

Both fixtures wired into the test-app, router.named-routes.gen.ts regenerated, 11 tests across dev + (production build).


#570 — safe cleanup (8 of 10 items)

Internal-only, behavior-preserving: removed write-only MiddlewareEntry.mountPrefix; deduped cookie helpers (deleted test-only middleware-cookies.ts, canonicalized in request-context); deleted the zero-importer handles/index.ts barrel; dropped a stale MiddlewareCollectableEntry re-export; extracted hasContextVars(); added a loud TypeError guard when a Static handler returns a Response (was a silent corrupt artifact); clarified the ctx.reverse() middleware fallback; doc fix for inTransitionScope.


Deferred (tracked, intentionally not here)

Matrix-critical (need a semantic-matrix run), saved for a focused PR:

The document-cache "timing-gap caveat" cited in #570 is a no-op — it doesn't exist at the cited lines (those are Cache-Control parsing).


Test plan

ivogt added 5 commits June 13, 2026 20:19
… and redirect under loading() (M9)

Adds the dev + production e2e fixtures the M16/M9 unit-tested fixes still
lacked (CLAUDE.md mandates dual-mode coverage). Deferred from the stabilization
PR; addresses the two main gaps in #567.

- prerender-loading: Prerender + loading() + ctx.passthrough(). The loading()
  boundary defers the build handler so the passthrough sentinel arrives
  Promise-wrapped; detectPrerenderPassthrough awaits it. getParams lists
  [baked, skip]: baked is prerendered, skip passes through to the live handler.
  Production asserts the manifest holds baked but NOT skip (M16 regression
  guard: count === 1; without the fix skip would bake a corrupt artifact).
- loading-redirect: a synchronous redirect() return under loading()
  short-circuits to a real HTTP redirect. Verified for hard + soft navigation
  in both modes.

Regenerated router.named-routes.gen.ts for the two new includes (urls.gen.ts is
legacy/unreferenced and no longer auto-generated, so left untouched).

Lower-priority #567 gaps (build-time intercept _currentSegmentId handle drop,
server-action-bridge pre-dispatch over-invalidation, document-cache
private+s-maxage) need fixes first and are deferred; #567 stays open for them.
Internal-only, behavior-preserving cleanups; grep-verified safe and gate-green
(typecheck, full unit suite, lint, format).

- Remove write-only MiddlewareEntry.mountPrefix (read nowhere; scoping folds
  into fullPattern before the entry is built). Dropped 3 production assignments
  plus the test fixtures.
- Dedupe cookie helpers: delete the test-only middleware-cookies.ts (byte-for-byte
  dup of request-context's parseCookiesFromHeader/serializeCookieValue), export
  the canonical copies from request-context for the unit tests, drop the
  middleware.ts re-export.
- Delete the zero-importer src/handles/index.ts barrel (Meta/MetaTags/Breadcrumbs
  are exported directly from the client/index entries); repoint collect-handle.test.
- Drop the MiddlewareCollectableEntry re-export from middleware.ts; its one
  importer now imports from middleware-types.
- Extract hasContextVars() into context-var.ts and use it in the two prerender
  entry points that duplicated the keys-or-symbols predicate.
- renderStaticSegment: throw a TypeError when a Static handler returns a Response
  (was silently serialized as a corrupt build artifact); the fresh/revalidation
  paths already throw via handleHandlerResult. Adds a unit test.
- ctx.reverse() middleware fallback: widen the signature to (name, params?, search?)
  to match ScopedReverseFunction and make the error message accurate.
- docs: tree-structure.md inTransitionScope also covers error/notFound segments.

Deferred, still open in #570: the optionalParams all-or-nothing removal and the
loaderPromises param removal both sit on matrix-critical matching/segment-resolution
paths and warrant a semantic-matrix run rather than a cleanup sweep. The
document-cache "timing-gap caveat" cited at document-cache.ts:45-53 does not exist
there (those lines are Cache-Control parsing), so that doc item is a no-op.
…part 1)

Type/naming/export-surface decisions (no behavior change). See #569.

- #1 router.use(pattern, mw): add a pattern-generic overload so
  `.use("/users/:id", (ctx) => ...)` types `ctx.params.id` via ExtractParams.
  Bare `.use(mw)` stays generic. Added on both Rango and RangoInternal. Pins it
  with a type-level test (router-use-types.test.ts).
- #2 useAction: export a stable public `ActionState` (and `ActionLifecycleState`)
  from ./client so consumers can name the hook's return type; TrackedActionState
  stays the @internal impl name (ActionState aliases it).
- #4 buildEnv: move off the shared base onto per-preset interfaces. Cloudflare
  keeps `"auto"` (wrangler getPlatformProxy); Node now rejects `"auto"` at the
  type level (Exclude<BuildEnvOption,"auto">) instead of only at build time.
- #5 poke plugin: rename name "vite-plugin-poke" -> "@rangojs/router:poke" so it
  matches the @rangojs/router:* family for consumers ordering/filtering by name.
- #6 ./build: move realm-crossing pipeline internals (buildRouteTrie,
  buildPerRouterTrie, TrieNode, TrieLeaf, collectFallbackClientRefs) to a new
  ./build/internal subpath; ./build keeps the documented public API
  (generateManifest*, route-type generation, createScanFilter, hashParams).
- #8 mergeHandlerUse: document the intentional validation asymmetry (explicit
  use() items bypass the mount-site allow-list); chosen over enforcing it.

Deferred to part 2 (behavior changes): #3 prefetch options, #7 scanFilter
wiring, #9 profile-registry trim, #10 redirect-in-action idiom + examples.
…omics (#569 part 2a)

- #3 router.prefetch(url, { key: ":source" }): add an options arg so imperative
  prefetch can source-scope its cache entry, at parity with
  <Link prefetchKey=":source">. Wired through to prefetchDirect.
- #10 redirect() in actions: drop the `as any` on non-form actions by typing
  them Promise<Response> (saveAndRedirect, actionSimpleRedirect). The form-bound
  peReturnRedirect keeps a documented intrinsic cast (React's form-action type is
  () => void | Promise<void>, which can't express a Response return); consumers
  should prefer `throw redirect(...)`. Strengthened the server-actions skill.

Deferred to part 2b: #7 (complete scanFilter — needs a new public plugin option)
and #9 (trim profile-registry — runtime cache-resolution refactor).
…istry (#569 part 2b)

- #7 complete the scanFilter feature. Adds a public rango() option
  `discovery: { include, exclude }` (glob, root-relative; mirrors the CLI's
  --include/--exclude). The consume side was already wired
  (findRouterFiles(state.projectRoot, state.scanFilter) at 3 sites); this
  threads the option through PluginOptions and compiles it via createScanFilter
  into state.scanFilter in configResolved, once projectRoot is known. Filters
  which files route discovery / static type-gen scan. Tests in
  generate-route-types.test.ts (include restricts, exclude removes).
- #9 trim the profile registry. Removed the global mutable `_profiles`,
  `setCacheProfiles`, and `getCacheProfile` — all dead: `getCacheProfile` had no
  production reader (its own JSDoc said so), and `setCacheProfiles` only
  populated the unread global. Kept the load-bearing `resolveCacheProfiles`,
  which createRouter() already uses to thread a per-request profile map (the
  "use cache: <profile>" path reads it request-scoped via ctx._cacheProfiles).
  Dropped the router.ts setCacheProfiles() call; repointed the tests to
  resolveCacheProfiles (and reframed the "global replace" test as per-router
  map independence).

#569 is now fully implemented (all 10 decisions).
@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@573

commit: abcf177

…in return-form redirect actions (#569.10)

CI on #573 caught a regression from #569 part 1: the Vite discovery pipeline
imports buildRouteTrie / buildPerRouterTrie / collectFallbackClientRefs at build
time via runner.import("@rangojs/router/build") (discover-routers.ts), and the
RSC runner does not resolve a ./build/internal subpath in that realm. Moving them
left collectFallbackClientRefs undefined, so the router-level default error /
notFound boundary was never collected into the app-fallback chunk
(mini.test.ts clientChunks: "mini-default-error must be in app-fallback").

Revert #569.6: restore buildRouteTrie/buildPerRouterTrie/collectFallbackClientRefs
+ TrieNode/TrieLeaf on ./build; delete ./build/internal + its package.json export.
#569.6 is re-deferred — internalizing these needs runner subpath resolution or a
different seam (noted in build/index.ts).

#569.10 revision: per owner, the return-form redirect test actions
(saveAndRedirect, actionSimpleRedirect) now `throw redirect()` instead of being
typed Promise<Response>. The form-bound peReturnRedirect keeps its documented
intrinsic cast.

Verified: mini clientChunks 6/6, location-state e2e 47/47 (dev+prod), typecheck,
3866 unit, lint, format.
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