Skip to content

fix(router): nested unnamed include() routes match; dispatch matches through the route trie - #1128

Merged
ivogt merged 2 commits into
mainfrom
fix/include-names-dispatch-trie
Oct 13, 2026
Merged

ivogt merged 2 commits into
mainfrom
fix/include-names-dispatch-trie

Conversation

@ivogt

@ivogt ivogt commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

devcorner: dc-t7 · dc-t26

Closes #1104
Closes #1107

Problem

#1104. With the trie live (every request in vite dev and production), the routes of an unnamed include() inside another unnamed include() answered 404, with no warning from the build, rango generate or the dev server. The same cause made a route behind a { name: "" } include render another module's handler, or 404 in production, when two included modules named a route alike (index).

#1107. dispatch from @rangojs/router/testing matched a router with no trie by URL pattern, while serveShellRequest and the real handler build the trie first. A router answered differently depending on what ran before: /api/items/ got a 308 on a fresh router and a 200 after serveShellRequest. A route only the trie gets wrong (the #1104 shape) passed in dispatch and 404'd in the app.

Cause of #1104: the hidden include name $prefix_N came from a counter whose value differs by realm. Build discovery produced $prefix_0.$prefix_0.login; lazy expansion at runtime produced $prefix_0.$prefix_1.login, because the counter snapshot is carried into lazy includes. find-match.ts then fell back silently.

Before and after

// urls.tsx
include("/", publicPatterns), // unnamed

// urls-public.tsx
export const publicPatterns = urls(({ include }) => [
  include("/", signInPatterns), // unnamed, nested
]);
// before: GET /login          -> 404 (dev and production), no warning
// after:  GET /login          -> the sign-in route
// path.json("/api/items", handler), no trailingSlash option
const res = await dispatch(router, { request: "/api/items/" });
// before: 308 -> /api/items on a fresh router, 200 after serveShellRequest
// after:  200 from the route, before and after serveShellRequest

End-to-end consumer example

An app mounts a public area and a sign-in area, both unnamed, and tests them in userland:

// urls.tsx
export const urlpatterns = urls(({ include, path }) => [
  include("/", publicPatterns), // unnamed
]);

// urls-public.tsx
export const publicPatterns = urls(({ include, path }) => [
  path("/", HomePage, { name: "home" }),
  include("/", signInPatterns), // unnamed, nested
]);

// urls-sign-in.tsx
export const signInPatterns = urls(({ path }) => [
  path("/login", LoginPage, { name: "login" }),
]);

// sign-in.test.ts
const router = createRouter({}).routes(urlpatterns);
const res = await dispatch(router, { request: "/login" });
expect(res.status).toBe(200); // was 404 in the app, and 200 here only because dispatch used the regex matcher

No source change is needed. The include("/", signInPatterns, { name: "" }) workaround keeps working.

Semantics

Case Before After
Unnamed include in an unnamed include, trie live 404 serves the route
Unnamed include beside a { name: "" } include holding another unnamed include, same route name in both wrong module's handler, or 404 in production each URL renders its own handler
Trie matches a key no loaded entry owns, vite dev silent 404 fallback throws, naming the route, static prefix and the includes checked
Same, production fallback unchanged (fallback kept)
dispatch, route with no trailingSlash and the other slash form 308 on a fresh router, 200 after serveShellRequest 200 both ways, as production
dispatch, trailingSlash: "never" or "always" 308 308 (unchanged)
dispatch, wildcard vs specific route specific wins unchanged
Hidden names in generated files, ctx.routeName, transition({ when }) never present never present (unchanged)
dispatch, include handlers and async providers ran once run once more on the first dispatch per router, when the trie is built

Code changes

  • src/urls/include-helper.ts: hidden names come from tree position, not a counter. nextIncludePosition counts per handler run (keyed by scopePath, which no ancestor snapshot holds), so every run counts from 0 in every realm. A nested unnamed include is $prefix_0.$prefix_0; one behind a transparent include is $prefix_1_0.

    const index = nextIncludePosition(ctx.counters, ctx.scopePath);
    const hiddenSoFar = joinScopePath(ctx.hiddenScopePath, index);
    const nextSegment = hasExplicitName
      ? explicitName
      : `${INTERNAL_INCLUDE_SCOPE_PREFIX}${hiddenSoFar}`;

    Why here: it is the one place that allocates the name; deriving it from position makes build and runtime agree by construction. IncludeScopePosition rides on the DSL context and through TrackedInclude and LazyIncludeContext (server/context.ts, types/route-entry.ts, build/generate-manifest.ts).

  • src/router/lazy-includes.ts: one resolveEntryPatterns for match-time expansion and loadManifest; replaces _lazyInflight and the providerResolutions WeakMap. Concurrent first hits share the import.

  • src/router/find-match.ts: the dev tripwire (describeUnownedTrieRoute).

  • src/testing/dispatch.ts, src/rsc/manifest-init.ts: dispatch calls ensureRouterTrie(router), which builds the trie unless the id has one from this router object. A NO_TRIE marker stops a zero-route router rebuilding on every dispatch. handler.ts is untouched: dev on Node gets tries pushed by router-discovery that no router object built, so the dispatch predicate would rebuild there.

  • Docs: docs/internal/matching-and-lazy-discovery.md, async-includes.md, feature-map.md, docs/testing.md, skills/testing/response-routes.md.

