Skip to content

docs(skills): ux-architecture — a recipe for fast, stable navigation with urls() and clientUrls() - #1134

Merged
ivogt merged 3 commits into
mainfrom
docs/skill-ux-architecture
Oct 13, 2026
Merged

ivogt merged 3 commits into
mainfrom
docs/skill-ux-architecture

Conversation

@ivogt

@ivogt ivogt commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

devcorner: dc-t40 · session dc-s36

Draft for the user's review of the recipes. Sibling skill app-structure (dc-t41) is in progress; the three plain-text mentions of it become /app-structure links once both land. Devcorner ticket ids were removed from the shipped text (consumers cannot open them); the gaps are described in plain terms.

docs(skills): ux-architecture — a recipe for fast, stable navigation with urls() and clientUrls()

Problem

An agent building a Rango app reads skills organised by primitive (/client-urls, /loader, /layout, ...). None of them says how to compose an app so navigation is instant and stable, so agents put shared chrome in route components (it remounts on every route change), wrap a page in one big <Suspense> (the whole page flashes a skeleton), write revalidate(() => true) or () => false by habit (wasted runs, or the old product shown after product to product), throw transient loader failures (an action's form is replaced by an error boundary), and split an area into several clientUrls() groups (every navigation between them is a server round trip). The user: "agents dont know how good to use rango with url() and clientUrls and need reciept on how to architect a good UX".

Before / after

Before: an agent asked "where does the cart badge's loader go, and which revalidate() does it get?" has to assemble the answer from /client-urls, /loader, /layout, /links and docs/client-urls.md, and nothing tells it that a group layout() loader re-runs on every cross-route navigation today (dc-t38).

After: /ux-architecture section 2.3 and 2.4 answer it in one place (server layout around the include(), the ternary isAction() ? isAction(CartActions) : undefined, why), recipe 3.1 shows it in a shop, and section 5 states the dc-t38 gap with a measured workaround.

The skill's structure (886 lines, about 6,900 words)

  1. The UX contract. Four promises (a prefetched click shows the next page at the click; deferred parts reveal by React's rules; nothing that stays remounts; fallbacks only for new loading), each with what Rango does and what the app must do. How to judge it: a build, a 300 to 500 ms server.
  2. The decision tree. 2.1 server urls() page or clientUrls() group. 2.2 server structure around the group, and cross-group navigation (the shell stays, the destination group mounts; chrome that must survive goes above the groups). 2.3 server layout vs group layout(). 2.4 loader placement, the revalidate() defaults table and four predicates (bySlug, bySearch, byCartAction, bySavedProfile). 2.5 loading() vs inline <Suspense>. 2.6 prefetch, prefetch: false with its measured cost, prefetchKey=":source". 2.7 transition(), revalidate={false}. 2.8 intercept() and parallel(). 2.9 actions and failures as data. 2.10 three update channels (own actions plus revalidate(); load()/refreshGroup/router.refresh()/invalidateClientCache(); cache tags). 2.11 handles.
  3. Five recipes, each a sketch with a why per placement: a shop (vite-rsc-demo client-shop named as the reference implementation), dashboard tabs, a settings area with forms, search with filters, a modal over a list.
  4. Fifteen anti-patterns, each with the symptom the user sees and the fix.
  5. Known gaps: dc-t38 (with a measured workaround), dc-t30, React's 300 ms throttle, the idle view transition (A loading() skeleton delays content by up to 300 ms on a cold click; an idle view transition adds about 250 ms under transition() #1078), a client component first used by the click, the group's 2 to 4 commits (Suspense boundaries are handed promises React has not read: skeleton flashes over content on screen, and possible promise hot-swapping #1079), the URL leading a held page (A reader that mounts while React holds a navigation reads the destination's URL and params (usePathname, useSearchParams, useParams, useSegments) #1046).
  6. Where the details live: a topic to skill table.

Verification

Every behavioural claim was checked against origin/main e7b3d88. "Probe" is a scratch serveShellRequest test (not committed) of a server layout with two loaders around an include() of a group with a layout() (one loader revalidate(() => false), one with none) and two routes; counts are loader runs per request.

Claim Evidence
Default prefetch is viewport in production, none in dev src/router/prefetch-default.ts:35-37
A fully prefetched click commits in a transition with no fallback; a same-structure nav holds revealed content src/browser/partial-update.ts:1049-1062, :1170-1206
Server route keys carry params unless transition() is in scope; group segments are keyed cg:<mount> src/segment-system.tsx:505-569; src/client-urls/server-projection.ts:659
A group layout() keeps its instance across its routes; the route component is keyed by route src/client-urls/client-root.tsx:190-208; e2e "a layout() keeps its DOM across navigations between its routes (#1124)" in both apps, dev and production
A same-route intent from the committed page is not presented early src/client-urls/client-root.tsx:126-144
Navigation to another group gets no local presentation src/client-urls/navigation.ts:93-108, :138-142
A group destination that suspends with no boundary keeps the old page docs/client-urls.md, "Navigation authority"; client-root.tsx:126-138 (useDeferredValue)
Middleware runs on every in-group request; the outer layout handler holds tests/vite-rsc-demo/e2e/client-shop-guards.test.ts (dev + prodDescribe)
Group layout() loader with no rule re-runs on param and search change and after every action Probe: layoutDefault 1 on param nav, search nav, both actions
dc-t38: a group layout() loader with () => false runs on a cross-route nav Probe: layoutHeld 0 on param nav, 1 on /g/items/a to /g; src/client-urls/client-urls.ts:569-597; segment-resolution/revalidation.ts:254
Workaround: a server layout loader with the cart ternary holds on param, search, cross-route nav and on entering the group; runs once on a cart action, not on another action Probe: outerCart 0, 0, 0, 0; 1 on the cart action; 0 on the other action
useLoader finds a loader registered on an enclosing layout src/use-loader.tsx:175-212 (walks current.parent), src/outlet-provider.tsx:47 (code read, not test-pinned for a read inside a group)
A prefetch runs the predicates: a tab-switch prefetch holds a param-keyed loader and runs an unruled one Probe: prefetch ?tab=1 to ?tab=2: itemById 0, item 1; param prefetch: itemById 1
A param-sensitive rule that returns false on actions holds after an action Probe: itemById 0 on both actions
Loader defaults per position (route, layout above, first render always runs) skills/loader/SKILL.md defaults table; docs/internal/execution-model.md, "Revalidation Contract" (first-render guarantee)
A handler on a route without loading() is awaited; with loading() it streams docs/internal/execution-model.md:802-815
Loaders stream per loader with and without loading(); forceAwait and action lanes commit whole src/segment-system.tsx:705-750, :776-790
clientUrls predicates run on the server; imports and globals only docs/client-urls.md, "Supported surface"; skills/client-urls/SKILL.md, "Revalidation runs on the server"
prefetch: false in a group: the click shows the page at once, one fill; read under its own Suspense skills/client-urls/SKILL.md, "prefetch: false"; docs/client-urls.md
Measured cost of a flagged tab: 843-918 ms vs 77-157 ms prefetched whole devcorner consumer app, dev, loaded machine (dc-t3 comment dc-c79)
Prefetch TTL 300 s; an action drops prefetches unless keepClientCache() src/router/prefetch-cache-ttl.ts, src/router.ts:278; skills/client-urls/SKILL.md, "After an action"
prefetchKey=":source" for targets whose revalidate() reads currentUrl/currentParams src/browser/react/Link.tsx:107-128
revalidate={false} and setSearchParams(..., { revalidate: false }) update URL and hooks with no request; same pathname only src/browser/react/Link.tsx:78-86; skills/hooks/urls.md, navigation.md
Search-only navigation holds content in server routes and groups; isLoading flags the held reader partial-update.ts:1049-1062; skills/hooks/data.md; tests/vite-rsc-demo/e2e/client-shop-filters.test.ts
An action re-runs route-owned segments and loaders above, not layouts above; form state survives docs/internal/execution-model.md, "Revalidation Contract"; docs/client-urls.md, "After an action"
A loader that throws on an action re-run rejects the call or hands the error to a boundary docs/client-urls.md, "A loader that fails on its rerun after an action" (e2e action-failure in both apps)
One action, three outcomes in the demo shop tests/vite-rsc-demo/e2e/client-shop-cart.test.ts (dev + production)
load()/refetch/useRefreshLoaders() need a fetchable loader; plain load() broadcasts skills/hooks/data.md
invalidateClientCache() marks history stale and flushes prefetches skills/hooks/state.md
cacheTag, updateTag, revalidateTag, keepClientCache, invalidateClientCache exports src/index.rsc.ts:242-254
A loader an action skips keeps its title and crumb tests/shared-e2e/src/held-handles-scenario.ts:54 (both apps)
dc-t30: intercept middleware only on the live path src/router/intercept-resolution.ts:320 is the only caller; cache hit goes match-middleware/intercept-resolution.ts:155 → resolveInterceptLoadersOnly
Middleware runs on a cache hit skills/caching/SKILL.md:192
React's R-throttle and R-start/R-wait; idle view transition; first-use client component; 2-4 group commits docs/design/prefetch-false.md:1041-1073; skills/view-transitions/SKILL.md:468-487; docs/internal/suspense-contract.md rule 5 and "What the audit does not see"
PR #1130 (revalidate(true)/revalidate(false)) is not on main gh pr view 1130: open draft. The skill does not mention the shorthand.

Code-change summary

  • packages/rangojs-router/skills/ux-architecture/SKILL.md (new).
  • packages/rangojs-router/skills/rango/SKILL.md: one index row under "Structure & routing", sized to the existing column so the table does not reflow.
  • packages/rangojs-router/skills/catalog.json: regenerated (node tools/generate-skills-catalog.mjs).
  • packages/rangojs-router/docs/README.md: a one-sentence pointer.
  • tools/check-docs-api.mjs: the new skill joins the guarded docs, so a renamed API it names fails CI.

Test plan

  • node tools/check-skills.mjs: OK, 48 skills.
  • node tools/check-docs-api.mjs: 321 identifiers verified (the new skill's 39 included, --list).
  • node tools/check-doc-links.mjs: OK.
  • node tools/generate-skills-catalog.mjs --check: current, rango table complete both ways.
  • oxfmt --check .: clean. oxlint --deny-warnings: 0 warnings, 0 errors.
  • Lefthook pre-commit (format, skills, skills-catalog, no-external-store): green.
  • Scratch serveShellRequest probe for the revalidation counts above (9 cases, passed).
  • Typecheck and test:unit:all: not run; no TypeScript source changed.
  • /simplify and an Opus review of the diff.

Notes

  • The app-structure link is plain text. skills/app-structure/ (dc-t41) is on another branch, and check-skills fails a backticked /app-structure that does not resolve. After both branches merge, turn the three "app-structure skill" mentions (Not this skill if, 2.2, section 6) into `/app-structure`.
  • devcorner ids in a shipped skill. Section 5 names dc-t38 and dc-t30 as asked; no shipped skill did this before, and consumers cannot open them. Filing GitHub issues for both and swapping the ids would fix that.
  • dc-t38 wording. The gap says "check CHANGELOG.md for the fix" rather than naming 0.24.3. When the fix lands, the recipe 3.1 note and the 2.3 table cell can move the cart loader back to the group layout() if that is preferred; the server-layout placement stays correct either way.
  • Unreleased behaviour. Intercept loaders' prefetch: false and per-loader revalidate() (feat(router): intercept() loaders are loader segments of their slot: prefetch: false, per-loader revalidate() and handles reach modals #1132) are in CHANGELOG.md "Unreleased"; the skill describes them as current, which holds from the next release.

Update 2026-10-13

  • G2 (fix(router): a clientUrls() layout() loader is held across its routes and follows its revalidate(), as a server layout's does #1137): a clientUrls() layout() loader is now held on navigation, as on a server layout. The defaults tables and recipes say so, the known gap is removed, and the revalidate(true|false) shorthand (feat(router): revalidate(true) and revalidate(false) shorthand in urls() and clientUrls() #1130) is used.
  • Links by name, never by string.
    • Inside a module, link by local name (ctx.reverse(".x"), useReverse(routes) with the module's generated .gen.ts). The module never knows its mount, and mount params (/:accountId/:projectId, /:locale, /:locale?) are filled automatically.
    • Links that cross modules are built on the server and passed down.
    • Hardcoded paths in the recipes are replaced.
    • The rules are verified in research dc-d58.
  • Held layouts. A server layout stays held across navigations, its own param included, by design. Param-dependent nav goes in a client component with useReverse(routes), or the layout gets a revalidate() that compares the param.
  • Rewrite or redirect: when to use throw rewrite() from middleware (same URL, no round trip), and its limits.

@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 6bb2034

ivogt added 2 commits October 13, 2026 09:52
…with urls() and clientUrls()

A new skill, packages/rangojs-router/skills/ux-architecture/SKILL.md, for
agents that design or review a Rango app's routing, layout, loading and
navigation UX. The existing skills are organised by primitive; none says
where layouts, loaders, boundaries, revalidate() rules, prefetch and modals
go so a click shows the next page at once, nothing on screen remounts, and
no fallback re-flashes.

- The UX contract in one place: what Rango guarantees and what the app
  must do to keep it, and how to judge it (a build, a 300-500 ms server).
- A decision tree: server urls() pages vs clientUrls() groups, server
  structure around an include(), cross-group navigation, layout and loader
  placement with the revalidate() defaults and four predicates, loading()
  vs inline Suspense, prefetch (prefetch: false and its measured cost),
  transition(), intercept() and parallel(), actions, the three update
  channels, handles.
- Five recipes: a shop (vite-rsc-demo client-shop as the reference),
  dashboard tabs, settings forms, search with filters, a modal over a list.
- Fifteen anti-patterns, each with the symptom and the fix.
- Known gaps: dc-t38 with a measured workaround, dc-t30, React's 300 ms
  throttle and the other documented React gaps.

Wiring: the rango index row, catalog.json, a pointer in docs/README.md, and
the skill added to check-docs-api's guarded docs.
… on navigation; revalidate(true|false) shorthand

A group layout() loader now takes a server layout's revalidation default
(#1137): held on navigation, re-run after actions unless its revalidate()
narrows that. The loader-defaults tables, the shop recipe and the cart rule
say so, and the known gap is gone. prefetch: false skips only a loader new
to the page. The revalidate() anti-patterns use the #1130 shorthand.
@ivogt
ivogt force-pushed the docs/skill-ux-architecture branch 2 times, most recently from cfbf68a to 5643e2a Compare October 13, 2026 10:22
… with the generated map) and throw rewrite()
@ivogt
ivogt force-pushed the docs/skill-ux-architecture branch from 5643e2a to 6bb2034 Compare October 13, 2026 10:26
@ivogt
ivogt marked this pull request as ready for review October 13, 2026 11:24
@ivogt
ivogt merged commit d54eac9 into main Oct 13, 2026
78 checks passed
@ivogt
ivogt deleted the docs/skill-ux-architecture branch October 13, 2026 11:41
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