@rangojs/router 0.32.0 → 0.33.1

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.
Files changed (181) hide show
  1. package/README.md +3 -2
  2. package/dist/bin/browser-7KIJ5HSV.js +15 -0
  3. package/dist/bin/chunk-3B7CKBRO.js +463 -0
  4. package/dist/bin/chunk-524VACCG.js +71 -0
  5. package/dist/bin/chunk-LD7UBHT4.js +196 -0
  6. package/dist/bin/chunk-TO6CRWLN.js +1429 -0
  7. package/dist/bin/chunk-TTXC2U3G.js +2477 -0
  8. package/dist/bin/client-urls-projection-DQLWPDAT.js +28 -0
  9. package/dist/bin/forward-user-plugins-URBZKJP4.js +62 -0
  10. package/dist/bin/rango.js +42 -10173
  11. package/dist/bin/runtime-EPUDEHO4.js +596 -0
  12. package/dist/bin/runtime-discovery-3HH6BP3K.js +352 -0
  13. package/dist/bin/state-ACD2MGKJ.js +16 -0
  14. package/dist/bin/temp-rsc-server-JALTIWLT.js +3190 -0
  15. package/dist/types/bin/rango.d.ts +1 -0
  16. package/dist/types/browser/action-fence.d.ts +20 -0
  17. package/dist/types/browser/cookie-name.d.ts +51 -10
  18. package/dist/types/browser/invalidate-client-cache.d.ts +8 -6
  19. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  20. package/dist/types/browser/navigation-client.d.ts +19 -0
  21. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  22. package/dist/types/browser/navigation-store.d.ts +2 -2
  23. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  24. package/dist/types/browser/partial-update.d.ts +13 -0
  25. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  26. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  27. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  28. package/dist/types/browser/rango-state.d.ts +34 -5
  29. package/dist/types/browser/react/Link.d.ts +15 -6
  30. package/dist/types/browser/types.d.ts +25 -0
  31. package/dist/types/build/generate-route-types.d.ts +1 -1
  32. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  33. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  34. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  35. package/dist/types/cache/cache-scope.d.ts +4 -2
  36. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  37. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  38. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  39. package/dist/types/cache/segment-codec.d.ts +2 -2
  40. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  41. package/dist/types/cache/types.d.ts +12 -7
  42. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  43. package/dist/types/client-urls/types.d.ts +7 -5
  44. package/dist/types/prefetch-markers.d.ts +9 -0
  45. package/dist/types/prefetch-source-scope.d.ts +66 -0
  46. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  47. package/dist/types/router/intercept-resolution.d.ts +25 -11
  48. package/dist/types/router/match-context.d.ts +1 -0
  49. package/dist/types/router/match-handlers.d.ts +1 -1
  50. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  51. package/dist/types/router/router-context.d.ts +1 -1
  52. package/dist/types/router/router-options.d.ts +6 -3
  53. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  54. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  55. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  56. package/dist/types/router/source-reads.d.ts +53 -0
  57. package/dist/types/router/types.d.ts +1 -1
  58. package/dist/types/rsc/helpers.d.ts +5 -2
  59. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  60. package/dist/types/rsc/request-stages.d.ts +2 -0
  61. package/dist/types/rsc/shell-serve.d.ts +6 -1
  62. package/dist/types/server/context.d.ts +23 -6
  63. package/dist/types/server/cookie-store.d.ts +15 -1
  64. package/dist/types/server/document-location-state.d.ts +5 -2
  65. package/dist/types/server/handle-store.d.ts +2 -0
  66. package/dist/types/server/request-context.d.ts +43 -7
  67. package/dist/types/testing/e2e/server.d.ts +16 -1
  68. package/dist/types/testing/render-route.d.ts +66 -2
  69. package/dist/types/testing/serve-shell-request.d.ts +6 -3
  70. package/dist/types/types/handler-context.d.ts +7 -5
  71. package/dist/types/types/loader-types.d.ts +7 -4
  72. package/dist/types/types/segments.d.ts +15 -4
  73. package/dist/types/urls/path-helper-types.d.ts +7 -5
  74. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  75. package/dist/vite/index.js +449 -189
  76. package/package.json +2 -2
  77. package/skills/app-structure/SKILL.md +81 -0
  78. package/skills/app-structure/e-commerce.md +10 -9
  79. package/skills/app-structure/large-app.md +18 -2
  80. package/skills/caching/SKILL.md +10 -5
  81. package/skills/client-urls/SKILL.md +29 -11
  82. package/skills/comparison/references/framework-comparison.md +13 -8
  83. package/skills/document-cache/SKILL.md +8 -0
  84. package/skills/hooks/navigation.md +8 -8
  85. package/skills/hooks/outlets.md +4 -0
  86. package/skills/hooks/state.md +24 -8
  87. package/skills/intercept/SKILL.md +33 -12
  88. package/skills/layout/SKILL.md +60 -0
  89. package/skills/links/SKILL.md +101 -4
  90. package/skills/loader/SKILL.md +49 -22
  91. package/skills/migrate-nextjs/SKILL.md +8 -2
  92. package/skills/migrate-react-router/data-and-actions.md +5 -0
  93. package/skills/ppr/SKILL.md +26 -5
  94. package/skills/prerender/SKILL.md +4 -1
  95. package/skills/route/SKILL.md +6 -6
  96. package/skills/router-setup/SKILL.md +2 -1
  97. package/skills/server-actions/SKILL.md +41 -8
  98. package/skills/testing/client-components.md +29 -13
  99. package/skills/testing/serve-shell-request.md +7 -7
  100. package/skills/testing/server-actions.md +8 -0
  101. package/skills/ux-architecture/SKILL.md +70 -19
  102. package/src/bin/rango.ts +39 -13
  103. package/src/browser/action-fence.ts +72 -0
  104. package/src/browser/cookie-name.ts +82 -14
  105. package/src/browser/invalidate-client-cache.ts +9 -7
  106. package/src/browser/navigation-bridge.ts +164 -32
  107. package/src/browser/navigation-client.ts +95 -20
  108. package/src/browser/navigation-store-handle.ts +67 -5
  109. package/src/browser/navigation-store.ts +86 -17
  110. package/src/browser/navigation-transaction.ts +0 -6
  111. package/src/browser/partial-update.ts +31 -12
  112. package/src/browser/prefetch/cache.ts +188 -11
  113. package/src/browser/prefetch/fetch.ts +211 -51
  114. package/src/browser/prefetch/queue.ts +3 -2
  115. package/src/browser/rango-state.ts +50 -8
  116. package/src/browser/react/Link.tsx +20 -9
  117. package/src/browser/react/use-navigation.ts +1 -1
  118. package/src/browser/rsc-router.tsx +8 -1
  119. package/src/browser/server-action-bridge.ts +16 -11
  120. package/src/browser/types.ts +25 -0
  121. package/src/build/generate-route-types.ts +1 -0
  122. package/src/build/route-types/include-resolution.ts +40 -3
  123. package/src/build/route-types/router-processing.ts +170 -25
  124. package/src/build/route-types/scan-filter.ts +1 -0
  125. package/src/cache/cache-key-utils.ts +12 -8
  126. package/src/cache/cache-runtime.ts +5 -1
  127. package/src/cache/cache-scope.ts +28 -7
  128. package/src/cache/cf/cf-cache-types.ts +9 -5
  129. package/src/cache/document-cache-policy.ts +27 -0
  130. package/src/cache/document-cache.ts +5 -0
  131. package/src/cache/memory-segment-store.ts +3 -2
  132. package/src/cache/segment-codec.ts +2 -2
  133. package/src/cache/shell-snapshot.ts +1 -1
  134. package/src/cache/types.ts +12 -7
  135. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  136. package/src/client-urls/types.ts +7 -5
  137. package/src/prefetch-markers.ts +10 -0
  138. package/src/prefetch-source-scope.ts +137 -0
  139. package/src/route-definition/helpers-types.ts +8 -5
  140. package/src/router/intercept-resolution.ts +94 -50
  141. package/src/router/loader-resolution.ts +8 -6
  142. package/src/router/match-api.ts +33 -29
  143. package/src/router/match-context.ts +5 -0
  144. package/src/router/match-handlers.ts +2 -1
  145. package/src/router/match-middleware/cache-lookup.ts +21 -13
  146. package/src/router/match-result.ts +39 -87
  147. package/src/router/navigation-snapshot.ts +18 -3
  148. package/src/router/prefetch-cache-ttl.ts +15 -3
  149. package/src/router/revalidation.ts +53 -23
  150. package/src/router/router-context.ts +2 -1
  151. package/src/router/router-options.ts +6 -3
  152. package/src/router/segment-resolution/loader-cache.ts +21 -3
  153. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  154. package/src/router/segment-resolution/revalidation.ts +40 -12
  155. package/src/router/source-reads.ts +122 -0
  156. package/src/router/types.ts +2 -1
  157. package/src/router.ts +11 -12
  158. package/src/rsc/helpers.ts +8 -3
  159. package/src/rsc/prefetch-render-error.ts +7 -2
  160. package/src/rsc/progressive-enhancement.ts +3 -2
  161. package/src/rsc/request-stages.ts +59 -0
  162. package/src/rsc/rsc-rendering.ts +49 -33
  163. package/src/rsc/server-action.ts +2 -1
  164. package/src/rsc/shell-capture.ts +4 -5
  165. package/src/rsc/shell-serve.ts +12 -1
  166. package/src/segment-system.tsx +1 -1
  167. package/src/server/context.ts +37 -6
  168. package/src/server/cookie-store.ts +22 -1
  169. package/src/server/document-location-state.ts +23 -2
  170. package/src/server/handle-store.ts +2 -0
  171. package/src/server/request-context.ts +113 -28
  172. package/src/testing/dispatch.ts +3 -0
  173. package/src/testing/e2e/fixture.ts +4 -12
  174. package/src/testing/e2e/server.ts +91 -4
  175. package/src/testing/render-route.tsx +269 -4
  176. package/src/testing/serve-shell-request.ts +6 -3
  177. package/src/types/handler-context.ts +7 -5
  178. package/src/types/loader-types.ts +7 -4
  179. package/src/types/segments.ts +15 -4
  180. package/src/urls/path-helper-types.ts +7 -5
  181. package/src/vite/temp-rsc-server.ts +2 -2
