Skip to content

fix(router): name-less children-fn path() collapsed the whole route map (#642) - #643

Merged
ivogt merged 1 commit into
mainfrom
fix/router-nameless-children-fn-route-map-642
Jul 3, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/router-nameless-children-fn-route-map-642

Conversation

@ivogt

@ivogt ivogt commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #642

Problem

A single name-less route written in the 3-arg children-fn form collapsed the
entire app's generated route map — not just the offending route:

path("/section/stream", StreamPage, () => [
  loader(StreamLoader),
  loading(<div>Loading...</div>),
]),

Downstream, Rango.Path resolved to never and every href(...) / Link.to /
named-route call in the app failed to typecheck. In tests/cloudflare-basic
(which registers RegisteredRoutes extends typeof router.routeMap) this is
observable as ~28 cascading errors like:

Argument of type '"/api/health"' is not assignable to parameter of type
'WithSuffix<"/unnamed-children-fn">'.
Property 'status' does not exist on type 'never'.   // Rango.PathResponse -> never

The 2-arg form (path(pattern, component)) and the named 3-arg form both
worked. Reproduced identically in two independent apps, so it was the overload,
not app-specific augmentation.

Root cause

For path(pattern, component, () => [...]), the third argument's parameter type
is PathOptions<TName, TSearch> | (() => UseItems<RouteUseItem>). A function
value structurally satisfies the all-optional PathOptions<TName> member, so
TypeScript resolves the route-name type parameter TName to its constraint
string instead of the UnnamedRoute = "$unnamed" default. ExtractRoutesFromItem
then mapped over a bare string key:

{ [K in TName]: TPattern }   // TName = string  ->  { [x: string]: "/section/stream" }

That index signature, intersected across siblings via UnionToIntersection,
poisons the whole map. (Confirmed via repro: the 2-arg form infers TName
"$unnamed"; the children-fn form infers string.)

Fix

packages/rangojs-router/src/urls/type-extraction.ts — a centralized
widened-name guard. This is the right location because every path variant
(path, path.json/.text/.html/.xml/.md/.image/.stream/.any) funnels through
these two extractors, so one guard covers them all with no change to the five
delicate PathFn overload return types:

// ExtractRoutesFromItem and ExtractResponsesFromItem
? TName extends string
  ? string extends TName          // NEW: unresolved/widened name -> treat as unnamed
    ? {}
    : TName extends UnnamedRoute
      ? {}
      : { [K in TName]: TPattern }
  : {}

Plus a defense-in-depth blast-radius guard in ExtractRoutesFromItems /
ExtractResponsesFromItems so a never intersection can never propagate as the
whole map (also makes empty urls(() => []) yield {} instead of never):

> extends infer R
  ? [R] extends [never] ? {} : R extends Record<string, any> ? R : {}   // NEW [R] extends [never]

The guard is precise: string extends TName is true only for the bare
string constraint. Template-literal names (item${number} from Array.from
loops) and literal unions ("a" | "b") are false under it and remain preserved.

Before vs. after

// urls.tsx
urls(({ path, loader, loading }) => [
  path("/section/stream", StreamPage, () => [loader(StreamLoader), loading(<Spinner/>)]),
  path("/about", AboutPage, { name: "about" }),
]);
Surface Before After
route map contribution of the name-less route { [x: string]: "/section/stream" } (index sig) {}
whole map { [x: string]: ... } & { about } -> poisoned { about: "/about" }
Rango.Path never "/about" (+ others)
href("about") type error ok

Semantics

Call form TName inferred Map contribution
path(p, C) (2-arg, unnamed) "$unnamed" {}
path(p, C, () => [...]) (name-less children-fn) string → guarded {} (was index sig)
path(p, C, { name: "x" }) "x" { x: p }
path(p, C, { name: "x" }, () => [...]) "x" { x: p }
path.json(p, h, () => [...]) (name-less) string → guarded {} (route+response)
name via a string variable (pre-existing, unchanged) string → guarded {} (now excluded, previously poisoned)

Tests

  • Unit type-test (new): src/__tests__/unnamed-children-fn-route-types.test.ts
    — 9 expectTypeOf assertions through the public urls() API across the full
    call-form matrix (name-less children-fn, empty children-fn, whole realistic map,
    multiple name-less, 2-arg, named children-fn, named options) plus the response
    map for name-less path.json / path.text. Pins the exact { about: "/about" }
    and { health: {...} } results the bug corrupted.
  • App-level end-to-end guard (new): added a name-less children-fn route
    /unnamed-children-fn to tests/cloudflare-basic/src/urls.tsx. Because that app
    wires RegisteredRoutes extends typeof router.routeMap, its pnpm typecheck now
    collapses if this regresses (proved red-before-green: 28 errors without the fix,
    0 with it). e2e/unnamed-children-fn.test.ts asserts the route resolves, renders,
    and hydrates in dev AND production (the runtime contract the issue asserted
    but never proved).
  • Verified: all package typechecks (router main/strict/augment + every consumer
    app), test:unit:all (router 4685+66, cloudflare-basic 46+10, vite-rsc-demo 25+1,
    mini 21+3), oxlint (0/0), oxfmt, e2e-bucketing + docs-api guards. No *.gen.ts
    changed (unnamed route correctly stays out of the named map).

Notes

  • Runtime is unaffected (the route always resolved and rendered) — this is a
    compile-time inference fix. The e2e is a runtime no-regression proof.
  • The overload widening itself (in path-helper-types.ts) is left as-is: the only
    consumers of the item's TName phantom are these two extractors, so guarding
    them is the complete, minimal fix and avoids touching the five path-variant
    biconditional return types.

…ap (#642)

The name-less 3-arg children-fn form path("/x", C, () => [...]) inferred its
route-name type param TName as the bare `string` constraint instead of the
UnnamedRoute sentinel: a function value structurally satisfies the all-optional
PathOptions<TName> union member of the 3rd parameter, so TS resolved TName to
its constraint. ExtractRoutesFromItem then mapped over a bare `string` key
producing `{ [K in string]: TPattern }`, an index signature that collapsed the
entire sibling map (Rango.Path -> never), breaking every href()/Link.to.

Fix: a centralized widened-name guard `string extends TName ? {}` in both
ExtractRoutesFromItem and ExtractResponsesFromItem (covers all path.* variants,
which funnel through these two extractors), plus a `[R] extends [never] ? {}`
blast-radius guard in the *FromItems combinators. The guard is precise:
template-literal names and literal unions are preserved.

Tests: 13 expectTypeOf assertions through the public urls() API (full call-form
matrix, response variants incl. path.stream, nested cache()/layout(), empty
urls()); a name-less children-fn route in cloudflare-basic making its typecheck
an end-to-end guard; and a dev+prod e2e proving the route renders and hydrates.
@pkg-pr-new

pkg-pr-new Bot commented Jul 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: a9d5e48

@ivogt
ivogt marked this pull request as ready for review July 3, 2026 10:50
@ivogt
ivogt merged commit d7ba4cd into main Jul 3, 2026
46 checks passed
@ivogt
ivogt deleted the fix/router-nameless-children-fn-route-map-642 branch July 3, 2026 11:03
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.

DSL typing: name-less 3-arg path(pattern, component, childrenFn) collapses the route map to never

1 participant