Not applied: deriving includeScope from scopePath, because includeScope feeds shortCodes, which are segment ids.

Test plan

  • src/router/__tests__/include-scope-names.test.ts (24 tests): build discovery and runtime expansion produce the same keys; dispatch serves every route through the dev and the production trie. 20 red without the fix.
  • src/router/__tests__/find-match.test.ts: the dev throw names the route; red without the fix.
  • src/testing/__tests__/dispatch.test.ts, async-include-dispatch.test.ts: trie built once per router; the NO_TRIE test failed with "expected 4 to be 2" before the marker.
  • Userland, tests/cloudflare-basic/test/include-scope-dispatch.test.ts and dispatch-after-shell.rsc-test.tsx: both red without the fix; the second pins the 200-before-and-after answer.
  • e2e packages/rangojs-router/e2e/include-scope.test.ts and tests/cloudflare-basic/e2e/include-scope.test.ts, each with a dev and a (production) describe. Router 115/115 (include-scope, route-resolution, semantic-matrix, include-middleware, optional-include), cloudflare-basic include-scope 4/4, dev and production, on the pre-rebase tree. Red was shown for cloudflare-basic dev only; the production counterparts were not run red.
  • On the rebased tree (0.24.1): typecheck 16/16; test:unit:all router 8,733 passed (5 skipped, 6 todo), rsc 830 passed (2 expected fail), consumer suites 1, 3, 20, 25, 27, 27, 87 passed; lint 0 warnings; format clean; check:e2e-bucketing OK; check-bundle-guards green, router chunk 49,527 B against 49,536 B.
  • CI runs e2e on the rebased tree.