@@ -20,13 +20,13 @@ A `Prerender` route is served as in production: from the artifact `router.matchF
20
20
 
21
21
  ### Options — `ServeShellRequestOptions`
22
22
 
23
- | Field | Type | Meaning |
24
- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
- | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
- | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
- | `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean; boundary?: string }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends it as a `<Link>` prefetch (`X-Rango-Prefetch`), so `prefetch: false` work is deferred. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. `stale: true` sends a stale request (`_rsc_stale`), as the browser sends it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`), segments take an action's revalidation defaults, and it renders live, as an action does: neither the route cache (`cache()`) nor the document cache serves or stores it, and a `ppr` route's `replayStatus` is `{ outcome: "BYPASS", reason: "stale" }`. `boundary` names the error segment of a thrown action's boundary the page at `from` shows (`_rsc_boundary`), as the background revalidation an earlier action owes that page sends it, with `stale: true` and no `segments`: the response renders every segment above the boundary and nothing at or below it, claims the boundary in `matched` and sends no segment for it. The server reads it only beside `stale`. |
29
- | `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
23
+ | Field | Type | Meaning |
24
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
+ | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
+ | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
+ | `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean; boundary?: string }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends the speculative kind a viewport or render `<Link>` prefetch sends (`X-Rango-Prefetch: 1`), so `prefetch: false` work is deferred; add `headers: { "X-Rango-Prefetch": "intent" }` to send a hover's or `prefetch()`'s intent prefetch instead, which defers nothing. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. `stale: true` sends a stale request (`_rsc_stale`), as the browser sends it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`), segments take an action's revalidation defaults, and it renders live, as an action does: neither the route cache (`cache()`) nor the document cache serves or stores it, and a `ppr` route's `replayStatus` is `{ outcome: "BYPASS", reason: "stale" }`. `boundary` names the error segment of a thrown action's boundary the page at `from` shows (`_rsc_boundary`), as the background revalidation an earlier action owes that page sends it, with `stale: true` and no `segments`: the response renders every segment above the boundary and nothing at or below it, claims the boundary in `matched` and sends no segment for it. The server reads it only beside `stale`. |
29
+ | `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
30
30
 
31
31
  ### Returns — `ServeShellRequestResult`
32
32
 
@@ -53,6 +53,14 @@
53
53
  | `stateCookieName` | `string` | The resolved rango state cookie name this run seeded (default `rango-state_router_0`). Assert an `invalidateClientCache()` rotation against it without recomputing. |
54
54
  | `locationState` | `Record<string, unknown>` | The flash set via `ctx.setLocationState()` / `redirect({ state })`, as the flat `{ key: value }` the client reads. |
55
55
 
56
+ `runInRequestContext` runs your function, not the router's action handling,
57
+ so its `response` carries no write bump: the router sets that around every
58
+ action it runs (the state cookie's `{version}:{timestamp}:{write}`, write part
59
+ bumped unless the action called `keepClientCache()`). To assert it, send the
60
+ action through the router with `serveShellRequest(router, url, { action })`
61
+ (or `{ form }` with `createActionForm` for a post before hydration) and read
62
+ the state `Set-Cookie` on `result.response`.
63
+
56
64
  Low-level building blocks (all from `@rangojs/router/testing`):
57
65
 
58
66
  - `createTestRequestContext(opts)` builds the context WITHOUT entering it and returns `{ ctx, request, url, variables, stateCookieName }`. Its `ctx.reverse` accepts any name from your `routeMap`.
@@ -249,6 +249,13 @@ them right:
249
249
  server").
250
250
  - A prefetch runs the predicates too, so a loader that holds costs nothing in
251
251
  the prefetch of a tab switch.
252
+ - A predicate that reads `currentParams` or `fromRouteName` and keeps its
253
+ segment shares that prefetch only between the pages with equal values of
254
+ what it read; one that reads `currentUrl` makes it page-only (2.6).
255
+ `bySlug` keeps only on an unchanged slug, so a product's tabs share their
256
+ prefetches; `bySearch` reads the URL, so the cost is the links between one
257
+ query's tabs. A layout rule comparing a param most pages share (a locale,
258
+ an account) groups by that param and keeps the sharing.
252
259
  - Unit-test them with `runRevalidate` (`/testing`).
253
260
 
254
261
  ### 2.5 loading() or an inline Suspense
@@ -279,8 +286,14 @@ A prefetch is a navigation sent early: the server renders the destination's
279
286
  handlers and loaders for it, from the page you are on (`/links`, "What a
280
287
  prefetch renders"). A click that adopts a complete prefetch commits with no
281
288
  request. Entries live 300 s by default (`prefetchCacheTTL`) in the router's
282
- memory only (prefetch responses carry no `Cache-Control`), and an action
283
- drops them unless it calls `keepClientCache()`. A prefetch whose page failed
289
+ memory. An action marks them stale unless it calls `keepClientCache()`: a
290
+ click on one shows it at once, then revalidates the page in the background
291
+ (`/server-actions`, "Client Cache After an Action"). The browser HTTP cache
292
+ keeps a prefetch for as long only when nothing in it is live: a document cache
293
+ HIT, or an answer whose every sent segment came from a `cache()` record or a
294
+ Prerender entry and that runs no loader
295
+ (`Cache-Control: private, max-age=<prefetchCacheTTL>`). Any other prefetch
296
+ carries no `Cache-Control` of the router's. A prefetch whose page failed
284
297
  is never used: a 500 or 404, and a 200 whose render reported an error (a
285
298
  loader that threw, a `loading()` handler that fell back to its
286
299
  `errorBoundary()`, an async component that threw). The click sends its own
@@ -301,11 +314,16 @@ The rule: **prefetch broadly, and flag what is expensive.**
301
314
  already on screen whose `revalidate()` re-runs it for the destination is
302
315
  prefetched with it, so the click does not wait on a fill for data the page
303
316
  is already showing.
304
- - The trade-off, measured on a `clientUrls()` tab in a consumer app (dev, a
305
- loaded machine): with the tab's loader flagged, a hover then a click 150 ms
317
+ - `prefetch: false` applies to speculative prefetches: viewport and render.
318
+ A hover, or `useRouter().prefetch()`, is intent to click and runs the
319
+ loader, so its click needs no fill. A hover over a link a viewport prefetch
320
+ already answered sends nothing, and that click still fills.
321
+ - The trade-off of a deferring prefetch, measured on a `clientUrls()` tab in
322
+ a consumer app (dev, a loaded machine), before a hover's prefetch ran
323
+ flagged loaders: with the tab's loader flagged, a hover then a click 150 ms
306
324
  later showed the content in 843 to 918 ms; with the tab prefetched whole, in
307
325
  77 to 157 ms. The flagged click pays the fill request and then React's
308
- 300 ms on the tab's fallback.
326
+ 300 ms on the tab's fallback. A viewport-prefetched link still pays it.
309
327
  - A click that adopts a prefetch with a flagged loader shows the page at once
310
328
  and sends one fill request for the flagged loaders. Read a flagged loader
311
329
  under its own `<Suspense>`: with no boundary, React keeps the page being
@@ -313,12 +331,33 @@ The rule: **prefetch broadly, and flag what is expensive.**
313
331
  - On a server route, `loading(fallback, { prefetch: false })` keeps the whole
314
332
  entry behind that fallback out of the prefetch (`/route`, "`loading()`
315
333
  options"). A group's `loading()` takes no options.
316
- - The default prefetch entry is shared by every source page. When the
317
- target's `revalidate()` reads `currentParams`, `currentUrl` or
318
- `fromRouteName` and keeps the segment, the router scopes that prefetch to
319
- the page that sent it (`x-rsc-prefetch-scope: source`): another page
320
- prefetches its own. `prefetchKey=":source"` on the Link scopes every
321
- prefetch of it (`src/browser/react/Link.tsx`, `prefetchKey`).
334
+ - One prefetch of a page serves every page that links to it, so a paginated list
335
+ or a grid does not prefetch each link again after every navigation. The server
336
+ makes an answer page-only (`x-rsc-prefetch-scope: source`) when sharing it
337
+ would be wrong: an intercept targets the route or the page sending it, the
338
+ answer defers a `prefetch: false` unit the page does not show yet, a
339
+ `revalidate()` on the target read `currentUrl` and kept its segment, a rule
340
+ on the route (or inside its `path()`) read the page being left and kept the
341
+ segment through the route's default, or a kept answer sends no segment at
342
+ all. Only a click from that exact URL, query
343
+ and hash included, uses a page-only answer, so every page prefetches it
344
+ again (`/links`, "Shared prefetches and `prefetchKey`").
345
+ - A `revalidate()` that read `currentParams` or `fromRouteName` and kept its
346
+ segment, or a prerendered target reached from a page with the same route
347
+ params, groups the answer (`x-rsc-prefetch-scope: params; <label>`): the
348
+ pages with equal values of what was read share it. A layout rule comparing
349
+ `currentParams.locale` shares each prefetch between the pages of one locale.
350
+ Spreading `currentParams` reads every param; read the ones the rule needs by
351
+ name. A page's own block (a category hero) still goes in its route or in a
352
+ layout inside its `path()`, and links that follow a mount param come from
353
+ `useReverse`; neither needs a rule (`/app-structure`, "Prefetches are
354
+ shared").
355
+ - `prefetchKey=":source"` on a Link, or
356
+ `router.prefetch(url, { key: ":source" })`, makes that link's prefetches
357
+ page-only. Use it when the target's answer depends on the page through
358
+ something the server does not track: `context` in a `revalidate()`, or a
359
+ `Referer` read. It costs the same requests, so set it per link, not
360
+ app-wide.
322
361
 
323
362
  ### 2.7 transition() and view transitions
324
363
 
@@ -375,16 +414,16 @@ The rule: **prefetch broadly, and flag what is expensive.**
375
414
  the error to the nearest error boundary, which replaces the page and its
376
415
  form (`/client-urls`, "A loader that fails on its rerun").
377
416
  - `keepClientCache()` in an action that changed nothing keeps the prefetches
378
- and the history cache.
417
+ and the history cache fresh: no revalidation after a click or on Back.
379
418
  - `useOptimistic` gives feedback before the action answers.
380
419
 
381
420
  ### 2.10 Updates you did not make: three channels
382
421
 
383
- | The change | The tool |
384
- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
385
- | 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. |
386
- | 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. |
387
- | 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`). |
422
+ | The change | The tool |
423
+ | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
424
+ | 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. |
425
+ | 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, drops prefetches and rotates the state when the change makes them wrong. |
426
+ | 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`). |
388
427
 
389
428
  ### 2.11 Handles: titles and breadcrumbs
390
429
 
@@ -448,7 +487,7 @@ ctx.reverse(".product", { slug: "b", locale: "de" }); // switch language
448
487
  take unchecked path strings: prefer the typed `useReverse` map.
449
488
  - `useReverse` with another module's map, or `useReverse(NamedRoutes)` under
450
489
  a mount, gives a wrong URL with no error. Build cross-module links on the
451
- server and pass them as props or loader data (dc-t57).
490
+ server and pass them as props or loader data.
452
491
  - A server layout stays held across navigations, its own `:projectId`
453
492
  changing included: partial rendering by design. Links it rendered keep the
454
493
  old params. Put param-dependent nav links in a client component with
@@ -902,7 +941,9 @@ Each entry: what the user sees, why, the fix.
902
941
  8. **Shared chrome in a route component.** It remounts on every navigation to
903
942
  another route: its state resets and its own fallbacks show again. Fix: move
904
943
  it into a `layout()` (a group `layout()` keeps its instance across its
905
- routes; a server layout across everything below it).
944
+ routes; a server layout across everything below it). A layout that moves
945
+ its `<Outlet />` between branches remounts the children the same way; see
946
+ `/layout`, "Moving the Outlet remounts its children".
906
947
  9. **`useEffect` fetching.** A spinner after the page has shown, nothing
907
948
  prefetched or server-rendered, no re-run after an action, races on fast
908
949
  navigations. Fix: a loader read with `useLoader`; `useFetchLoader` for
@@ -929,6 +970,16 @@ Each entry: what the user sees, why, the fix.
929
970
  15. **An effect keyed on a location-state object.** In a group the object is
930
971
  the one the navigation passed until the commit, then the history entry's
931
972
  copy: the effect runs twice. Fix: key it on a primitive field.
973
+ 16. **A page's own block in a shared layout, kept by a rule that reads the
974
+ page being left** (a category hero in a template over the product pages,
975
+ re-rendered when `currentParams.category` changes). Nothing looks wrong,
976
+ but every prefetch under that layout is shared only between the pages of
977
+ one category (or with the same params, off the category routes): the
978
+ other pages prefetch their links again and the server renders them
979
+ again. Fix: put
980
+ the block in its route, or in a layout inside its `path()`; it
981
+ re-renders on its own params with no rule (`/app-structure`, "Prefetches
982
+ are shared").
932
983
 
933
984
  ## 5. Known gaps
934
985
 
package/src/bin/rango.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { resolve, dirname } from "node:path";
1
+ #!/usr/bin/env node
2
+ import { resolve, dirname, basename } from "node:path";
2
3
  import { readFileSync, statSync, existsSync } from "node:fs";
3
4
  import {
4
5
  findTsFiles,
@@ -8,6 +9,7 @@ import {
8
9
  detectUnresolvableIncludesForUrlsFile,
9
10
  findNestedRouterConflict,
10
11
  formatNestedRouterConflictError,
12
+ isRoutableSourceFile,
11
13
  type UnresolvableInclude,
12
14
  } from "../build/generate-route-types.ts";
13
15
 
@@ -93,6 +95,19 @@ Examples:
93
95
  );
94
96
  }
95
97
 
98
+ /**
99
+ * A createRouter() file that is not a test: a test builds its own router (a
100
+ * Flight test's `*.rsc-test.tsx` usually mounts an inline urls() tree), and a
101
+ * named-routes.gen.ts beside it would merge its routes into the app's global
102
+ * GeneratedRouteMap. The same rule findRouterFiles() applies in Vite.
103
+ */
104
+ function isRouterFile(filePath: string, source: string): boolean {
105
+ return (
106
+ isRoutableSourceFile(basename(filePath)) &&
107
+ /\bcreateRouter\s*[<(]/.test(source)
108
+ );
109
+ }
110
+
96
111
  /**
97
112
  * Walk up from a file path to find the project root (directory containing
98
113
  * package.json or vite.config.ts).
@@ -141,7 +156,7 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
141
156
  for (const filePath of files) {
142
157
  try {
143
158
  const source = readFileSync(filePath, "utf-8");
144
- if (/\bcreateRouter\s*[<(]/.test(source)) {
159
+ if (isRouterFile(filePath, source)) {
145
160
  routerFiles.push(filePath);
146
161
  }
147
162
  // Both spellings, matching the writer's own gate: "clientUrls(" does
@@ -181,7 +196,8 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
181
196
  // Deduplicate diagnostics (router and urls detection may find the same issue)
182
197
  const seen = new Set<string>();
183
198
  const uniqueDiagnostics = allDiagnostics.filter((d) => {
184
- const key = `${d.sourceFile}:${d.pathPrefix}:${d.reason}`;
199
+ // Every unreadable routes mount has pathPrefix "/": its detail names it.
200
+ const key = `${d.sourceFile}:${d.pathPrefix}:${d.reason}${d.reason === "unresolvable-routes" ? `:${d.detail}` : ""}`;
185
201
  if (seen.has(key)) return false;
186
202
  seen.add(key);
187
203
  return true;
@@ -192,10 +208,11 @@ function runStaticGeneration(args: string[], mode: "default" | "static") {
192
208
  console.error("\n[rango] Unresolvable routes detected:\n");
193
209
  formatDiagnostics(uniqueDiagnostics);
194
210
  console.error(
195
- "\nThe static parser cannot resolve these includes or basename because " +
196
- "they use factory functions or dynamic expressions. A basename must be " +
197
- "a string literal or a top-level const (in the router file or imported " +
198
- "from a relative module).\n\n" +
211
+ "\nThe static parser cannot resolve these includes, routes or basename " +
212
+ "because they use factory functions or dynamic expressions. A router's " +
213
+ "routes must be a urls() call, a builder function or a variable holding " +
214
+ "urls(); a basename must be a string literal or a top-level const (in " +
215
+ "the router file or imported from a relative module).\n\n" +
199
216
  "Options:\n" +
200
217
  " rango generate <path> --runtime Use Vite-based discovery (requires vite)\n" +
201
218
  " rango generate <path> --static Accept partial output (missing routes or basename prefix above)\n",
@@ -255,7 +272,7 @@ async function runRuntimeDiscovery(args: string[], configFile?: string) {
255
272
  for (const filePath of files) {
256
273
  try {
257
274
  const source = readFileSync(filePath, "utf-8");
258
- if (/\bcreateRouter\s*[<(]/.test(source)) {
275
+ if (isRouterFile(filePath, source)) {
259
276
  routerEntries.push(filePath);
260
277
  }
261
278
  // Also generate per-module types for urls files. Both spellings — a
@@ -281,6 +298,10 @@ async function runRuntimeDiscovery(args: string[], configFile?: string) {
281
298
  process.exit(1);
282
299
  }
283
300
 
301
+ // Loads from its own chunk only because the bin is built with esbuild
302
+ // --splitting (package.json "build"). A single-file bundle folds this module
303
+ // in and hoists its Vite imports to the top, so every command, --help
304
+ // included, failed without vite installed.
284
305
  let discoverAndWriteRouteTypes: typeof import("../build/runtime-discovery.ts").discoverAndWriteRouteTypes;
285
306
  try {
286
307
  const mod = await import("../build/runtime-discovery.ts");
@@ -290,9 +311,12 @@ async function runRuntimeDiscovery(args: string[], configFile?: string) {
290
311
  err.code === "ERR_MODULE_NOT_FOUND" ||
291
312
  err.code === "MODULE_NOT_FOUND"
292
313
  ) {
314
+ // runtime-discovery checks vite and @vitejs/plugin-rsc itself and names
315
+ // what to install, so reaching here means a bundled chunk file is
316
+ // missing: the installed package is incomplete.
293
317
  console.error(
294
- "[rango] Runtime discovery requires 'vite' and '@vitejs/plugin-rsc'.\n" +
295
- "Install them with: pnpm add -D vite @vitejs/plugin-rsc",
318
+ `[rango] Failed to load runtime discovery: ${err.message}\n` +
319
+ "The installed @rangojs/router looks broken (a CLI chunk is missing). Reinstall it.",
296
320
  );
297
321
  } else {
298
322
  console.error(`[rango] Failed to load runtime discovery: ${err.message}`);
@@ -320,9 +344,11 @@ function formatDiagnostics(
320
344
  const prefix =
321
345
  d.reason === "unresolvable-basename"
322
346
  ? "createRouter basename"
323
- : d.namePrefix
324
- ? `${d.namePrefix}.*`
325
- : `${d.pathPrefix}*`;
347
+ : d.reason === "unresolvable-routes"
348
+ ? "createRouter routes"
349
+ : d.namePrefix
350
+ ? `${d.namePrefix}.*`
351
+ : `${d.pathPrefix}*`;
326
352
  console.error(` ${prefix}`);
327
353
  console.error(` Reason: ${d.reason} -- ${d.detail}`);
328
354
  console.error(` Source: ${d.sourceFile}`);
@@ -38,12 +38,82 @@
38
38
 
39
39
  let fenceCount = 0;
40
40
 
41
+ /**
42
+ * Whether a cookie read saw the state rotate externally while the fence was
43
+ * up and no action's landing took it yet (navigation-store-handle.ts reports
44
+ * it). Almost always an action's own server-seat `invalidateClientCache()`
45
+ * `Set-Cookie`; a sibling tab's rotation in that window reads the same and
46
+ * costs one full flush, today's behavior. navigation-store.ts takes it when
47
+ * an action lands, so one rotation makes one landing flush, not every later
48
+ * landing of the batch.
49
+ */
50
+ let rotatedInFence = false;
51
+
52
+ /**
53
+ * Whether a cookie read saw a write-only change of the state (a server
54
+ * action's response, this tab's or another's) while the fence was up and no
55
+ * action's landing took it yet. A landing marks every cache stale, so it
56
+ * takes this; when the fence drops with it still set (the actions that
57
+ * landed called keepClientCache(), failed before they were sent, or reloaded
58
+ * on a version mismatch), the drop hands it to `unlandedWriteHandler`.
59
+ */
60
+ let wroteInFence = false;
61
+
62
+ let unlandedWriteHandler: (() => void) | null = null;
63
+
64
+ /**
65
+ * Register what runs when the fence drops holding a write no landing took:
66
+ * the navigation store's markCachesStaleAfterWrite (navigation-store-handle.ts).
67
+ */
68
+ export function setUnlandedWriteHandler(handler: (() => void) | null): void {
69
+ unlandedWriteHandler = handler;
70
+ }
71
+
41
72
  export function enterActionFence(): void {
73
+ if (fenceCount === 0) {
74
+ rotatedInFence = false;
75
+ wroteInFence = false;
76
+ }
42
77
  fenceCount++;
43
78
  }
44
79
 
45
80
  export function exitActionFence(): void {
46
81
  if (fenceCount > 0) fenceCount--;
82
+ if (fenceCount === 0 && wroteInFence) {
83
+ wroteInFence = false;
84
+ unlandedWriteHandler?.();
85
+ }
86
+ }
87
+
88
+ /** A cookie read saw an external state rotation (rango-state.ts observer). */
89
+ export function noteStateRotation(): void {
90
+ if (fenceCount > 0) rotatedInFence = true;
91
+ }
92
+
93
+ /**
94
+ * A cookie read saw a write-only change (rango-state.ts write observer).
95
+ * Returns whether the fence is up and took it; with the fence down the
96
+ * caller marks the caches stale itself.
97
+ */
98
+ export function noteStateWrite(): boolean {
99
+ if (fenceCount === 0) return false;
100
+ wroteInFence = true;
101
+ return true;
102
+ }
103
+
104
+ /** A landing marked every cache stale: a write seen so far is covered. */
105
+ export function takeStateWriteInActionFence(): void {
106
+ wroteInFence = false;
107
+ }
108
+
109
+ /**
110
+ * Whether the state rotated externally since the fence went up or the last
111
+ * landing took it, and clear it. False with the fence down.
112
+ */
113
+ export function takeStateRotationInActionFence(): boolean {
114
+ const rotated = fenceCount > 0 && rotatedInFence;
115
+ rotatedInFence = false;
116
+ return rotated;
47
117
  }
48
118
 
49
119
  export function isActionFenceActive(): boolean {
@@ -53,4 +123,6 @@ export function isActionFenceActive(): boolean {
53
123
  /** Test-only: reset the refcount between cases. */
54
124
  export function __resetActionFence(): void {
55
125
  fenceCount = 0;
126
+ rotatedInFence = false;
127
+ wroteInFence = false;
56
128
  }
@@ -91,31 +91,48 @@ export function getRawCookieValue(
91
91
  }
92
92
 
93
93
  /**
94
- * Encode a state value for the wire: `encodeURIComponent(version):timestamp`.
95
- * Only the build-derived version is encoded (it is arbitrary); the `:`
96
- * separator and numeric timestamp stay raw, so the `{version}:{timestamp}`
97
- * shape survives and `:` is a legal cookie-value octet.
94
+ * Encode a state value for the wire: `encodeURIComponent(version):timestamp`,
95
+ * plus `:write` when the write part is not 0. Only the build-derived version
96
+ * is encoded (it is arbitrary); the `:` separators and the numeric parts stay
97
+ * raw, so the shape survives and `:` is a legal cookie-value octet. A value no
98
+ * server action has bumped keeps the two-part shape it had before the write
99
+ * part existed.
98
100
  */
99
- export function encodeStateValue(version: string, timestamp: number): string {
100
- return `${encodeURIComponent(version)}:${timestamp}`;
101
+ export function encodeStateValue(
102
+ version: string,
103
+ timestamp: number,
104
+ write = 0,
105
+ ): string {
106
+ const base = `${encodeURIComponent(version)}:${timestamp}`;
107
+ return write === 0 ? base : `${base}:${write}`;
101
108
  }
102
109
 
103
- /** Parsed state value. `version` is decoded; `timestamp` is the raw integer. */
110
+ /**
111
+ * Parsed state value. `version` is decoded; `timestamp` (the invalidation
112
+ * part) and `write` are the raw integers. `write` is 0 for a two-part value.
113
+ */
104
114
  export interface StateValue {
105
115
  version: string;
106
116
  timestamp: number;
117
+ write: number;
107
118
  }
108
119
 
109
120
  /**
110
- * Decode a wire value back into `{version, timestamp}`. Returns null for a
111
- * malformed value (no `:`, empty version, or non-numeric timestamp) so callers
112
- * mint fresh instead of trusting garbage.
121
+ * Decode a wire value back into `{version, timestamp, write}`. Accepts the
122
+ * two-part `{version}:{timestamp}` (a value no action bumped, or one an open
123
+ * tab wrote before the write part existed) and the three-part
124
+ * `{version}:{timestamp}:{write}`. Returns null for a malformed value (no `:`,
125
+ * empty version, a non-numeric part, or more parts) so callers mint fresh
126
+ * instead of trusting garbage.
113
127
  */
114
128
  export function decodeStateValue(raw: string): StateValue | null {
115
129
  const colon = raw.indexOf(":");
116
130
  if (colon <= 0) return null;
117
- const timestamp = Number(raw.slice(colon + 1));
118
- if (!Number.isFinite(timestamp)) return null;
131
+ const parts = raw.slice(colon + 1).split(":");
132
+ if (parts.length > 2) return null;
133
+ const timestamp = parseStatePart(parts[0]!);
134
+ const write = parts.length === 2 ? parseStatePart(parts[1]!) : 0;
135
+ if (timestamp === null || write === null) return null;
119
136
  let version: string;
120
137
  try {
121
138
  version = decodeURIComponent(raw.slice(0, colon));
@@ -124,7 +141,31 @@ export function decodeStateValue(raw: string): StateValue | null {
124
141
  // a thrown URIError here would 500 the server seat or fail client boot.
125
142
  return null;
126
143
  }
127
- return { version, timestamp };
144
+ return { version, timestamp, write };
145
+ }
146
+
147
+ function parseStatePart(part: string): number | null {
148
+ if (part === "") return null;
149
+ const value = Number(part);
150
+ return Number.isFinite(value) ? value : null;
151
+ }
152
+
153
+ /**
154
+ * The `{version}:{timestamp}` part of a wire value: what every in-memory
155
+ * comparison on the client reads (the prefetch cache keys, the action
156
+ * fence's rotation check, the external-rotation observer). A change to it is
157
+ * a hard invalidation (a deploy, or invalidateClientCache() on either seat);
158
+ * a change to the write part alone is a server action's write, which marks
159
+ * caches stale and flushes nothing. The full value, write part included, is
160
+ * what requests send as `X-Rango-State`, so the browser HTTP cache (Vary)
161
+ * misses after every write. A raw cut, no decode: the version is
162
+ * percent-encoded, so the second `:` is always the write separator.
163
+ */
164
+ export function stateEpoch(raw: string): string {
165
+ const first = raw.indexOf(":");
166
+ if (first < 0) return raw;
167
+ const second = raw.indexOf(":", first + 1);
168
+ return second < 0 ? raw : raw.slice(0, second);
128
169
  }
129
170
 
130
171
  /**
@@ -132,7 +173,9 @@ export function decodeStateValue(raw: string): StateValue | null {
132
173
  * one, so a re-mint inside the same millisecond (or a backward clock step) still
133
174
  * differs from the current value. `prevRaw` is the current wire value (the
134
175
  * client's in-memory mirror, or the server's inbound header/cookie) or null; its
135
- * timestamp is the floor. Shared by both seats so the monotonic guard lives once.
176
+ * timestamp is the floor. The write part starts over (left out): the new
177
+ * timestamp alone makes the value one no response was cached under. Shared by
178
+ * both seats so the monotonic guard lives once.
136
179
  */
137
180
  export function mintStateValue(
138
181
  version: string,
@@ -143,6 +186,31 @@ export function mintStateValue(
143
186
  return encodeStateValue(version, ts);
144
187
  }
145
188
 
189
+ /**
190
+ * Bump the write part of `prevRaw`, keeping its version and timestamp: what
191
+ * the response of a server action without a directive sets. Keeping the
192
+ * version keeps the bump soft for a client still on another build (a form
193
+ * posted from a document served before a deploy); its own boot handles the
194
+ * deploy. Returns null when `prevRaw` is absent or malformed: a client with
195
+ * no state value sends no `X-Rango-State` and has nothing in the HTTP cache
196
+ * keyed by one.
197
+ *
198
+ * The new write part must be one no response was cached under. It is
199
+ * strictly greater than the previous one, and it is the current time in
200
+ * microseconds with random low digits: two actions that start from the same
201
+ * value (two tabs, or two in flight in one tab) can run in the same server
202
+ * millisecond (a Worker's clock does not move between I/O), and the second
203
+ * response must not set the value the first one set, under which a prefetch
204
+ * between them may have been cached with the second write missing.
205
+ */
206
+ export function bumpStateWrite(prevRaw: string | null): string | null {
207
+ const prev = prevRaw ? decodeStateValue(prevRaw) : null;
208
+ if (!prev) return null;
209
+ const fresh = Date.now() * 1000 + Math.floor(Math.random() * 1000);
210
+ const write = Math.max(fresh, prev.write + 1);
211
+ return encodeStateValue(prev.version, prev.timestamp, write);
212
+ }
213
+
146
214
  /**
147
215
  * Attribute string for the state cookie. Session cookie (no Max-Age/Expires),
148
216
  * Path=/ (whole app), SameSite=Lax (sent on top-level navigations), and Secure
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Client seat of `invalidateClientCache()` (the `default` export condition).
3
3
  *
4
- * Makes the current client behave as if a server action had just completed:
5
- * the history cache is marked stale (SWR), the prefetch map is flushed, the
6
- * state rotates, and sibling tabs are broadcast to — the same
7
- * `markCacheAsStaleAndBroadcast()` path the server-action bridge uses. This is
8
- * the gentler mark-stale (not hard-clear) behavior, so Back renders the cached
9
- * entry instantly and revalidates.
4
+ * The full client invalidation, more than a server action without a
5
+ * directive does: the history cache is marked stale (SWR), the prefetch map
6
+ * is flushed (in-flight prefetches aborted, visible links prefetch again), the
7
+ * state rotates, and sibling tabs are broadcast to, which adopt the new state
8
+ * and flush too (`invalidateCacheAndBroadcast()`). A directive-free action
9
+ * only marks history and prefetches stale and rotates nothing
10
+ * (`markCacheAsStaleAndBroadcast()`). History is marked stale, not cleared, so
11
+ * Back renders the cached entry instantly and revalidates.
10
12
  */
11
13
 
12
14
  import { getRegisteredStore } from "./navigation-store-handle.js";
@@ -27,7 +29,7 @@ export function invalidateClientCache(): void {
27
29
 
28
30
  const store = getRegisteredStore();
29
31
  if (store) {
30
- store.markCacheAsStaleAndBroadcast();
32
+ store.invalidateCacheAndBroadcast();
31
33
  return;
32
34
  }
33
35