@rangojs/router 0.24.4 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/bin/rango.js +363 -39
- package/dist/testing/vitest.js +37 -4
- package/dist/types/browser/event-controller.d.ts +24 -5
- package/dist/types/browser/intercept-utils.d.ts +5 -2
- package/dist/types/browser/merge-segment-loaders.d.ts +0 -23
- package/dist/types/browser/navigation-transaction.d.ts +5 -5
- package/dist/types/browser/partial-update.d.ts +21 -13
- package/dist/types/browser/prefetch/cache.d.ts +8 -0
- package/dist/types/browser/rango-marker.d.ts +7 -0
- package/dist/types/browser/react/filter-segment-order.d.ts +10 -0
- package/dist/types/browser/react/use-handle.d.ts +20 -0
- package/dist/types/browser/react/use-href.d.ts +6 -0
- package/dist/types/browser/react/use-router.d.ts +6 -1
- package/dist/types/browser/response-adapter.d.ts +5 -3
- package/dist/types/browser/scroll-restoration.d.ts +9 -3
- package/dist/types/browser/segment-reconciler.d.ts +10 -13
- package/dist/types/browser/types.d.ts +38 -8
- package/dist/types/build/route-types/per-module-writer.d.ts +12 -0
- package/dist/types/cache/cf/cf-shell-frame.d.ts +2 -0
- package/dist/types/cache/types.d.ts +8 -0
- package/dist/types/client-urls/client-urls.d.ts +6 -2
- package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
- package/dist/types/client-urls/server-projection.d.ts +9 -5
- package/dist/types/client-urls/types.d.ts +29 -16
- package/dist/types/client.d.ts +2 -2
- package/dist/types/client.rsc.d.ts +2 -2
- package/dist/types/decode-loader-results.d.ts +28 -7
- package/dist/types/href-client.d.ts +3 -1
- package/dist/types/index.rsc.d.ts +1 -1
- package/dist/types/loader-redirect.d.ts +2 -3
- package/dist/types/outlet-context.d.ts +7 -6
- package/dist/types/prefetch-markers.d.ts +14 -5
- package/dist/types/route-content-wrapper.d.ts +17 -14
- package/dist/types/route-definition/helpers-types.d.ts +7 -2
- package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
- package/dist/types/route-definition/rewrite.d.ts +48 -5
- package/dist/types/route-types.d.ts +2 -2
- package/dist/types/router/handler-context.d.ts +14 -2
- package/dist/types/router/intercept-resolution.d.ts +19 -46
- package/dist/types/router/lazy-includes.d.ts +7 -0
- package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
- package/dist/types/router/middleware.d.ts +0 -16
- package/dist/types/router/navigation-snapshot.d.ts +2 -0
- package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
- package/dist/types/router/revalidation.d.ts +8 -1
- package/dist/types/router/router-context.d.ts +4 -16
- package/dist/types/router/router-interfaces.d.ts +0 -6
- package/dist/types/router/router-options.d.ts +4 -7
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
- package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
- package/dist/types/router/segment-resolution.d.ts +1 -1
- package/dist/types/router/segment-wrappers.d.ts +3 -5
- package/dist/types/router/substitute-pattern-params.d.ts +3 -2
- package/dist/types/router/tracing.d.ts +2 -2
- package/dist/types/rsc/full-payload.d.ts +14 -2
- package/dist/types/rsc/helpers.d.ts +8 -0
- package/dist/types/rsc/manifest-init.d.ts +20 -0
- package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
- package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
- package/dist/types/rsc/rewrite-passes.d.ts +12 -2
- package/dist/types/rsc/server-action.d.ts +6 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +10 -1
- package/dist/types/rsc/shell-capture.d.ts +7 -0
- package/dist/types/rsc/types.d.ts +5 -0
- package/dist/types/segment-system.d.ts +15 -0
- package/dist/types/server/context.d.ts +64 -4
- package/dist/types/server/request-context.d.ts +41 -4
- package/dist/types/server/request-headers.d.ts +12 -0
- package/dist/types/server/rewrite-trail.d.ts +8 -1
- package/dist/types/testing/e2e/index.d.ts +1 -1
- package/dist/types/testing/flight.entry.d.ts +1 -1
- package/dist/types/testing/index.d.ts +1 -1
- package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
- package/dist/types/testing/render-route.d.ts +21 -11
- package/dist/types/testing/run-loader.d.ts +7 -0
- package/dist/types/testing/run-middleware.d.ts +7 -0
- package/dist/types/testing/run-revalidate.d.ts +5 -4
- package/dist/types/testing/serve-shell-request.d.ts +41 -4
- package/dist/types/testing/shell-status.d.ts +17 -0
- package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
- package/dist/types/testing/vitest.d.ts +10 -1
- package/dist/types/thenable-status.d.ts +0 -6
- package/dist/types/types/handler-context.d.ts +12 -1
- package/dist/types/types/loader-types.d.ts +4 -3
- package/dist/types/types/route-entry.d.ts +2 -1
- package/dist/types/urls/path-helper-types.d.ts +4 -2
- package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
- package/dist/types/vite/discovery/state.d.ts +20 -0
- package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
- package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
- package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
- package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
- package/dist/vite/index.js +6816 -6547
- package/package.json +1 -1
- package/skills/app-structure/SKILL.md +255 -0
- package/skills/app-structure/content-site.md +333 -0
- package/skills/app-structure/e-commerce.md +435 -0
- package/skills/app-structure/large-app.md +885 -0
- package/skills/app-structure/multi-app.md +280 -0
- package/skills/breadcrumbs/SKILL.md +6 -1
- package/skills/caching/SKILL.md +3 -1
- package/skills/catalog.json +12 -0
- package/skills/client-urls/SKILL.md +185 -32
- package/skills/composability/SKILL.md +10 -8
- package/skills/handler-use/SKILL.md +48 -9
- package/skills/hooks/handle-and-actions.md +104 -0
- package/skills/hooks/navigation.md +24 -10
- package/skills/hooks/urls.md +1 -1
- package/skills/i18n/SKILL.md +2 -1
- package/skills/intercept/SKILL.md +60 -29
- package/skills/layout/SKILL.md +6 -3
- package/skills/links/SKILL.md +151 -17
- package/skills/loader/SKILL.md +32 -10
- package/skills/middleware/SKILL.md +111 -15
- package/skills/observability/SKILL.md +11 -0
- package/skills/parallel/SKILL.md +5 -5
- package/skills/ppr/SKILL.md +43 -8
- package/skills/prerender/SKILL.md +1 -2
- package/skills/rango/SKILL.md +3 -0
- package/skills/response-routes/SKILL.md +4 -1
- package/skills/scripts/SKILL.md +12 -1
- package/skills/server-actions/SKILL.md +60 -20
- package/skills/testing/SKILL.md +1 -0
- package/skills/testing/client-components.md +13 -10
- package/skills/testing/loader.md +25 -24
- package/skills/testing/middleware.md +11 -11
- package/skills/testing/response-routes.md +4 -1
- package/skills/testing/serve-shell-request.md +85 -21
- package/skills/ux-architecture/SKILL.md +980 -0
- package/skills/view-transitions/SKILL.md +43 -10
- package/src/__augment-tests__/augmented.check.ts +28 -0
- package/src/browser/event-controller.ts +44 -18
- package/src/browser/intercept-utils.ts +3 -2
- package/src/browser/merge-segment-loaders.ts +0 -85
- package/src/browser/navigation-bridge.ts +112 -84
- package/src/browser/navigation-client.ts +20 -15
- package/src/browser/navigation-store.ts +4 -2
- package/src/browser/navigation-transaction.ts +9 -8
- package/src/browser/partial-update.ts +90 -32
- package/src/browser/prefetch/cache.ts +8 -0
- package/src/browser/prefetch/fetch.ts +13 -3
- package/src/browser/rango-marker.ts +13 -0
- package/src/browser/react/NavigationProvider.tsx +25 -7
- package/src/browser/react/filter-segment-order.ts +13 -0
- package/src/browser/react/use-action.ts +9 -15
- package/src/browser/react/use-handle.ts +63 -71
- package/src/browser/react/use-href.tsx +24 -5
- package/src/browser/react/use-link-status.ts +1 -1
- package/src/browser/react/use-navigation.ts +30 -40
- package/src/browser/react/use-router.ts +37 -6
- package/src/browser/react/use-segments.ts +8 -12
- package/src/browser/response-adapter.ts +11 -5
- package/src/browser/rsc-router.tsx +3 -1
- package/src/browser/scroll-restoration.ts +34 -20
- package/src/browser/segment-reconciler.ts +11 -29
- package/src/browser/server-action-bridge.ts +52 -16
- package/src/browser/types.ts +40 -8
- package/src/build/generate-manifest.ts +12 -1
- package/src/build/route-types/codegen.ts +6 -2
- package/src/build/route-types/per-module-writer.ts +94 -61
- package/src/cache/cache-runtime.ts +9 -0
- package/src/cache/cache-scope.ts +2 -1
- package/src/cache/cf/cf-cache-store.ts +2 -0
- package/src/cache/cf/cf-shell-frame.ts +3 -0
- package/src/cache/types.ts +8 -0
- package/src/cache/vercel/vercel-cache-store.ts +6 -1
- package/src/client-urls/client-urls.ts +202 -153
- package/src/client-urls/revalidate-registry.ts +12 -3
- package/src/client-urls/server-projection.ts +137 -66
- package/src/client-urls/types.ts +30 -15
- package/src/client.rsc.tsx +11 -1
- package/src/client.tsx +9 -1
- package/src/decode-loader-results.ts +64 -31
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +1 -0
- package/src/index.ts +12 -4
- package/src/loader-redirect.tsx +2 -3
- package/src/outlet-context.ts +7 -6
- package/src/prefetch-markers.ts +15 -5
- package/src/reverse.ts +3 -3
- package/src/route-content-wrapper.tsx +19 -17
- package/src/route-definition/dsl-helpers.ts +27 -7
- package/src/route-definition/helpers-types.ts +7 -2
- package/src/route-definition/resolve-handler-use.ts +23 -10
- package/src/route-definition/rewrite.ts +86 -19
- package/src/route-types.ts +1 -2
- package/src/router/find-match.ts +37 -1
- package/src/router/handler-context.ts +85 -40
- package/src/router/intercept-resolution.ts +161 -307
- package/src/router/lazy-includes.ts +43 -27
- package/src/router/loader-resolution.ts +21 -1
- package/src/router/manifest.ts +6 -8
- package/src/router/match-api.ts +11 -1
- package/src/router/match-middleware/background-revalidation.ts +2 -4
- package/src/router/match-middleware/cache-lookup.ts +16 -20
- package/src/router/match-middleware/intercept-resolution.ts +41 -60
- package/src/router/match-result.ts +31 -2
- package/src/router/middleware.ts +6 -147
- package/src/router/navigation-snapshot.ts +38 -2
- package/src/router/prefetch-cache-ttl.ts +10 -18
- package/src/router/prerender-match.ts +2 -1
- package/src/router/revalidation.ts +24 -3
- package/src/router/router-context.ts +5 -18
- package/src/router/router-interfaces.ts +0 -7
- package/src/router/router-options.ts +4 -7
- package/src/router/segment-resolution/fresh.ts +6 -2
- package/src/router/segment-resolution/helpers.ts +7 -1
- package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
- package/src/router/segment-resolution/revalidation.ts +75 -35
- package/src/router/segment-resolution.ts +0 -1
- package/src/router/segment-wrappers.ts +8 -13
- package/src/router/substitute-pattern-params.ts +3 -2
- package/src/router/tracing.ts +2 -2
- package/src/router.ts +2 -9
- package/src/rsc/full-payload.ts +26 -4
- package/src/rsc/handler.ts +83 -41
- package/src/rsc/helpers.ts +13 -0
- package/src/rsc/manifest-init.ts +56 -3
- package/src/rsc/prefetch-render-error.ts +38 -0
- package/src/rsc/progressive-enhancement.ts +161 -211
- package/src/rsc/rewrite-passes.ts +56 -9
- package/src/rsc/rsc-rendering.ts +79 -23
- package/src/rsc/server-action.ts +25 -2
- package/src/rsc/shell-capture-constants.ts +22 -0
- package/src/rsc/shell-capture.ts +11 -0
- package/src/rsc/types.ts +5 -0
- package/src/segment-system.tsx +125 -119
- package/src/server/context.ts +96 -5
- package/src/server/handle-store.ts +3 -2
- package/src/server/request-context.ts +42 -3
- package/src/server/request-headers.ts +32 -0
- package/src/server/rewrite-trail.ts +55 -9
- package/src/ssr/ssr-root.tsx +6 -10
- package/src/testing/dispatch.ts +4 -5
- package/src/testing/e2e/index.ts +1 -0
- package/src/testing/flight.entry.ts +1 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/internal/client-urls-projection.ts +31 -11
- package/src/testing/render-route.tsx +83 -40
- package/src/testing/run-loader.ts +26 -2
- package/src/testing/run-middleware.ts +10 -0
- package/src/testing/run-revalidate.ts +13 -6
- package/src/testing/serve-shell-request.ts +77 -4
- package/src/testing/shell-status.ts +40 -0
- package/src/testing/vitest-stubs/version.ts +1 -0
- package/src/testing/vitest.ts +74 -7
- package/src/thenable-status.ts +0 -11
- package/src/types/handler-context.ts +12 -1
- package/src/types/loader-types.ts +4 -3
- package/src/types/route-entry.ts +2 -1
- package/src/urls/include-helper.ts +38 -11
- package/src/urls/path-helper-types.ts +4 -2
- package/src/vite/discovery/client-urls-projection.ts +205 -16
- package/src/vite/discovery/discover-routers.ts +2 -2
- package/src/vite/discovery/prerender-collection.ts +2 -6
- package/src/vite/discovery/state.ts +26 -0
- package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
- package/src/vite/plugins/expose-internal-ids.ts +46 -40
- package/src/vite/plugins/hoist/source.ts +8 -7
- package/src/vite/plugins/transition-when-hoist.ts +1 -0
- package/src/vite/plugins/version-plugin.ts +7 -0
- package/src/vite/plugins/version.d.ts +7 -0
- package/src/vite/plugins/virtual-entries.ts +20 -0
- package/src/vite/rango.ts +4 -1
- package/src/vite/router-discovery.ts +39 -7
- package/src/vite/utils/prerender-utils.ts +17 -0
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ux-architecture
|
|
3
|
+
description: Architect a Rango app so navigation is instant and stable — when a server urls() page or a clientUrls() group wins, where layouts, loaders, Suspense boundaries, revalidate() rules, prefetch, transition(), intercept() modals and actions go, with worked recipes (shop, dashboard tabs, settings forms, search filters, a modal over a list) and the anti-patterns behind remounts, skeleton flashes and slow clicks. Use when designing or reviewing a Rango app's routing, layout, loading or navigation UX, choosing between urls() and clientUrls(), placing a loader or a boundary, writing revalidate() rules, or debugging a remount, a re-shown fallback or a click that waits.
|
|
4
|
+
argument-hint: "[shop|dashboard|settings|search|modal]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Navigation UX architecture with urls() and clientUrls()
|
|
8
|
+
|
|
9
|
+
The primitive skills tell you what each helper does. This one tells you where
|
|
10
|
+
each piece goes so a click shows the next page at once, nothing on screen
|
|
11
|
+
remounts, and no skeleton flashes over content that was already there. Read
|
|
12
|
+
the contract first, then the decision tree, then the recipe closest to your
|
|
13
|
+
app.
|
|
14
|
+
|
|
15
|
+
## Not this skill if…
|
|
16
|
+
|
|
17
|
+
- You want to lay out the `urls()` files for an app type (a SaaS dashboard, a
|
|
18
|
+
multi-app host, a static site): that is the **app-structure** skill. This
|
|
19
|
+
skill only sketches the routes each UX recipe needs.
|
|
20
|
+
- You want a primitive's full API: `/client-urls`, `/loader`, `/layout`,
|
|
21
|
+
`/route`, `/links`, `/intercept`, `/view-transitions`, `/server-actions`,
|
|
22
|
+
`/hooks`. Section 6 maps each topic to its skill.
|
|
23
|
+
|
|
24
|
+
## 1. The UX contract
|
|
25
|
+
|
|
26
|
+
Four promises. Rango keeps its half; the app keeps the other half by where it
|
|
27
|
+
puts things.
|
|
28
|
+
|
|
29
|
+
| Promise | What Rango does | What your app must do |
|
|
30
|
+
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
31
|
+
| **A prefetched click shows the next page, or its skeleton, at the click.** | A click that adopts a complete prefetch commits from it with no request. Inside a `clientUrls()` group a click to another route of the group renders the destination in the browser before the server answers, prefetched or not. The router adds no wait of its own. | Let the links people click prefetch. Keep what a prefetch runs cheap (`prefetch: false` on expensive loaders). Give each slow read a boundary, so the destination can show before that read's data arrives. |
|
|
32
|
+
| **Deferred parts reveal by React's rules.** | Each loader streams on its own and each read gets one stable promise. The router never steers React's scheduling. | Place a `<Suspense>` wherever the page may show in parts. Expect React's timing: a fallback that appeared stays at least 300 ms (section 5). |
|
|
33
|
+
| **Nothing that stays on screen remounts.** | A layout stays mounted across navigations between its routes. A group's route segment is keyed by the group, so param and search changes reconcile. A search-only navigation reconciles everywhere. An action's revalidation renders into the mounted tree, so React state survives. | Put shared chrome in a layout, not in a route component. Never key a boundary or a wrapper by the URL. On a server route whose param changes should keep the page (`/product/a` to `/product/b`), add `transition()`. |
|
|
34
|
+
| **A fallback shows only for loading that is new.** | A navigation that mounts nothing new commits in a transition, so React keeps revealed content until the new data lands. A loader whose `revalidate()` holds is not re-run, so nothing waits for it. | Write `revalidate()` rules that hold what the navigation did not change. Return transient loader failures as data instead of throwing them. |
|
|
35
|
+
|
|
36
|
+
What is a bug and what is the design: a fallback for a boundary the navigation
|
|
37
|
+
mounts, or for data the navigation fetches, is the design. A remount of
|
|
38
|
+
something that stays on screen, or a fallback over content whose data was in
|
|
39
|
+
hand, is a router bug: report it.
|
|
40
|
+
|
|
41
|
+
**Judge it in a build, at a realistic distance.**
|
|
42
|
+
|
|
43
|
+
- Dev loads client references per render, so a boundary new to the page can
|
|
44
|
+
show its fallback for 300 ms in dev where a build shows none
|
|
45
|
+
(`docs/internal/suspense-contract.md`, "Dev is not production"). Dev also
|
|
46
|
+
prefetches nothing by default (`defaultPrefetch` is `"none"` in development
|
|
47
|
+
and `"viewport"` in production, `src/router/prefetch-default.ts`).
|
|
48
|
+
- Add a 300 to 500 ms delay to every server response (a middleware that
|
|
49
|
+
sleeps) before you compare clicks. At a local 10 ms, React's 300 ms throttle
|
|
50
|
+
is the slowest thing on the page and decides everything you see.
|
|
51
|
+
|
|
52
|
+
## 2. The decision tree
|
|
53
|
+
|
|
54
|
+
### 2.1 A server urls() page or a clientUrls() group?
|
|
55
|
+
|
|
56
|
+
| Pick | When | What you get |
|
|
57
|
+
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
58
|
+
| A server `urls()` route with a handler | Pages people mostly reach by a document load (marketing, docs, articles, landing pages). Pages built from server components you don't want to ship as client code. Routes that need `cache()`, `Prerender()`, `parallel()`, `middleware()` or an `errorBoundary()` on the route itself. | The handler renders on the server, and `cache()` or a prerender can store its output. A click with no prefetch waits for the server before the page changes; a prefetched click does not. |
|
|
59
|
+
| A `clientUrls()` group | An area where people move a lot inside one layout: dashboards, admin, settings, tabs, filters, master and detail. Pages that are client components anyway. | The browser matches the group, so a click to another route of the group renders the destination at once, before the server answers. Param and search changes on one route reconcile and hold. There are no handlers: every read is a `useLoader` under its own boundary. A document load is still rendered on the server, and `loader(Def, { ssr: false })` puts a loader's data and handles in that HTML. |
|
|
60
|
+
|
|
61
|
+
Two limits of a group decide some cases:
|
|
62
|
+
|
|
63
|
+
- The destination renders before middleware has run. Its loader reads wait
|
|
64
|
+
for the server, but everything outside a loader read shows at the click. If
|
|
65
|
+
showing a page's shell before authorization is a problem, define that route
|
|
66
|
+
in `urls()` (`docs/client-urls.md`, "Security boundary").
|
|
67
|
+
- Inside the group there is no `middleware()`, `include()`, `parallel()`,
|
|
68
|
+
`cache()`, `errorBoundary()` or `notFoundBoundary()`. They go on the server
|
|
69
|
+
tree around the `include()`. For caching, put `"use cache"` on the function
|
|
70
|
+
a loader calls.
|
|
71
|
+
|
|
72
|
+
### 2.2 Combine them: server structure around the group
|
|
73
|
+
|
|
74
|
+
The common shape: a server layout holds what every area shares, middleware
|
|
75
|
+
guards, an error boundary catches what loaders throw, and each area is a
|
|
76
|
+
group mounted with `include()`.
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
// src/urls.tsx (server)
|
|
80
|
+
import { urls } from "@rangojs/router";
|
|
81
|
+
import appUrls from "./app/app.client.js"; // clientUrls() group
|
|
82
|
+
import settingsUrls from "./settings/settings.client.js"; // clientUrls() group
|
|
83
|
+
import { AppShell, AppError, HomePage } from "./shell.js";
|
|
84
|
+
import { SessionLoader } from "./session.loader.js";
|
|
85
|
+
import * as AccountActions from "./account.actions.js";
|
|
86
|
+
import { requireSession } from "./middleware.js";
|
|
87
|
+
|
|
88
|
+
export const urlpatterns = urls(
|
|
89
|
+
({
|
|
90
|
+
path,
|
|
91
|
+
layout,
|
|
92
|
+
include,
|
|
93
|
+
loader,
|
|
94
|
+
middleware,
|
|
95
|
+
errorBoundary,
|
|
96
|
+
revalidate,
|
|
97
|
+
}) => [
|
|
98
|
+
layout(AppShell, () => [
|
|
99
|
+
middleware(requireSession),
|
|
100
|
+
errorBoundary(<AppError />),
|
|
101
|
+
loader(SessionLoader, () => [
|
|
102
|
+
revalidate(({ isAction }) =>
|
|
103
|
+
isAction() ? isAction(AccountActions) : undefined,
|
|
104
|
+
),
|
|
105
|
+
]),
|
|
106
|
+
path("/", HomePage, { name: "home" }),
|
|
107
|
+
include("/app", appUrls, { name: "app" }),
|
|
108
|
+
include("/settings", settingsUrls, { name: "settings" }),
|
|
109
|
+
]),
|
|
110
|
+
],
|
|
111
|
+
);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
On a navigation inside one group:
|
|
115
|
+
|
|
116
|
+
| Piece | Runs? |
|
|
117
|
+
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
118
|
+
| `requireSession` | Yes, on every request, a tab switch whose loaders all hold included. |
|
|
119
|
+
| The `AppShell` handler | No. Its segment is held. A `revalidate()` on the layout opts it in, and its handler time then adds to every navigation it runs on. |
|
|
120
|
+
| `SessionLoader` | Held on navigation (the default for a loader above the route). After an action, only an account action re-runs it. |
|
|
121
|
+
| The group's loaders | Each by its own `revalidate()`, run on the server. |
|
|
122
|
+
|
|
123
|
+
Pinned by `tests/vite-rsc-demo/e2e/client-shop-guards.test.ts` (dev and
|
|
124
|
+
production): the middleware stamps every request while the outer layout's run
|
|
125
|
+
count holds.
|
|
126
|
+
|
|
127
|
+
**Navigating between two groups** (`/app/...` to `/settings/...`) is a server
|
|
128
|
+
navigation: each group matches only below its own mount
|
|
129
|
+
(`src/client-urls/navigation.ts`, `stripMountPrefix`), so nothing renders
|
|
130
|
+
before the response, and the destination group mounts with its layouts (a
|
|
131
|
+
group's segment key is its mount, `src/segment-system.tsx`). The server shell
|
|
132
|
+
above both and its loaders stay as they are. That is the expected shape, not a
|
|
133
|
+
remount bug. Two consequences:
|
|
134
|
+
|
|
135
|
+
- Chrome that must survive moving between areas (the account menu, a cart
|
|
136
|
+
badge, global navigation) goes in the server layout above the groups.
|
|
137
|
+
Chrome that belongs to one area (its section nav, its tab bar) goes in that
|
|
138
|
+
group's `layout()`.
|
|
139
|
+
- Prefetch the links that cross areas: the adopted prefetch makes that click
|
|
140
|
+
commit at once like any prefetched click.
|
|
141
|
+
|
|
142
|
+
A large app follows the same shape: server layouts for the first page and
|
|
143
|
+
the chrome, loaders for user and app data, and one group per area (app, auth,
|
|
144
|
+
onboarding, settings). How to cut an app into areas and files is the
|
|
145
|
+
app-structure skill.
|
|
146
|
+
|
|
147
|
+
### 2.3 Where a layout goes
|
|
148
|
+
|
|
149
|
+
| | A server `layout()` around the `include()` | A client `layout()` inside the group |
|
|
150
|
+
| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
151
|
+
| Stays mounted across | Every navigation below it, between groups too. | Navigations between the routes it wraps (#1124). The route component inside it mounts anew on a route change; it is a different page. |
|
|
152
|
+
| Its own code re-runs | Never on a navigation or an action by default. `revalidate()` opts in; the handler time adds to each such navigation. | It is a client component: it re-renders with React state, never re-fetched. |
|
|
153
|
+
| Can hold | Server components, `parallel()` slots, `middleware()`, `errorBoundary()`, `intercept()`. | Client components only. `useOutlet()` gives `content` and `pending`, which is true from the click to the commit of a navigation inside the group. |
|
|
154
|
+
| A `loader()` on it | Held on navigation, re-run after every action unless its `revalidate()` narrows that. | The same as on a server layout: held on navigation, whatever route of the layout or params change, and re-run after every action unless its `revalidate()` narrows that. |
|
|
155
|
+
| Use it for | App-wide chrome and its data: session, account menu, cart badge, notifications. | Area chrome: section nav, tabs, a busy indicator from `pending`. |
|
|
156
|
+
|
|
157
|
+
A server layout handler that reads request data (a cookie, the session,
|
|
158
|
+
`ctx.params`) renders it once and keeps it. Data that must refresh goes in a
|
|
159
|
+
loader read by a client component (section 2.4), or in middleware `ctx.set()`
|
|
160
|
+
consumed by a loader. See `/rango`, "Passing data down the tree".
|
|
161
|
+
|
|
162
|
+
### 2.4 Where loaders go, and their revalidate() rules
|
|
163
|
+
|
|
164
|
+
Put a loader on the segment whose lifetime matches the data, and read it with
|
|
165
|
+
`useLoader` in the component that shows it, under its own boundary. Never
|
|
166
|
+
fetch in a handler with `await ctx.use(Loader)` when a read site can do it:
|
|
167
|
+
the handler then waits for the data, a route without `loading()` waits with
|
|
168
|
+
it, and under `cache()` or a `ppr` shell the value bakes in.
|
|
169
|
+
|
|
170
|
+
Defaults, when no `revalidate()` decides (`/loader`, "`revalidate()` return
|
|
171
|
+
shapes"):
|
|
172
|
+
|
|
173
|
+
| Declared on | On a navigation | After an action |
|
|
174
|
+
| ---------------------------------------- | ------------------------------------ | --------------- |
|
|
175
|
+
| A server `path()`, or anything inside it | Re-runs when params or search change | Re-runs |
|
|
176
|
+
| A server `layout()` above the route | Held | Re-runs |
|
|
177
|
+
| A `clientUrls()` `path()` | Re-runs when params or search change | Re-runs |
|
|
178
|
+
| A `clientUrls()` `layout()` | Held | Re-runs |
|
|
179
|
+
| Any segment the page does not hold yet | Runs; a predicate cannot stop it | Runs |
|
|
180
|
+
|
|
181
|
+
The four predicates most apps need (all run on the server, in `clientUrls()`
|
|
182
|
+
too):
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import type { RevalidateParams } from "@rangojs/router";
|
|
186
|
+
import * as CartActions from "./cart.actions.js";
|
|
187
|
+
import { saveProfile } from "./profile.actions.js";
|
|
188
|
+
|
|
189
|
+
// 1. Data keyed by a param: re-run when the param changes; hold on tab and
|
|
190
|
+
// search changes and on actions.
|
|
191
|
+
export function bySlug({
|
|
192
|
+
isAction,
|
|
193
|
+
currentParams,
|
|
194
|
+
nextParams,
|
|
195
|
+
defaultShouldRevalidate,
|
|
196
|
+
}: RevalidateParams): boolean {
|
|
197
|
+
if (isAction()) return false;
|
|
198
|
+
return currentParams.slug !== nextParams.slug
|
|
199
|
+
? defaultShouldRevalidate
|
|
200
|
+
: false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2. Data driven by the query string: re-run when the search changes.
|
|
204
|
+
export function bySearch({
|
|
205
|
+
isAction,
|
|
206
|
+
currentUrl,
|
|
207
|
+
nextUrl,
|
|
208
|
+
defaultShouldRevalidate,
|
|
209
|
+
}: RevalidateParams): boolean {
|
|
210
|
+
return !isAction() && currentUrl.search !== nextUrl.search
|
|
211
|
+
? defaultShouldRevalidate
|
|
212
|
+
: false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 3. Data an action changes: after an action, only these actions re-run it;
|
|
216
|
+
// on a navigation, keep the default.
|
|
217
|
+
export const byCartAction = ({ isAction }: RevalidateParams) =>
|
|
218
|
+
isAction() ? isAction(CartActions) : undefined;
|
|
219
|
+
|
|
220
|
+
// 4. Data that depends on what the action returned.
|
|
221
|
+
export const bySavedProfile = ({ isAction, actionResult }: RevalidateParams) =>
|
|
222
|
+
isAction(saveProfile) ? actionResult?.ok === true : undefined;
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`bySlug` is the vite-rsc-demo's `productData`
|
|
226
|
+
(`tests/vite-rsc-demo/src/urls/client-shop.predicates.ts`). Rules that keep
|
|
227
|
+
them right:
|
|
228
|
+
|
|
229
|
+
- A boolean is a hard answer, `undefined` defers to the default. Pick by the
|
|
230
|
+
default you are changing: the ternary narrows a loader that re-runs after
|
|
231
|
+
every action; `isAction(X) || undefined` adds a re-run where the default
|
|
232
|
+
skips (a server layout's own handler).
|
|
233
|
+
- `revalidate(true)` and `revalidate(false)` are shorthands for `() => true`
|
|
234
|
+
and `() => false`.
|
|
235
|
+
- Never `revalidate(false)` on data keyed by a param: product to product then
|
|
236
|
+
keeps showing the first product. Never `revalidate(true)` as a habit: it re-runs on
|
|
237
|
+
every navigation and action, and where its read is on screen the navigation
|
|
238
|
+
either waits for it (a held commit) or shows that read's fallback again (an
|
|
239
|
+
urgent one).
|
|
240
|
+
- Chrome data that does not depend on the URL, on a group `layout()`: the
|
|
241
|
+
default already holds it on navigation, and a bare `isAction(CartActions)`
|
|
242
|
+
narrows the actions that re-run it to the cart's. It is a hard `false` on
|
|
243
|
+
navigation, which is what a cart badge wants, and `true` after a cart action. This is the
|
|
244
|
+
vite-rsc-demo's `CartLoader` rule.
|
|
245
|
+
- A predicate in a `clientUrls()` module may reference imports and globals
|
|
246
|
+
only. A predicate shared by several loaders goes in a module without
|
|
247
|
+
`"use client"` and is imported (`/client-urls`, "Revalidation runs on the
|
|
248
|
+
server").
|
|
249
|
+
- A prefetch runs the predicates too, so a loader that holds costs nothing in
|
|
250
|
+
the prefetch of a tab switch.
|
|
251
|
+
- Unit-test them with `runRevalidate` (`/testing`).
|
|
252
|
+
|
|
253
|
+
### 2.5 loading() or an inline Suspense
|
|
254
|
+
|
|
255
|
+
Default to an inline `<Suspense>` directly above each `useLoader` read that
|
|
256
|
+
can be slow. What the URL already decides (a heading, the tabs, a breadcrumb
|
|
257
|
+
from params) paints at once, and each read fills on its own. A route-level
|
|
258
|
+
`loading()` is one skeleton for the whole route content.
|
|
259
|
+
|
|
260
|
+
| Use | When |
|
|
261
|
+
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
262
|
+
| Inline `<Suspense>` per read | The default. Several reads with different latencies; a page whose frame is meaningful before the data. |
|
|
263
|
+
| `loading()` on a server route | The route's handler is async (with `loading()` it streams instead of holding the response; `docs/internal/execution-model.md`, "Limitation: a Response from an async handler under `loading()` is not a redirect"), or the content means nothing until its data lands. Keep inline boundaries inside it. |
|
|
264
|
+
| `loading()` on a group route | The route-level boundary around the destination the browser renders at the click. Same-route navigations hold whatever you declare. |
|
|
265
|
+
| No boundary at all | A read with no boundary suspends to the nearest one above: on a server route that is a layout's `loading()` or `<Suspense>`, so a larger area shows a fallback; with none, nothing of the new page shows until the data lands. In a group the destination is not shown at the click; React keeps the old page until the server's commit. |
|
|
266
|
+
|
|
267
|
+
Which fallbacks show: a boundary the navigation mounts shows its fallback; a
|
|
268
|
+
boundary already revealed holds its content on a navigation that mounts
|
|
269
|
+
nothing new; a fallback that appeared stays at least 300 ms.
|
|
270
|
+
|
|
271
|
+
`useLoader().isLoading` is true on a reader that still shows the old data
|
|
272
|
+
while a held navigation fetches the new one. Dim it instead of adding a
|
|
273
|
+
boundary (`/hooks`, `data.md`).
|
|
274
|
+
|
|
275
|
+
### 2.6 Prefetch
|
|
276
|
+
|
|
277
|
+
A prefetch is a navigation sent early: the server renders the destination's
|
|
278
|
+
handlers and loaders for it, from the page you are on (`/links`, "What a
|
|
279
|
+
prefetch renders"). A click that adopts a complete prefetch commits with no
|
|
280
|
+
request. Entries live 300 s by default (`prefetchCacheTTL`) in the router's
|
|
281
|
+
memory only (prefetch responses carry no `Cache-Control`), and an action
|
|
282
|
+
drops them unless it calls `keepClientCache()`. A prefetch whose page failed
|
|
283
|
+
is never used: a 500 or 404, and a 200 whose render reported an error (a
|
|
284
|
+
loader that threw, a `loading()` handler that fell back to its
|
|
285
|
+
`errorBoundary()`, an async component that threw). The click sends its own
|
|
286
|
+
request, like a plain click.
|
|
287
|
+
|
|
288
|
+
The rule: **prefetch broadly, and flag what is expensive.**
|
|
289
|
+
|
|
290
|
+
- Links people click: leave the production default `viewport`, or set it
|
|
291
|
+
(`createRouter({ defaultPrefetch })`, `<Link prefetch>`). Long lists of
|
|
292
|
+
secondary links (related items): `hover`. Unbounded combinations (filter
|
|
293
|
+
chips, arbitrary query pages) and rarely followed links to expensive pages:
|
|
294
|
+
`none`, or `data-prefetch-scope="none"` on their container.
|
|
295
|
+
- `loader(Def, { prefetch: false })` for a loader that is expensive (a slow
|
|
296
|
+
query, a third-party API, a large payload), personalised (every visible link
|
|
297
|
+
runs it again for that user), or rarely needed (a reviews tab). Cheap
|
|
298
|
+
loaders stay prefetched, so their data is there at the click.
|
|
299
|
+
- `prefetch: false` only skips a loader that is new to the page. A loader
|
|
300
|
+
already on screen whose `revalidate()` re-runs it for the destination is
|
|
301
|
+
prefetched with it, so the click does not wait on a fill for data the page
|
|
302
|
+
is already showing.
|
|
303
|
+
- The trade-off, measured on a `clientUrls()` tab in a consumer app (dev, a
|
|
304
|
+
loaded machine): with the tab's loader flagged, a hover then a click 150 ms
|
|
305
|
+
later showed the content in 843 to 918 ms; with the tab prefetched whole, in
|
|
306
|
+
77 to 157 ms. The flagged click pays the fill request and then React's
|
|
307
|
+
300 ms on the tab's fallback.
|
|
308
|
+
- A click that adopts a prefetch with a flagged loader shows the page at once
|
|
309
|
+
and sends one fill request for the flagged loaders. Read a flagged loader
|
|
310
|
+
under its own `<Suspense>`: with no boundary, React keeps the page being
|
|
311
|
+
left until the fill answers.
|
|
312
|
+
- On a server route, `loading(fallback, { prefetch: false })` keeps the whole
|
|
313
|
+
entry behind that fallback out of the prefetch (`/route`, "`loading()`
|
|
314
|
+
options"). A group's `loading()` takes no options.
|
|
315
|
+
- When the target's `revalidate()` reads `currentUrl` or `currentParams`, give
|
|
316
|
+
the Link `prefetchKey=":source"`: the default prefetch entry is shared by
|
|
317
|
+
every source page (`src/browser/react/Link.tsx`, `prefetchKey`).
|
|
318
|
+
|
|
319
|
+
### 2.7 transition() and view transitions
|
|
320
|
+
|
|
321
|
+
- On a server route, `transition()` makes its navigations commit in a
|
|
322
|
+
transition and drops params from the route's React key, so `/product/a` to
|
|
323
|
+
`/product/b` keeps the page on screen until the new data lands instead of
|
|
324
|
+
remounting it under a skeleton (`src/segment-system.tsx`,
|
|
325
|
+
`inTransitionScope`). `transition({ viewTransition: false })` gives the hold
|
|
326
|
+
without the animation.
|
|
327
|
+
- In a group the hold needs no `transition()`: group segments are keyed by the
|
|
328
|
+
group. There `transition()` adds the view-transition animation only, and
|
|
329
|
+
goes inside a `path()`.
|
|
330
|
+
- Put `transition()` at one level, not on every layout, and mount the
|
|
331
|
+
`<ParallelOutlet>` of a modal slot in a layout, not in a route component
|
|
332
|
+
that has a transition (`/view-transitions`, "Recommendations").
|
|
333
|
+
- A `transition({ when })` that returns `false` commits that navigation
|
|
334
|
+
urgently: no hold, the skeleton streams. Keep it to the navigations it names.
|
|
335
|
+
- `<Link revalidate={false}>` or `setSearchParams(next, { revalidate: false })`
|
|
336
|
+
changes the URL and the hooks at once with no server request, for view state
|
|
337
|
+
that needs no data (grid or list, an open panel). The pathname must stay the
|
|
338
|
+
same.
|
|
339
|
+
|
|
340
|
+
### 2.8 intercept() modals and parallel() slots
|
|
341
|
+
|
|
342
|
+
- A modal over a list is an `intercept()` declared on the layout that wraps
|
|
343
|
+
both the list and the target route, rendered by
|
|
344
|
+
`<ParallelOutlet name="@modal" />` in that layout. A click opens the modal
|
|
345
|
+
and the list stays mounted under it; a document load of the URL renders the
|
|
346
|
+
full page; Back closes the modal. Recipe 3.5.
|
|
347
|
+
- An intercept's loaders each take their own `revalidate()`, and a flagged
|
|
348
|
+
`prefetch: false` loader opens the modal at once with its `loading()`. Its
|
|
349
|
+
errors render the declaring layout's `errorBoundary()`.
|
|
350
|
+
- Inside a group, `intercept()` takes a dot-local named target in the same
|
|
351
|
+
definition and `loader()`/`loading()` only, and only navigations from inside
|
|
352
|
+
the group open it.
|
|
353
|
+
- `parallel()` is server-only. A slot on a standalone layout is kept across
|
|
354
|
+
navigations and actions unless its `revalidate()` opts in; a slot with its
|
|
355
|
+
own `loading()` streams on its own. Use it for chrome that fills
|
|
356
|
+
separately, such as a sidebar (`/parallel`).
|
|
357
|
+
|
|
358
|
+
### 2.9 Actions
|
|
359
|
+
|
|
360
|
+
- What re-runs by default: the route, its loaders and what is inside its
|
|
361
|
+
`path()`; loaders on layouts above. A layout's own handler and its parallel
|
|
362
|
+
slots do not. The server decides per loader with each `revalidate()`
|
|
363
|
+
(`actionResult` and `formData` included), in the action's own response.
|
|
364
|
+
- The commit renders into the mounted page: nothing remounts, a controlled
|
|
365
|
+
input keeps what was typed, and `useActionState` keeps its state.
|
|
366
|
+
- Return validation failures from the action as state for `useActionState`;
|
|
367
|
+
throw only for the unexpected (`/server-actions`, "Error Handling").
|
|
368
|
+
- Return a loader's transient failure (a flaky upstream, a rate limit) as
|
|
369
|
+
data, `{ ok: false, reason }`, and render it where the data goes. A throw on
|
|
370
|
+
the re-run after an action rejects a direct `await action()` call, or hands
|
|
371
|
+
the error to the nearest error boundary, which replaces the page and its
|
|
372
|
+
form (`/client-urls`, "A loader that fails on its rerun").
|
|
373
|
+
- `keepClientCache()` in an action that changed nothing keeps the prefetches
|
|
374
|
+
and the history cache.
|
|
375
|
+
- `useOptimistic` gives feedback before the action answers.
|
|
376
|
+
|
|
377
|
+
### 2.10 Updates you did not make: three channels
|
|
378
|
+
|
|
379
|
+
| The change | The tool |
|
|
380
|
+
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
381
|
+
| Your own mutation | A server action plus `revalidate()` rules (`isAction(ref)`, `actionResult`). The server re-runs what the action touched in the same response; nothing else to call. |
|
|
382
|
+
| Out of band: a push, polling, a REST call, a login | `useLoader(L).load()` (also `refetch`): a plain `load()` of a route-registered loader updates every reader of it. `refreshGroup` on reads plus `useRefreshLoaders()` refreshes several loaders at once. Both need a fetchable loader, `createLoader(fn, true)`; a plain one answers 403. `router.refresh()` re-renders the current route's server data and keeps client state. `invalidateClientCache()` marks the history cache stale and drops prefetches when the change makes them wrong. |
|
|
383
|
+
| Shared server caches (other users' data) | Tags: `cache({ tags })` or `cacheTag()`, then `updateTag()` in the action (awaitable, the action's response reads fresh) or `revalidateTag()` from a webhook. `revalidate()` is not cache invalidation (`/cache-guide`). |
|
|
384
|
+
|
|
385
|
+
### 2.11 Handles: titles and breadcrumbs
|
|
386
|
+
|
|
387
|
+
- Push from the loader that has the data: `ctx.use(Meta)({ title })`,
|
|
388
|
+
`ctx.use(Breadcrumbs)({ label, href })`. This is how a group, which has no
|
|
389
|
+
handlers, sets them (`/breadcrumbs`, "Pushing from Loaders").
|
|
390
|
+
- Read them in chrome with `useHandle(Breadcrumbs)`, in a layout that stays.
|
|
391
|
+
- `loader(Def, { ssr: false })` puts its pushes in the document's HTML (a
|
|
392
|
+
title for search engines, a real 404 from `notFound()`). The document waits
|
|
393
|
+
for that loader; flag only the one that needs it.
|
|
394
|
+
- A loader an action skips keeps its title and crumbs
|
|
395
|
+
(`tests/shared-e2e/src/held-handles-scenario.ts`), and a link back to a page
|
|
396
|
+
you visited keeps that page's (#1115).
|
|
397
|
+
|
|
398
|
+
### 2.12 Links by name, never by string
|
|
399
|
+
|
|
400
|
+
A hardcoded `"/dashboard/traffic"` keeps working until someone moves the
|
|
401
|
+
`include()` mount or renames the path, then breaks without a type error. A
|
|
402
|
+
name follows the move. Inside a module, link by local name, never by its
|
|
403
|
+
mount: `ctx.reverse(".x")` on the server and `useReverse(routes)` on the
|
|
404
|
+
client resolve against wherever the module is mounted and fill the mount's
|
|
405
|
+
params (`:locale`, `:accountId`, `:projectId`) from the current URL. The
|
|
406
|
+
module never knows its prefix, so it can be mounted twice. Pick the tool by
|
|
407
|
+
where the link is built (`/links`):
|
|
408
|
+
|
|
409
|
+
| Where the link is built | Use |
|
|
410
|
+
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
411
|
+
| Server handler, layout, loader | `ctx.reverse(".local")` or `ctx.reverse("global.name")`; pass the string to client components as a prop. `scopedReverse` types the local names. |
|
|
412
|
+
| Client component linking inside one module or `clientUrls()` group | `useReverse(routes)` with `routes` from that module's generated `.gen.ts`. Mount-aware, fills params from `useParams()`, and only that module's names reach the client bundle. |
|
|
413
|
+
| Client component that needs a global name | Prefer a prop from the server. `router.named-routes.gen.ts` works with `useReverse` as an escape hatch, but is correct only at the root mount and pulls every route name into the client bundle. |
|
|
414
|
+
| A static path with no route behind it | `href("/about")`. |
|
|
415
|
+
|
|
416
|
+
The maps exist only if they are generated and committed: the Vite plugin
|
|
417
|
+
writes them on `vite dev` and `vite build`, `rango generate src` writes them
|
|
418
|
+
without Vite (`/typesafety`, "Generated Files and CLI Setup"). A group module
|
|
419
|
+
`dashboard.client.tsx` gets `dashboard.client.gen.ts` beside it. The recipes
|
|
420
|
+
below use these.
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
// one shopUrls module at include("/:locale", ...) and include("/outlet", ...)
|
|
424
|
+
ctx.reverse(".product", { slug: "b" }); // /en/product/b, /outlet/product/b
|
|
425
|
+
ctx.reverse(".product", { slug: "b", locale: "de" }); // switch language
|
|
426
|
+
// /:locale? only: { locale: undefined } drops it; on a required /:locale it
|
|
427
|
+
// throws `Missing param "locale"`
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
- Explicit params override the filled ones: `reverse(".issues", { projectId })`.
|
|
431
|
+
- Middleware `ctx.reverse` takes global names only and fills nothing, so
|
|
432
|
+
pass every param; a missing one throws `Missing param`.
|
|
433
|
+
`getRequestContext().reverse` in route middleware does fill (rebound after
|
|
434
|
+
the match); in global middleware only after `next()`.
|
|
435
|
+
- An action's `getRequestContext().reverse("global.name")` fills from the page
|
|
436
|
+
URL: `throw redirect(getRequestContext().reverse("account.settings"))`.
|
|
437
|
+
- Wrap each module's map once in a userland file
|
|
438
|
+
(`export const useAccountReverse = () => useReverse(accountRoutes)` in
|
|
439
|
+
`src/lib/links.ts`) and call that hook in components, under that module's
|
|
440
|
+
own mount (`/links`, "One hook per module").
|
|
441
|
+
- Not these: `useParams().accountId` in a template string, `ctx.params`
|
|
442
|
+
passed down as props to build links, or a "current account" context.
|
|
443
|
+
`useHref()` and relative `router.push()` fill the mount's params too, but
|
|
444
|
+
take unchecked path strings: prefer the typed `useReverse` map.
|
|
445
|
+
- `useReverse` with another module's map, or `useReverse(NamedRoutes)` under
|
|
446
|
+
a mount, gives a wrong URL with no error. Build cross-module links on the
|
|
447
|
+
server and pass them as props or loader data (dc-t57).
|
|
448
|
+
- A server layout stays held across navigations, its own `:projectId`
|
|
449
|
+
changing included: partial rendering by design. Links it rendered keep the
|
|
450
|
+
old params. Put param-dependent nav links in a client component with
|
|
451
|
+
`useReverse(routes)` (it re-renders from `useParams()`), or give the layout
|
|
452
|
+
a `revalidate()` that compares the param.
|
|
453
|
+
- A layout resolves `.name` in the module that declares it, on every page
|
|
454
|
+
below it: a parent module's layout links within its own module (`/links`,
|
|
455
|
+
"Local names").
|
|
456
|
+
|
|
457
|
+
Build cross-module links, and links a client component cannot reach with
|
|
458
|
+
its own map, on the server: `ctx.reverse` in the handler (passed as a
|
|
459
|
+
prop) or in a loader (returned as data, with a `revalidate()` when they
|
|
460
|
+
follow a param). The full example is in `/links`, "Client components:
|
|
461
|
+
receive URLs as props".
|
|
462
|
+
|
|
463
|
+
### 2.13 Rewrite or redirect
|
|
464
|
+
|
|
465
|
+
Both send the visitor somewhere other than the matched route. Pick on what
|
|
466
|
+
the visitor should see.
|
|
467
|
+
|
|
468
|
+
| | `throw redirect(...)` | `throw rewrite(...)` |
|
|
469
|
+
| --------------- | --------------------- | ----------------------------------------------- |
|
|
470
|
+
| Cost | An extra round trip | None: the same response renders the other route |
|
|
471
|
+
| Address bar | Changes to the target | Keeps the visitor's URL |
|
|
472
|
+
| Reload or share | Lands on the target | Hits the original URL, which rewrites again |
|
|
473
|
+
|
|
474
|
+
- Rewrite fits a maintenance page, a flag or A/B variant, a legacy or alias
|
|
475
|
+
URL served by the canonical route, a geo split. Redirect fits a login gate
|
|
476
|
+
or a moved page, where the new address should show and be bookmarked.
|
|
477
|
+
- Middleware only: a loader or handler cannot rewrite. A global rewrite moves
|
|
478
|
+
a server action or form post to the target, where the action runs, so a
|
|
479
|
+
maintenance lock answers mutations with a `Response` (a 503) or throws
|
|
480
|
+
`rewrite("maintenance").disallowMutations()`, as in `/middleware`,
|
|
481
|
+
"Rewrite".
|
|
482
|
+
- The target can be a path or a route name (`rewrite("maintenance")`,
|
|
483
|
+
`rewrite("product", { slug })`). Global middleware and the target's own
|
|
484
|
+
route middleware run again for the target, so guards still apply.
|
|
485
|
+
- A loop answers 508 with `RewriteLoopError`: past the hop limit, or a rewrite
|
|
486
|
+
to the URL the request is already on (semantic-matrix row RW4; RW3 pins the
|
|
487
|
+
mutation's move and the `.disallowMutations()` error).
|
|
488
|
+
|
|
489
|
+
## 3. Recipes
|
|
490
|
+
|
|
491
|
+
Each recipe is a sketch: the placements are the point. Imports of your own
|
|
492
|
+
modules are left out where they are obvious.
|
|
493
|
+
|
|
494
|
+
### 3.1 A shop: catalog, product, cart badge, add to cart
|
|
495
|
+
|
|
496
|
+
Reference implementation: `tests/vite-rsc-demo/src/urls/client-shop.client.tsx`
|
|
497
|
+
(the group), `client-shop-guard.tsx` (the server layout and middleware around
|
|
498
|
+
it), `client-shop.predicates.ts`, and the mount in `src/urls/index.tsx`, in
|
|
499
|
+
the router repository (not shipped in the package). Its e2e suites
|
|
500
|
+
`tests/vite-rsc-demo/e2e/client-shop-*.test.ts` pin each behaviour in dev and
|
|
501
|
+
production. The sketch below differs in one place: the cart loader sits on the
|
|
502
|
+
server layout, so the badge also survives entering and leaving the group. The
|
|
503
|
+
demo keeps it on the group `layout()`, which holds it across the group's
|
|
504
|
+
routes as well.
|
|
505
|
+
|
|
506
|
+
```tsx
|
|
507
|
+
// src/urls.tsx (server)
|
|
508
|
+
import { urls } from "@rangojs/router";
|
|
509
|
+
import shopUrls from "./shop/shop.client.js";
|
|
510
|
+
import { CartLoader } from "./shop/loaders.js";
|
|
511
|
+
import * as CartActions from "./shop/cart.actions.js";
|
|
512
|
+
|
|
513
|
+
export const urlpatterns = urls(
|
|
514
|
+
({ layout, include, loader, revalidate, errorBoundary }) => [
|
|
515
|
+
layout(
|
|
516
|
+
(ctx) => <ShopShell shopHref={ctx.reverse("shop.index")} />,
|
|
517
|
+
() => [
|
|
518
|
+
errorBoundary(<ShopError />),
|
|
519
|
+
loader(CartLoader, () => [
|
|
520
|
+
revalidate(({ isAction }) =>
|
|
521
|
+
isAction() ? isAction(CartActions) : undefined,
|
|
522
|
+
),
|
|
523
|
+
]),
|
|
524
|
+
include("/shop", shopUrls, { name: "shop" }),
|
|
525
|
+
],
|
|
526
|
+
),
|
|
527
|
+
],
|
|
528
|
+
);
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
```tsx
|
|
532
|
+
// src/shop/shell.tsx (server component) and cart-badge.tsx ("use client")
|
|
533
|
+
function ShopShell({ shopHref }: { shopHref: string }) {
|
|
534
|
+
return (
|
|
535
|
+
<>
|
|
536
|
+
<header>
|
|
537
|
+
<Link to={shopHref}>Shop</Link>
|
|
538
|
+
<Suspense fallback={<span>Cart</span>}>
|
|
539
|
+
<CartBadge />
|
|
540
|
+
</Suspense>
|
|
541
|
+
</header>
|
|
542
|
+
<Outlet />
|
|
543
|
+
</>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function CartBadge() {
|
|
548
|
+
const { data } = useLoader(CartLoader);
|
|
549
|
+
return <span>Cart ({data.count})</span>;
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
```tsx
|
|
554
|
+
// src/shop/shop.client.tsx
|
|
555
|
+
"use client";
|
|
556
|
+
import { Suspense } from "react";
|
|
557
|
+
import { clientUrls, Link, useLoader, useOutlet } from "@rangojs/router/client";
|
|
558
|
+
import { bySlug, bySearch } from "./predicates.js"; // no "use client" there
|
|
559
|
+
|
|
560
|
+
function ShopLayout() {
|
|
561
|
+
const { content, pending } = useOutlet();
|
|
562
|
+
return <main aria-busy={pending}>{content}</main>;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function ProductPage() {
|
|
566
|
+
return (
|
|
567
|
+
<>
|
|
568
|
+
<Tabs /> {/* from the URL: paints at the click */}
|
|
569
|
+
<Suspense fallback={<InfoSkeleton />}>
|
|
570
|
+
<ProductInfo /> {/* useLoader(ProductLoader) + <AddToCartForm> */}
|
|
571
|
+
</Suspense>
|
|
572
|
+
<Suspense fallback={<RelatedSkeleton />}>
|
|
573
|
+
<Related /> {/* <Link prefetch="hover"> per item */}
|
|
574
|
+
</Suspense>
|
|
575
|
+
<Suspense fallback={<ReviewsSkeleton />}>
|
|
576
|
+
<Reviews />
|
|
577
|
+
</Suspense>
|
|
578
|
+
</>
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export default clientUrls(({ path, layout, loader, revalidate }) => [
|
|
583
|
+
layout(ShopLayout, () => [
|
|
584
|
+
path("/", Catalog, { name: "index" }, () => [
|
|
585
|
+
loader(ProductsLoader, () => [revalidate(bySearch)]),
|
|
586
|
+
]),
|
|
587
|
+
path("/product/:slug", ProductPage, { name: "product" }, () => [
|
|
588
|
+
loader(ProductLoader, { ssr: false }, () => [revalidate(bySlug)]),
|
|
589
|
+
loader(RelatedLoader, () => [revalidate(bySlug)]),
|
|
590
|
+
loader(ReviewsLoader, { prefetch: false }, () => [revalidate(bySlug)]),
|
|
591
|
+
]),
|
|
592
|
+
]),
|
|
593
|
+
]);
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Why each placement:
|
|
597
|
+
|
|
598
|
+
| Placement | Why |
|
|
599
|
+
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
600
|
+
| Catalog and product in one group | A card click renders the product page at the click; the filters and tabs are same-route navigations that hold. |
|
|
601
|
+
| `CartLoader` on the server layout, ternary predicate | Held on every navigation (a server layout loader's default), re-run only after a cart action. It survives entering and leaving the group. The badge updates in the add-to-cart response itself. |
|
|
602
|
+
| `ProductLoader` with `ssr: false` | The product's title and a real 404 for an unknown slug are in the document's HTML. Client navigations still stream it. |
|
|
603
|
+
| `bySlug` on the product loaders | Product to product re-runs them; a tab switch and an add to cart hold them. One action, three outcomes: the badge refreshes, product and related keep their data (`client-shop-cart.test.ts`). |
|
|
604
|
+
| `ReviewsLoader` flagged `prefetch: false` | Every visible card prefetches; reviews are the expensive, rarely read part. The click still shows the product at once and the reviews fill behind their own boundary. A prefetch from another product page (the related links) still runs it: only a segment new to the page is deferred. |
|
|
605
|
+
| One `<Suspense>` per section | A slow product loader fills its section without holding the related list, and the other way round (the demo's product loader takes 2 s). |
|
|
606
|
+
| Cards `prefetch="viewport"`, filters `prefetch="none"` | A visible card is likely to be clicked; filter combinations are unbounded (the demo's `FilterLink`). |
|
|
607
|
+
| `pending` from `useOutlet()` on the layout | The progress signal for a filter change, whose grid holds instead of flashing (`client-shop-filters.test.ts`). |
|
|
608
|
+
|
|
609
|
+
The add-to-cart form uses `useActionState` and an action that returns its
|
|
610
|
+
result; nothing else is needed for the badge.
|
|
611
|
+
|
|
612
|
+
### 3.2 Dashboard tabs
|
|
613
|
+
|
|
614
|
+
```tsx
|
|
615
|
+
// src/dashboard/dashboard.client.tsx, mounted under the app's server shell
|
|
616
|
+
"use client";
|
|
617
|
+
import {
|
|
618
|
+
clientUrls,
|
|
619
|
+
Link,
|
|
620
|
+
useOutlet,
|
|
621
|
+
useReverse,
|
|
622
|
+
} from "@rangojs/router/client";
|
|
623
|
+
import { bySearch } from "../predicates.js";
|
|
624
|
+
import { routes } from "./dashboard.client.gen.js"; // generated, committed
|
|
625
|
+
|
|
626
|
+
function DashboardLayout() {
|
|
627
|
+
const { content, pending } = useOutlet();
|
|
628
|
+
const reverse = useReverse(routes);
|
|
629
|
+
return (
|
|
630
|
+
<section aria-busy={pending}>
|
|
631
|
+
<nav>
|
|
632
|
+
<Link to={reverse("overview")}>Overview</Link>
|
|
633
|
+
<Link to={reverse("traffic")}>Traffic</Link>
|
|
634
|
+
<Link to={reverse("reports")}>Reports</Link>
|
|
635
|
+
</nav>
|
|
636
|
+
{content}
|
|
637
|
+
</section>
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export default clientUrls(({ path, layout, loader, revalidate }) => [
|
|
642
|
+
layout(DashboardLayout, () => [
|
|
643
|
+
path("/", Overview, { name: "overview" }, () => [loader(KpiLoader)]),
|
|
644
|
+
path(
|
|
645
|
+
"/traffic",
|
|
646
|
+
Traffic,
|
|
647
|
+
{ name: "traffic", search: { range: "string?" } },
|
|
648
|
+
() => [loader(TrafficLoader, () => [revalidate(bySearch)])],
|
|
649
|
+
),
|
|
650
|
+
path("/reports", Reports, { name: "reports" }, () => [
|
|
651
|
+
loader(ReportsLoader, { prefetch: false }),
|
|
652
|
+
]),
|
|
653
|
+
]),
|
|
654
|
+
]);
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
- **A tab with its own data is a route.** A click to another route of the
|
|
658
|
+
group renders it at the click, prefetched or not; the layout and its tab bar
|
|
659
|
+
keep their instance (#1124, pinned by "a layout() keeps its DOM across
|
|
660
|
+
navigations between its routes" in both apps' `e2e/client-urls.test.ts`).
|
|
661
|
+
- **A view option over the same data is a search param** (a date range, a
|
|
662
|
+
sort). Its navigation stays on the route, so the browser renders nothing
|
|
663
|
+
early (`src/client-urls/client-root.tsx`: a same-route intent from the
|
|
664
|
+
committed page never swaps); the page holds with `pending` until the server
|
|
665
|
+
answers, one round trip, unless the link was prefetched: then it commits at
|
|
666
|
+
the click. The prefetch is cheap when the loaders' rules hold them. Change the range with
|
|
667
|
+
`setSearchParams({ range }, { replace: true, scroll: false })`; a pure view
|
|
668
|
+
toggle takes `revalidate: false`.
|
|
669
|
+
- `ReportsLoader` is slow and rarely opened: flag it, read it under its own
|
|
670
|
+
`<Suspense>`. `KpiLoader` and `TrafficLoader` are cheap: leave them
|
|
671
|
+
prefetched so the tab is complete at the click.
|
|
672
|
+
- The org switcher, the user menu and their loaders go in the server layout
|
|
673
|
+
above the group (section 2.2): they stay when the user goes from the
|
|
674
|
+
dashboard to settings, which is a different group.
|
|
675
|
+
|
|
676
|
+
### 3.3 A settings area with forms
|
|
677
|
+
|
|
678
|
+
```tsx
|
|
679
|
+
// src/settings/settings.client.tsx
|
|
680
|
+
"use client";
|
|
681
|
+
import { clientUrls } from "@rangojs/router/client";
|
|
682
|
+
import { saveProfile } from "./profile.actions.js"; // "use server"
|
|
683
|
+
|
|
684
|
+
export default clientUrls(({ path, layout, loader, revalidate }) => [
|
|
685
|
+
layout(SettingsLayout, () => [
|
|
686
|
+
path("/profile", ProfilePage, { name: "profile" }, () => [
|
|
687
|
+
loader(ProfileLoader, () => [
|
|
688
|
+
revalidate(({ isAction, actionResult }) =>
|
|
689
|
+
isAction()
|
|
690
|
+
? isAction(saveProfile) && actionResult?.ok === true
|
|
691
|
+
: undefined,
|
|
692
|
+
),
|
|
693
|
+
]),
|
|
694
|
+
]),
|
|
695
|
+
path("/notifications", NotificationsPage, { name: "notifications" }, () => [
|
|
696
|
+
loader(NotificationPrefsLoader),
|
|
697
|
+
]),
|
|
698
|
+
path("/billing", BillingPage, { name: "billing" }, () => [
|
|
699
|
+
loader(InvoicesLoader, { prefetch: false }),
|
|
700
|
+
]),
|
|
701
|
+
]),
|
|
702
|
+
]);
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
```tsx
|
|
706
|
+
// the profile form
|
|
707
|
+
function ProfileForm() {
|
|
708
|
+
const { data } = useLoader(ProfileLoader);
|
|
709
|
+
const [state, formAction, isPending] = useActionState(saveProfile, null);
|
|
710
|
+
if (!data.ok) return <p role="status">{data.reason}</p>;
|
|
711
|
+
return (
|
|
712
|
+
<form action={formAction}>
|
|
713
|
+
<input name="name" defaultValue={data.profile.name} />
|
|
714
|
+
{state?.ok === false && <p role="alert">{state.error}</p>}
|
|
715
|
+
<button disabled={isPending}>Save</button>
|
|
716
|
+
</form>
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
- `saveProfile` returns `{ ok: true }` or `{ ok: false, error }`; it never
|
|
722
|
+
throws for bad input, so the page stays and shows the error. The predicate
|
|
723
|
+
re-runs `ProfileLoader` only when the save succeeded: a refused save re-runs
|
|
724
|
+
nothing.
|
|
725
|
+
- `ProfileLoader` returns `{ ok: false, reason }` when its upstream fails,
|
|
726
|
+
instead of throwing: the page and the form stay mounted and the next re-run
|
|
727
|
+
replaces the message.
|
|
728
|
+
- The commit after the action renders into the mounted page: nothing
|
|
729
|
+
remounts and React state survives. React itself resets a `<form action>`'s
|
|
730
|
+
uncontrolled fields once the action function finishes without throwing; to
|
|
731
|
+
keep a refused value in its field, make the input controlled or return the
|
|
732
|
+
submitted value in the action state.
|
|
733
|
+
- The header's name and avatar come from the server shell's `SessionLoader`
|
|
734
|
+
with `isAction() ? isAction(saveProfile) : undefined` (or the module's
|
|
735
|
+
actions), so a saved name shows in the header in the same response.
|
|
736
|
+
- With JavaScript off the form still posts: a no-JS form post renders every
|
|
737
|
+
loader, whatever the predicates say.
|
|
738
|
+
- Each settings page is a route of one group so the section nav stays and
|
|
739
|
+
page changes render at the click. Billing's invoices are expensive: flagged.
|
|
740
|
+
|
|
741
|
+
### 3.4 Search with filters
|
|
742
|
+
|
|
743
|
+
Search results are often a landing page from a link or a search engine, so
|
|
744
|
+
this one is a server route. The same holds in a group.
|
|
745
|
+
|
|
746
|
+
```tsx
|
|
747
|
+
// src/search/urls.tsx (server)
|
|
748
|
+
export const searchPatterns = urls(({ path, loader, revalidate }) => [
|
|
749
|
+
path(
|
|
750
|
+
"/",
|
|
751
|
+
SearchPage,
|
|
752
|
+
{
|
|
753
|
+
name: "results",
|
|
754
|
+
search: { q: "string?", category: "string?", page: "number?" },
|
|
755
|
+
},
|
|
756
|
+
() => [
|
|
757
|
+
loader(ResultsLoader), // the route default: re-run on every search change
|
|
758
|
+
loader(FacetsLoader, () => [
|
|
759
|
+
revalidate(
|
|
760
|
+
({ isAction, currentUrl, nextUrl, defaultShouldRevalidate }) => {
|
|
761
|
+
if (isAction()) return false;
|
|
762
|
+
const keys = ["q", "category"];
|
|
763
|
+
return keys.some(
|
|
764
|
+
(k) =>
|
|
765
|
+
currentUrl.searchParams.get(k) !== nextUrl.searchParams.get(k),
|
|
766
|
+
)
|
|
767
|
+
? defaultShouldRevalidate
|
|
768
|
+
: false;
|
|
769
|
+
},
|
|
770
|
+
),
|
|
771
|
+
]),
|
|
772
|
+
],
|
|
773
|
+
),
|
|
774
|
+
]);
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
```tsx
|
|
778
|
+
// SearchPage: a cheap, synchronous server handler
|
|
779
|
+
function SearchPage() {
|
|
780
|
+
return (
|
|
781
|
+
<>
|
|
782
|
+
<SearchBox /> {/* client: setSearchParams with replace, see below */}
|
|
783
|
+
<Suspense fallback={<FacetsSkeleton />}>
|
|
784
|
+
<Facets /> {/* <Link prefetch="none"> per filter chip */}
|
|
785
|
+
</Suspense>
|
|
786
|
+
<Suspense fallback={<ResultsSkeleton />}>
|
|
787
|
+
<Results /> {/* useLoader(ResultsLoader); dim on isLoading */}
|
|
788
|
+
</Suspense>
|
|
789
|
+
</>
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
- A search-only navigation mounts nothing new, so it commits in a transition
|
|
795
|
+
and the results already on screen stay until the new ones land, in a server
|
|
796
|
+
route as in a group (`src/browser/partial-update.ts`, `isSameStructureNav`).
|
|
797
|
+
Show progress with `useLoader(ResultsLoader).isLoading` on the results, or
|
|
798
|
+
`useNavigation()`'s `isStreaming`.
|
|
799
|
+
- The route handler re-runs on every search change (route default), so it
|
|
800
|
+
must stay cheap and must not await data. The data lives in loaders.
|
|
801
|
+
- Facets depend on the query and category, not on the page number: their
|
|
802
|
+
rule holds them when only `page` changes.
|
|
803
|
+
- Filter chips: `prefetch="none"` (unbounded). The next-page link can take
|
|
804
|
+
`prefetch="hover"`.
|
|
805
|
+
- Typing: the setter replaces the whole query string, so merge, and replace
|
|
806
|
+
the history entry instead of pushing one per keystroke (debounce it):
|
|
807
|
+
`setSearchParams((prev) => { prev.set("q", q); prev.delete("page"); return prev; }, { replace: true })`.
|
|
808
|
+
|
|
809
|
+
### 3.5 A modal over a list
|
|
810
|
+
|
|
811
|
+
```tsx
|
|
812
|
+
// src/photos/urls.tsx (server), mounted with include("/photos", photoPatterns, { name: "photos" })
|
|
813
|
+
export const photoPatterns = urls(
|
|
814
|
+
({ path, layout, intercept, loader, loading, errorBoundary, revalidate }) => [
|
|
815
|
+
layout(GalleryLayout, () => [
|
|
816
|
+
errorBoundary(<GalleryError />),
|
|
817
|
+
path("/", Gallery, { name: "gallery" }, () => [loader(PhotosLoader)]),
|
|
818
|
+
path("/:id", PhotoPage, { name: "photo" }, () => [loader(PhotoLoader)]),
|
|
819
|
+
intercept("@modal", ".photo", <PhotoModal />, () => [
|
|
820
|
+
layout(<ModalChrome />), // the overlay; closes with router.back()
|
|
821
|
+
loader(PhotoLoader, () => [revalidate(byId)]), // bySlug, on :id
|
|
822
|
+
loader(CommentsLoader, { prefetch: false }, () => [
|
|
823
|
+
revalidate(({ isAction }) =>
|
|
824
|
+
isAction() ? isAction(CommentActions) : undefined,
|
|
825
|
+
),
|
|
826
|
+
]),
|
|
827
|
+
loading(<ModalSkeleton />),
|
|
828
|
+
]),
|
|
829
|
+
]),
|
|
830
|
+
],
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
function GalleryLayout() {
|
|
834
|
+
return (
|
|
835
|
+
<>
|
|
836
|
+
<Outlet />
|
|
837
|
+
<ParallelOutlet name="@modal" />
|
|
838
|
+
</>
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
- The intercept is declared on the layout that wraps both routes, and the
|
|
844
|
+
slot is rendered by that layout, so the gallery stays mounted under the
|
|
845
|
+
modal and opening it fires no layout view transition.
|
|
846
|
+
- Grid thumbnails prefetch the modal from the gallery page (a target an
|
|
847
|
+
intercept claims is prefetched per source page). `CommentsLoader` is
|
|
848
|
+
flagged: the modal opens at the click with its skeleton and one fill brings
|
|
849
|
+
the comments.
|
|
850
|
+
- Posting a comment from the modal re-runs the modal's loaders whose rule
|
|
851
|
+
says so (`CommentsLoader`; `byId` holds the photo on actions), and the modal
|
|
852
|
+
keeps its React state.
|
|
853
|
+
- A document load of a photo URL renders `PhotoPage`, a shareable full page.
|
|
854
|
+
- Put an authorization guard in route `middleware()` on the target route or an
|
|
855
|
+
enclosing layout, or in the loaders. Route middleware runs for the modal on
|
|
856
|
+
every pass, a `cache()` hit and a `Prerender()` target included; `intercept()`
|
|
857
|
+
takes no `middleware()` of its own.
|
|
858
|
+
- Inside a `clientUrls()` group the same shape is
|
|
859
|
+
`intercept("@modal", ".photo", PhotoModal, () => [loader(PhotoLoader), loading(<ModalSkeleton />)])`,
|
|
860
|
+
with a named target in the same definition; only navigations from inside
|
|
861
|
+
the group open it.
|
|
862
|
+
|
|
863
|
+
## 4. Anti-patterns
|
|
864
|
+
|
|
865
|
+
Each entry: what the user sees, why, the fix.
|
|
866
|
+
|
|
867
|
+
1. **One big `<Suspense>` (or only a route `loading()`) around the page.** The
|
|
868
|
+
whole page is a skeleton until its slowest read lands; the heading and tabs
|
|
869
|
+
the URL already decided don't show. Fix: one boundary per read that can be
|
|
870
|
+
slow (2.5).
|
|
871
|
+
2. **A boundary or wrapper keyed per navigation** (`<Suspense key={pathname}>`,
|
|
872
|
+
`key={params.id}` on a wrapper). Every navigation remounts the subtree: its
|
|
873
|
+
fallback shows again, inputs, scroll and child state reset, images reload.
|
|
874
|
+
A changed key always remounts in React. Fix: no URL-derived keys; let the
|
|
875
|
+
router hold and dim with `isLoading`.
|
|
876
|
+
3. **A server layout that reads request data and is expected to refresh** (the
|
|
877
|
+
user's name from the session, a param-derived label). It stays as first
|
|
878
|
+
rendered: a login, an action or a param change does not update it. Fix: a
|
|
879
|
+
loader read by a client component in the layout, with a `revalidate()` rule;
|
|
880
|
+
or `revalidate()` on the layout, paying its handler time on every navigation
|
|
881
|
+
it re-runs on.
|
|
882
|
+
4. **`revalidate(true)` everywhere.** Every navigation and action re-runs
|
|
883
|
+
the loader, prefetches carry it, and where its read is on screen the
|
|
884
|
+
navigation waits for it or shows its fallback again. Fix: rules that name
|
|
885
|
+
what changes the data (2.4).
|
|
886
|
+
5. **`revalidate(false)` on data keyed by a param.** Product to product
|
|
887
|
+
keeps showing the first product. Fix: compare the identifying param.
|
|
888
|
+
6. **A loader that throws a transient failure.** After an action, a direct
|
|
889
|
+
`await action()` rejects although the action ran, or an error boundary
|
|
890
|
+
replaces the page and the user's form. Fix: return `{ ok: false, reason }`
|
|
891
|
+
and render it at the read (2.9).
|
|
892
|
+
7. **Reading a loader above the boundary meant to catch it.** The read
|
|
893
|
+
suspends to the next boundary up: a larger area shows a skeleton, or in a
|
|
894
|
+
group the destination is not shown at the click at all; an error escapes to
|
|
895
|
+
a higher boundary (the vite-rsc-demo's `SsrCategoryProbe` notes a 404 turned
|
|
896
|
+
into a 500 without its containment). Fix: call `useLoader` inside the
|
|
897
|
+
component the boundary wraps.
|
|
898
|
+
8. **Shared chrome in a route component.** It remounts on every navigation to
|
|
899
|
+
another route: its state resets and its own fallbacks show again. Fix: move
|
|
900
|
+
it into a `layout()` (a group `layout()` keeps its instance across its
|
|
901
|
+
routes; a server layout across everything below it).
|
|
902
|
+
9. **`useEffect` fetching.** A spinner after the page has shown, nothing
|
|
903
|
+
prefetched or server-rendered, no re-run after an action, races on fast
|
|
904
|
+
navigations. Fix: a loader read with `useLoader`; `useFetchLoader` for
|
|
905
|
+
on-demand reads (search-as-you-type, load more).
|
|
906
|
+
10. **A server route with a param and no `transition()`, expected to hold.**
|
|
907
|
+
`/product/a` to `/product/b` remounts the route under its skeleton (its key
|
|
908
|
+
carries the params). Fix: `transition()` (or
|
|
909
|
+
`transition({ viewTransition: false })`), or make it a group route.
|
|
910
|
+
11. **An area split into several groups, or a group per page.** Navigating
|
|
911
|
+
between groups is a server navigation: nothing shows before the response,
|
|
912
|
+
and the destination group's layout mounts. Fix: one group per area, with
|
|
913
|
+
the area's chrome in its `layout()` and app chrome on the server layout
|
|
914
|
+
above.
|
|
915
|
+
12. **An async handler that awaits data, on a route without `loading()`.** The
|
|
916
|
+
response waits for it; with `loading()` the whole route waits behind one
|
|
917
|
+
skeleton. Fix: move the data to a loader read under its own boundary.
|
|
918
|
+
13. **Prefetching everything with an expensive loader.** Forty visible cards
|
|
919
|
+
are forty renders of that loader for this user. Fix: `prefetch: false` on
|
|
920
|
+
the expensive loader, `prefetch="none"` on unbounded links (2.6).
|
|
921
|
+
14. **Navigation status readers inside the destination's `loading()`.** In a
|
|
922
|
+
group, a `useNavigation()` or `useLinkStatus()` reader inside content the
|
|
923
|
+
click swaps out unmounts at the click. Fix: put status readers in chrome
|
|
924
|
+
that stays (`/client-urls`, "Pitfalls").
|
|
925
|
+
15. **An effect keyed on a location-state object.** In a group the object is
|
|
926
|
+
the one the navigation passed until the commit, then the history entry's
|
|
927
|
+
copy: the effect runs twice. Fix: key it on a primitive field.
|
|
928
|
+
|
|
929
|
+
## 5. Known gaps
|
|
930
|
+
|
|
931
|
+
These are open. Each says what you see and what to do meanwhile.
|
|
932
|
+
|
|
933
|
+
- **React's fallback throttle.** React commits a Suspense retry no sooner than
|
|
934
|
+
300 ms after any boundary on the page last showed or hid its fallback. A
|
|
935
|
+
skeleton that appeared stays at least that long, and a read that resolves
|
|
936
|
+
after its parent was revealed shows its own fallback and starts the 300 ms
|
|
937
|
+
again. After a click that adopts a `prefetch: false` prefetch, a late read
|
|
938
|
+
can show a fallback the plain click never shows, and its content can come up
|
|
939
|
+
to 300 ms later than the plain click's (`/route`, "`loading()` options",
|
|
940
|
+
Limits; `docs/design/prefetch-false.md`, "When the holes resolve"). Rango
|
|
941
|
+
leaves this to React by design: no placeholders or re-anchored timers to
|
|
942
|
+
dodge it.
|
|
943
|
+
- **A view transition that changes nothing.** On a route with `loading()` and
|
|
944
|
+
`transition()` whose loaders take longer than about 320 ms, React's retry of
|
|
945
|
+
the boundary starts a view transition with the fallback still up, and the
|
|
946
|
+
content lands about 250 ms after its data (#1078). Escape hatches:
|
|
947
|
+
`/view-transitions`, "A view transition that changes nothing under
|
|
948
|
+
`loading()`".
|
|
949
|
+
- **A client component first used by the click.** In a build, a client
|
|
950
|
+
component at the top of a route's content whose module the document has not
|
|
951
|
+
used yet makes the Flight client wait for its `import()`, so the route's
|
|
952
|
+
`loading()` shows for 300 ms (`docs/internal/suspense-contract.md`, "What
|
|
953
|
+
the audit does not see").
|
|
954
|
+
- **A group navigation commits the destination 2 to 4 times.** The instance
|
|
955
|
+
is the same throughout (local state survives, mount effects run once), but
|
|
956
|
+
the one-commit target of #1079 is not met yet
|
|
957
|
+
(`docs/internal/suspense-contract.md`, rule 5).
|
|
958
|
+
- **The URL leads a held page.** `location` and the address bar move when the
|
|
959
|
+
response arrives; where React then holds the page being left, they are ahead
|
|
960
|
+
of the screen for that wait (#1046, `/hooks`, `navigation.md`).
|
|
961
|
+
|
|
962
|
+
## 6. Where the details live
|
|
963
|
+
|
|
964
|
+
| Topic | Skill |
|
|
965
|
+
| ------------------------------------------------------------------ | ---------------------------------------- |
|
|
966
|
+
| How to lay out `urls()` files and areas for an app type | app-structure |
|
|
967
|
+
| `clientUrls()` surface, hooks inside a group, action failure table | `/client-urls` |
|
|
968
|
+
| Loader options, `revalidate()` return shapes, `ssr` and `prefetch` | `/loader` |
|
|
969
|
+
| Layout forms, outlets, layout revalidation, contracts | `/layout` |
|
|
970
|
+
| `path()`, `loading()` options, handler data ownership | `/route` |
|
|
971
|
+
| Link prefetch strategies, `data-prefetch-scope`, URL generation | `/links` |
|
|
972
|
+
| Middleware order, `throw rewrite()`, redirects | `/middleware` |
|
|
973
|
+
| Modals | `/intercept` |
|
|
974
|
+
| Slots | `/parallel` |
|
|
975
|
+
| `transition()`, view transitions and their costs | `/view-transitions` |
|
|
976
|
+
| Actions, `useActionState`, `keepClientCache()` | `/server-actions` |
|
|
977
|
+
| `useLoader`, `load()`, `refreshGroup`, `useNavigation` | `/hooks` |
|
|
978
|
+
| Titles and breadcrumbs | `/breadcrumbs` |
|
|
979
|
+
| Cache tags and invalidation | `/cache-guide`, `/caching`, `/use-cache` |
|
|
980
|
+
| `runRevalidate`, `serveShellRequest`, dev and production e2e | `/testing` |
|