Notes

  • Hidden names can change with this upgrade. On-demand prerender entries are stored under the route name: an app that pins createRouter({ version }) and prerenders on demand under unnamed includes should bump version on upgrade. This is in the CHANGELOG entry.
  • Headroom is 9 B under the 49,536 B router-chunk limit (main with fix(router): a clientUrls() layout() keeps its instance across navigations between its routes (#1124) #1125 is already near it).
  • tools/generate-route-types.mjs does not exist on this base. When the gen-files PR (dc-t21) lands first, this branch adds fixture modules (urls/include-scope.tsx, pages/include-scope.tsx) and needs node tools/generate-route-types.mjs run and committed after the rebase. git status --porcelain | grep '\.gen\.ts' is empty now.
  • The first dispatch per router runs every included urls() handler once more; tests counting handler or provider calls count from after it.

@pkg-pr-new

pkg-pr-new Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 7f1ad11

@ivogt
ivogt force-pushed the fix/include-names-dispatch-trie branch from 7838448 to f4ee465 Compare October 13, 2026 02:38
ivogt added 2 commits October 13, 2026 06:03
…mes come from tree position (#1104)

An unnamed include() took its hidden scope name ($prefix_N) from a per-context
counter. Build discovery runs each include's handler with fresh counters and
runtime expansion with the parent's snapshot, so an unnamed include nested in
another was $prefix_0.$prefix_0.login to the trie and $prefix_0.$prefix_1.login
at runtime: the route 404'd whenever the trie was live, with no warning. The
same counter gave an unnamed include beside a { name: "" } include the name of
an unnamed include behind it, so one URL rendered the other's handler.

Every include() now takes its index among its handler run's includes, and the
DSL context carries the position path (scopePath / hiddenScopePath) into the
nested run through TrackedInclude (discovery) and LazyIncludeContext (runtime
expansion, render-time loadManifest). An unnamed include's hidden segment is
$prefix_ plus its positions since the nearest include that added a name
segment, e.g. $prefix_1_0 behind a transparent include. Hidden names stay out
of every generated file and public route name; they can shift on upgrade, so
the changelog asks apps that pin createRouter({ version }) and use on-demand
prerender under unnamed includes to bump it.

In dev, a trie key that no entry at its static prefix registered now fails the
request with an error naming the key, the prefix and the includes checked (it
can also show briefly in the HMR window; the message says a reload fixes that);
production keeps the fallback.

Tests: include-scope-names.test.ts (build keys equal runtime keys; dispatch
serves every route through the dev and the production trie), find-match dev
check, e2e include-scope (dev + production) in the router test-app and
cloudflare-basic.
…duction do (#1107)

dispatch matched a router with no trie by URL pattern alone (the regex
fallback), while serveShellRequest and the router's handler build the trie
first. A router therefore answered by what ran before it: a trailing-slash URL
for a route with no trailingSlash option got a 308 on a fresh router and the
route after a serveShellRequest call. Trie-only bugs such as #1104 passed in
dispatch and 404'd in the app.

dispatch now loads a registered build manifest and otherwise matches through a
dev trie built from the router itself, the way rsc/handler.ts builds it (one
in-flight build per router). Router ids can repeat across instances, so
rsc/manifest-init.ts records which router object installed each id's trie
(isRouterTrieBuiltFrom); dispatch rebuilds when the id's trie came from another
router, and reuses a trie serveShellRequest built for the same router. A route
with no trailingSlash serves both forms with no redirect, as production does;
"never"/"always" still 308. Building the trie runs route discovery, so include
handlers run once more and async include providers are called.

loadManifest now shares one in-flight provider resolution per lazy entry, so
concurrent first renders of a leaf async include (resolved at render time
behind the precomputed shortcut) import it once, as match-time expansion does.

Tests that relied on the fallback:
- async-include-dispatch.test.ts: provider-call and handler-run counts start
  after a first dispatch (discovery calls each provider); the leaf include's
  handler runs once (precomputed routes at match time); concurrent dedup is
  pinned for a non-leaf include (match time) and a leaf include (render time).
- cloudflare-basic prefetch-false-client-urls.rsc-test.tsx: the #1107
  workaround router is gone; a fresh and a served router both serve the
  trailing-slash URL as the route.

New: dispatch.test.ts routers sharing an id each match their own routes;
userland cloudflare-basic include-scope-dispatch.test.ts (#1104 and the
transparent-scope collision through dispatch) and
dispatch-after-shell.rsc-test.tsx (#1107).
@ivogt
ivogt force-pushed the fix/include-names-dispatch-trie branch from f4ee465 to 7f1ad11 Compare October 13, 2026 03:05
@ivogt
ivogt merged commit a87c955 into main Oct 13, 2026
59 checks passed
@ivogt
ivogt deleted the fix/include-names-dispatch-trie branch October 13, 2026 03:33
ivogt added a commit that referenced this pull request Oct 13, 2026
… in 0.24.3

A clientUrls() layout() loader is held across the group's routes since
#1137, so the e-commerce recipe no longer steers the cart loader to a
server layout for that reason, and large-app loses its known-gap section.
The nested unnamed include note now says 0.24.x: #1128 ships after 0.24.3.
ivogt added a commit that referenced this pull request Oct 13, 2026
…-app, content and e-commerce apps (#1135)

* docs(skills): app-structure — concrete urls() layouts for SaaS, multi-app, content and e-commerce apps

A new skill, packages/rangojs-router/skills/app-structure/, puts the
primitives together into whole apps. SKILL.md holds the rules every recipe
shares (route tree in its own module, a named sync include() per section,
identity in router.use() and gates in route middleware, shared chrome in a
server layout, generated files), sync vs async include(), include naming
(name nested includes through 0.24.2, #1104), where shared chrome lives, a
growing-an-app path, and the minimal one-module app. Four companion recipes:

- large-app.md: a server shell over auth, onboarding, app, settings and
  admin clientUrls() groups; session in global middleware, sign-in,
  onboarding and role guards around the includes; prefetch: false with its
  measured trade-off; actions + revalidate(), refetch()/refreshGroup and
  cache tags for updates; fetchable loaders guard themselves; the group
  layout() loader gap through 0.24.2 and its workaround.
- multi-app.md: createHostRouter() with apex, app., admin. and docs. apps,
  what is shared and how, one TypeScript program per app, the host-override
  cookie in dev, cross-app links as document navigations.
- content-site.md: docs with Prerender + ppr and a Static nav, a blog with
  Prerender onDemand + Passthrough, the publish webhook beside the entry,
  machine routes.
- e-commerce.md: ppr category and product pages with tagged "use cache"
  reads and live loader holes, the cart as a layout loader, a guarded
  server-rendered checkout, the clientUrls() shop variant.

Wiring: the rango index and README tables list the skill, catalog.json is
regenerated, check-docs-api guards the five files, and check-skills
allowlists the /app and /settings route examples.

* docs(skills): app-structure: drop the group layout() loader gap fixed in 0.24.3

A clientUrls() layout() loader is held across the group's routes since
#1137, so the e-commerce recipe no longer steers the cart loader to a
server layout for that reason, and large-app loses its known-gap section.
The nested unnamed include note now says 0.24.x: #1128 ships after 0.24.3.

* docs(skills): app-structure: links by name (ctx.reverse, useReverse with the generated map) and throw rewrite()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant