@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.dacec167

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 (255) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2151 -846
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/document-cache/SKILL.md +78 -55
  13. package/skills/handler-use/SKILL.md +364 -0
  14. package/skills/hooks/SKILL.md +229 -20
  15. package/skills/host-router/SKILL.md +45 -20
  16. package/skills/i18n/SKILL.md +276 -0
  17. package/skills/intercept/SKILL.md +46 -4
  18. package/skills/layout/SKILL.md +28 -7
  19. package/skills/links/SKILL.md +247 -17
  20. package/skills/loader/SKILL.md +219 -9
  21. package/skills/middleware/SKILL.md +47 -12
  22. package/skills/migrate-nextjs/SKILL.md +562 -0
  23. package/skills/migrate-react-router/SKILL.md +769 -0
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +71 -6
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +242 -22
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +66 -9
  31. package/skills/route/SKILL.md +57 -4
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +751 -0
  34. package/skills/streams-and-websockets/SKILL.md +283 -0
  35. package/skills/testing/SKILL.md +778 -0
  36. package/skills/typesafety/SKILL.md +319 -27
  37. package/skills/use-cache/SKILL.md +34 -5
  38. package/skills/view-transitions/SKILL.md +294 -0
  39. package/src/__augment-tests__/augment.ts +81 -0
  40. package/src/__augment-tests__/augmented.check.ts +117 -0
  41. package/src/browser/action-coordinator.ts +53 -36
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/event-controller.ts +86 -70
  44. package/src/browser/history-state.ts +21 -0
  45. package/src/browser/index.ts +3 -3
  46. package/src/browser/navigation-bridge.ts +84 -11
  47. package/src/browser/navigation-client.ts +76 -28
  48. package/src/browser/navigation-store.ts +32 -9
  49. package/src/browser/navigation-transaction.ts +10 -28
  50. package/src/browser/partial-update.ts +64 -26
  51. package/src/browser/prefetch/cache.ts +129 -21
  52. package/src/browser/prefetch/fetch.ts +148 -16
  53. package/src/browser/prefetch/queue.ts +36 -5
  54. package/src/browser/rango-state.ts +53 -13
  55. package/src/browser/react/Link.tsx +30 -2
  56. package/src/browser/react/NavigationProvider.tsx +72 -31
  57. package/src/browser/react/filter-segment-order.ts +51 -7
  58. package/src/browser/react/index.ts +3 -0
  59. package/src/browser/react/location-state-shared.ts +175 -4
  60. package/src/browser/react/location-state.ts +39 -13
  61. package/src/browser/react/use-handle.ts +17 -9
  62. package/src/browser/react/use-navigation.ts +22 -2
  63. package/src/browser/react/use-params.ts +20 -8
  64. package/src/browser/react/use-reverse.ts +106 -0
  65. package/src/browser/react/use-router.ts +22 -2
  66. package/src/browser/react/use-segments.ts +11 -8
  67. package/src/browser/response-adapter.ts +25 -0
  68. package/src/browser/rsc-router.tsx +64 -22
  69. package/src/browser/scroll-restoration.ts +22 -14
  70. package/src/browser/segment-reconciler.ts +36 -14
  71. package/src/browser/segment-structure-assert.ts +2 -2
  72. package/src/browser/server-action-bridge.ts +23 -30
  73. package/src/browser/types.ts +21 -0
  74. package/src/build/collect-fallback-refs.ts +107 -0
  75. package/src/build/generate-manifest.ts +60 -35
  76. package/src/build/generate-route-types.ts +2 -0
  77. package/src/build/index.ts +2 -0
  78. package/src/build/route-trie.ts +52 -25
  79. package/src/build/route-types/codegen.ts +4 -4
  80. package/src/build/route-types/include-resolution.ts +1 -1
  81. package/src/build/route-types/per-module-writer.ts +7 -4
  82. package/src/build/route-types/router-processing.ts +55 -14
  83. package/src/build/route-types/scan-filter.ts +1 -1
  84. package/src/build/route-types/source-scan.ts +118 -0
  85. package/src/build/runtime-discovery.ts +9 -20
  86. package/src/cache/cache-scope.ts +28 -42
  87. package/src/cache/cf/cf-cache-store.ts +54 -13
  88. package/src/client.rsc.tsx +3 -0
  89. package/src/client.tsx +92 -182
  90. package/src/context-var.ts +5 -5
  91. package/src/decode-loader-results.ts +36 -0
  92. package/src/errors.ts +30 -1
  93. package/src/handle.ts +26 -13
  94. package/src/host/index.ts +2 -2
  95. package/src/host/router.ts +129 -57
  96. package/src/host/types.ts +31 -2
  97. package/src/host/utils.ts +1 -1
  98. package/src/href-client.ts +140 -20
  99. package/src/index.rsc.ts +9 -4
  100. package/src/index.ts +53 -15
  101. package/src/loader-store.ts +500 -0
  102. package/src/loader.rsc.ts +21 -6
  103. package/src/loader.ts +3 -10
  104. package/src/missing-id-error.ts +68 -0
  105. package/src/outlet-context.ts +1 -1
  106. package/src/prerender.ts +4 -4
  107. package/src/response-utils.ts +37 -0
  108. package/src/reverse.ts +65 -36
  109. package/src/route-content-wrapper.tsx +6 -28
  110. package/src/route-definition/dsl-helpers.ts +384 -257
  111. package/src/route-definition/helper-factories.ts +29 -139
  112. package/src/route-definition/helpers-types.ts +100 -28
  113. package/src/route-definition/resolve-handler-use.ts +6 -0
  114. package/src/route-definition/use-item-types.ts +32 -0
  115. package/src/route-types.ts +26 -41
  116. package/src/router/basename.ts +14 -0
  117. package/src/router/content-negotiation.ts +15 -2
  118. package/src/router/error-handling.ts +1 -1
  119. package/src/router/handler-context.ts +21 -38
  120. package/src/router/intercept-resolution.ts +4 -18
  121. package/src/router/lazy-includes.ts +8 -8
  122. package/src/router/loader-resolution.ts +19 -2
  123. package/src/router/manifest.ts +22 -13
  124. package/src/router/match-api.ts +4 -3
  125. package/src/router/match-handlers.ts +63 -20
  126. package/src/router/match-middleware/cache-lookup.ts +44 -91
  127. package/src/router/match-middleware/cache-store.ts +3 -2
  128. package/src/router/match-result.ts +53 -32
  129. package/src/router/metrics.ts +1 -1
  130. package/src/router/middleware-types.ts +15 -26
  131. package/src/router/middleware.ts +99 -84
  132. package/src/router/pattern-matching.ts +101 -17
  133. package/src/router/prerender-match.ts +1 -1
  134. package/src/router/preview-match.ts +3 -1
  135. package/src/router/request-classification.ts +4 -28
  136. package/src/router/revalidation.ts +58 -2
  137. package/src/router/router-interfaces.ts +45 -28
  138. package/src/router/router-options.ts +40 -1
  139. package/src/router/router-registry.ts +2 -5
  140. package/src/router/segment-resolution/fresh.ts +27 -6
  141. package/src/router/segment-resolution/revalidation.ts +147 -106
  142. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  143. package/src/router/substitute-pattern-params.ts +56 -0
  144. package/src/router/telemetry.ts +99 -0
  145. package/src/router/trie-matching.ts +18 -13
  146. package/src/router/types.ts +8 -0
  147. package/src/router/url-params.ts +49 -0
  148. package/src/router.ts +38 -23
  149. package/src/rsc/handler-context.ts +2 -2
  150. package/src/rsc/handler.ts +28 -69
  151. package/src/rsc/helpers.ts +91 -43
  152. package/src/rsc/index.ts +1 -1
  153. package/src/rsc/origin-guard.ts +28 -10
  154. package/src/rsc/progressive-enhancement.ts +4 -0
  155. package/src/rsc/response-route-handler.ts +46 -53
  156. package/src/rsc/rsc-rendering.ts +35 -51
  157. package/src/rsc/runtime-warnings.ts +9 -10
  158. package/src/rsc/server-action.ts +17 -37
  159. package/src/rsc/ssr-setup.ts +16 -0
  160. package/src/rsc/types.ts +8 -2
  161. package/src/search-params.ts +4 -4
  162. package/src/segment-content-promise.ts +67 -0
  163. package/src/segment-loader-promise.ts +122 -0
  164. package/src/segment-system.tsx +132 -116
  165. package/src/serialize.ts +243 -0
  166. package/src/server/context.ts +143 -53
  167. package/src/server/cookie-store.ts +28 -4
  168. package/src/server/request-context.ts +20 -42
  169. package/src/ssr/index.tsx +5 -1
  170. package/src/static-handler.ts +1 -1
  171. package/src/testing/cache-status.ts +166 -0
  172. package/src/testing/collect-handle.ts +63 -0
  173. package/src/testing/dispatch.ts +440 -0
  174. package/src/testing/dom.entry.ts +22 -0
  175. package/src/testing/e2e/fixture.ts +154 -0
  176. package/src/testing/e2e/index.ts +149 -0
  177. package/src/testing/e2e/matchers.ts +51 -0
  178. package/src/testing/e2e/page-helpers.ts +272 -0
  179. package/src/testing/e2e/parity.ts +306 -0
  180. package/src/testing/e2e/server.ts +183 -0
  181. package/src/testing/flight-matchers.ts +104 -0
  182. package/src/testing/flight-runtime.d.ts +57 -0
  183. package/src/testing/flight-tree.ts +320 -0
  184. package/src/testing/flight.entry.ts +39 -0
  185. package/src/testing/flight.ts +197 -0
  186. package/src/testing/generated-routes.ts +223 -0
  187. package/src/testing/index.ts +106 -0
  188. package/src/testing/internal/context.ts +331 -0
  189. package/src/testing/internal/flight-client-globals.ts +30 -0
  190. package/src/testing/render-route.tsx +565 -0
  191. package/src/testing/run-loader.ts +341 -0
  192. package/src/testing/run-middleware.ts +188 -0
  193. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  194. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  195. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  196. package/src/testing/vitest-stubs/version.ts +5 -0
  197. package/src/testing/vitest.ts +270 -0
  198. package/src/types/global-namespace.ts +39 -26
  199. package/src/types/handler-context.ts +68 -50
  200. package/src/types/index.ts +1 -0
  201. package/src/types/loader-types.ts +5 -6
  202. package/src/types/request-scope.ts +126 -0
  203. package/src/types/route-entry.ts +11 -0
  204. package/src/types/segments.ts +35 -2
  205. package/src/urls/include-helper.ts +34 -67
  206. package/src/urls/index.ts +0 -3
  207. package/src/urls/path-helper-types.ts +41 -7
  208. package/src/urls/path-helper.ts +17 -52
  209. package/src/urls/pattern-types.ts +36 -19
  210. package/src/urls/response-types.ts +22 -29
  211. package/src/urls/type-extraction.ts +26 -116
  212. package/src/urls/urls-function.ts +1 -5
  213. package/src/use-loader.tsx +413 -42
  214. package/src/vite/debug.ts +185 -0
  215. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  216. package/src/vite/discovery/discover-routers.ts +101 -51
  217. package/src/vite/discovery/discovery-errors.ts +194 -0
  218. package/src/vite/discovery/gate-state.ts +171 -0
  219. package/src/vite/discovery/prerender-collection.ts +67 -26
  220. package/src/vite/discovery/route-types-writer.ts +40 -84
  221. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  222. package/src/vite/discovery/state.ts +33 -0
  223. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  224. package/src/vite/index.ts +2 -0
  225. package/src/vite/plugin-types.ts +67 -0
  226. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  227. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  228. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  229. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  230. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  231. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  232. package/src/vite/plugins/expose-action-id.ts +54 -30
  233. package/src/vite/plugins/expose-id-utils.ts +12 -8
  234. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  235. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  236. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  237. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  238. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  239. package/src/vite/plugins/performance-tracks.ts +29 -25
  240. package/src/vite/plugins/use-cache-transform.ts +65 -50
  241. package/src/vite/plugins/version-injector.ts +39 -23
  242. package/src/vite/plugins/version-plugin.ts +59 -2
  243. package/src/vite/plugins/virtual-entries.ts +2 -2
  244. package/src/vite/rango.ts +116 -29
  245. package/src/vite/router-discovery.ts +750 -100
  246. package/src/vite/utils/ast-handler-extract.ts +15 -15
  247. package/src/vite/utils/banner.ts +1 -1
  248. package/src/vite/utils/bundle-analysis.ts +4 -2
  249. package/src/vite/utils/client-chunks.ts +190 -0
  250. package/src/vite/utils/forward-user-plugins.ts +193 -0
  251. package/src/vite/utils/manifest-utils.ts +21 -5
  252. package/src/vite/utils/package-resolution.ts +41 -1
  253. package/src/vite/utils/prerender-utils.ts +21 -6
  254. package/src/vite/utils/shared-utils.ts +107 -26
  255. package/src/browser/action-response-classifier.ts +0 -99
@@ -12,8 +12,54 @@ import {
12
12
  type ReactNode,
13
13
  } from "react";
14
14
  import { OutletContext, type OutletContextValue } from "./outlet-context.js";
15
+ import { loaderStore, type LoaderEntry } from "./loader-store.js";
15
16
  import type { LoaderDefinition, LoadOptions } from "./types.js";
16
17
 
18
+ /**
19
+ * A shareable GET — a `load()` call that reads data (GET or defaulted method)
20
+ * with no request body. Params are allowed. This is the gate for keyed sharing:
21
+ * when a hook is given an explicit `key`, every shareable GET writes to the
22
+ * keyed bucket so co-keyed readers (including parameterized views) refresh
23
+ * together. Non-GET methods and body-bearing calls are mutations and stay local
24
+ * to the call site.
25
+ */
26
+ function isShareableGet(options: LoadOptions | undefined): boolean {
27
+ if (!options) return true;
28
+ if (options.method && options.method !== "GET") return false;
29
+ if ("body" in options && (options as { body?: unknown }).body !== undefined) {
30
+ return false;
31
+ }
32
+ return true;
33
+ }
34
+
35
+ /**
36
+ * Plain route-context refetch — a `load()` call with no options or a
37
+ * trivially-defaulted GET (no params, no body). Results from these are
38
+ * broadcast to every component reading the same loader id via the shared
39
+ * store, so a layout's refetch button updates page + parallel-slot reads
40
+ * automatically.
41
+ *
42
+ * Calls with explicit `params`, an explicit non-GET method, or a `body`
43
+ * stay local to the call site — that preserves the today-semantics of
44
+ * `useFetchLoader(SearchLoader).load({ params: { q } })` style code where
45
+ * each component owns its own fetched view. (An explicit `key` opts a
46
+ * parameterized GET back into sharing; see `isShareableGet`.)
47
+ */
48
+ function isPlainRefetch(options: LoadOptions | undefined): boolean {
49
+ if (!isShareableGet(options)) return false;
50
+ if (options?.params && Object.keys(options.params).length > 0) return false;
51
+ return true;
52
+ }
53
+
54
+ // Per-hook unique suffix for grouped reads that have no explicit `key`. Such a
55
+ // read must NOT share the bare `loader.$$id` bucket, or a cross-loader group
56
+ // refresh would leak into unrelated unkeyed readers of the same loader (which
57
+ // the contract keeps local). Sharing within a group is opt-in via an explicit
58
+ // `key`; without one, each grouped read gets its own private bucket. The value
59
+ // is only ever used as a client-side store bucket key (never rendered), so the
60
+ // counter has no SSR/hydration consistency requirement.
61
+ let privateGroupBucketSeq = 0;
62
+
17
63
  /**
18
64
  * Extract a specific loader's data from a content ReactNode.
19
65
  *
@@ -121,6 +167,40 @@ export interface UseLoaderOptions {
121
167
  * @default true
122
168
  */
123
169
  throwOnError?: boolean;
170
+ /**
171
+ * Client refresh key. Partitions the shared refresh store so that only hooks
172
+ * using the same `key` refresh together when one of them calls `load()`.
173
+ *
174
+ * Without a `key` (default), a plain `load()` on a route-registered loader
175
+ * broadcasts to every reader of that loader, and any parameterized / unregistered
176
+ * load stays local to the calling hook. With a `key`:
177
+ * - readers of the same loader that share a `key` form one refresh group —
178
+ * a `load()` from any of them updates the whole group, including
179
+ * parameterized GETs;
180
+ * - readers with different keys are independent;
181
+ * - it works even when the loader is NOT registered on the route (keyed
182
+ * `useFetchLoader`), letting unrelated components opt into sharing.
183
+ *
184
+ * This is a client-side refresh identity only. It is unrelated to the server
185
+ * `cache({ key })` option and to `revalidate()`; it never changes the request
186
+ * sent to the server.
187
+ */
188
+ key?: string;
189
+ /**
190
+ * Cross-loader refresh group tag(s). Tag reads of DIFFERENT loaders with a
191
+ * shared name, then call `useRefreshLoaders()(name)` to refresh the whole group
192
+ * at once. Pass an array to tag one read into several groups — it is refreshed
193
+ * when ANY of its groups is refreshed, so a coarse tag can cover the whole set
194
+ * while a finer tag targets a subset. Each member is refreshed with a plain GET
195
+ * against the current route URL — no params, no body, no mutation methods —
196
+ * because a group spans heterogeneous loaders with different param/return
197
+ * shapes.
198
+ *
199
+ * For parameterized sharing of a SINGLE loader, use `key` instead; group
200
+ * members should be registered or non-parameterized-keyed reads (a plain-GET
201
+ * group refresh would drop any per-call params).
202
+ */
203
+ refreshGroup?: string | string[];
124
204
  }
125
205
 
126
206
  /**
@@ -132,12 +212,23 @@ function useLoaderInternal<T>(
132
212
  ): UseFetchLoaderResult<T> {
133
213
  const context = useContext(OutletContext);
134
214
 
135
- // Get data from context (SSR/navigation)
136
- const contextData = useMemo((): T | undefined => {
215
+ // Get data from context (SSR/navigation). `hasContextData` distinguishes
216
+ // "loader registered on the route, value happens to be undefined" from
217
+ // "loader is not in any parent's context at all". The shared store is
218
+ // only consulted when the loader really is in route context — that
219
+ // preserves per-component isolation for ad-hoc useFetchLoader callers
220
+ // who use the same fetchable loader without registering it.
221
+ const { contextData, hasContextData } = useMemo((): {
222
+ contextData: T | undefined;
223
+ hasContextData: boolean;
224
+ } => {
137
225
  let current: OutletContextValue | null | undefined = context;
138
226
  while (current) {
139
227
  if (current.loaderData && loader.$$id in current.loaderData) {
140
- return current.loaderData[loader.$$id] as T;
228
+ return {
229
+ contextData: current.loaderData[loader.$$id] as T,
230
+ hasContextData: true,
231
+ };
141
232
  }
142
233
  // Check content element — the route's OutletProvider is rendered as
143
234
  // <Outlet /> content (a child), so its loaderData isn't in the parent
@@ -147,32 +238,195 @@ function useLoaderInternal<T>(
147
238
  loader.$$id,
148
239
  );
149
240
  if (contentData !== NOT_FOUND) {
150
- return contentData as T;
241
+ return { contextData: contentData as T, hasContextData: true };
151
242
  }
152
243
  current = current.parent;
153
244
  }
154
- return undefined;
245
+ return { contextData: undefined, hasContextData: false };
155
246
  }, [context, loader.$$id]);
156
247
 
157
- // Local state for fetched data (from load() calls)
158
- const [fetchedData, setFetchedData] = useState<T | undefined>(undefined);
159
- const [isLoading, setIsLoading] = useState(false);
160
- const [error, setError] = useState<Error | null>(null);
161
- const requestIdRef = useRef(0);
162
-
163
- // Track context data changes to reset fetched data on navigation
248
+ // Shared subscription: every component reading the same loader id sees
249
+ // the same snapshot, so a plain refetch from one component propagates to
250
+ // the others. Mirrors the convention used by useParams / useLinkStatus —
251
+ // useState seeded from the store, useEffect subscribes for updates and
252
+ // calls setState inside startTransition so subscriber re-renders don't
253
+ // trip Suspense fallbacks during a refetch (matches the per-hook
254
+ // startTransition the old code wrapped setFetchedData in).
255
+ const loaderId = loader.$$id;
256
+ // Client refresh key. The shared store is partitioned by bucket key so that
257
+ // only hooks with the same `key` refresh together. Default (no key) keeps the
258
+ // historical behavior: one bucket per loader id.
259
+ const key = options?.key;
260
+ // Normalize the refresh-group tag(s) to a stable, deduped, sorted list. The
261
+ // joined `groupKey` string is the subscribe effect's dependency, so passing an
262
+ // inline array literal (`refreshGroup={["a", "b"]}`) does not force a
263
+ // resubscribe on every render. An empty list means "no groups" — identical to
264
+ // omitting the option (`hasGroups` stays false, no private bucket is created).
265
+ const refreshGroupOption = options?.refreshGroup;
266
+ const groupKey =
267
+ refreshGroupOption === undefined
268
+ ? ""
269
+ : JSON.stringify(
270
+ typeof refreshGroupOption === "string"
271
+ ? [refreshGroupOption]
272
+ : [...new Set(refreshGroupOption)].sort(),
273
+ );
274
+ const groupList = useMemo<string[]>(
275
+ () => (groupKey === "" ? [] : (JSON.parse(groupKey) as string[])),
276
+ [groupKey],
277
+ );
278
+ const hasGroups = groupList.length > 0;
279
+ // A grouped reader with no explicit key gets a private per-hook bucket so a
280
+ // cross-loader group refresh cannot leak into the bare `loader.$$id` bucket
281
+ // shared by unrelated unkeyed readers. Sharing within a group is opt-in via
282
+ // an explicit `key`.
283
+ const privateBucketIdRef = useRef<string | null>(null);
284
+ if (hasGroups && key === undefined && privateBucketIdRef.current === null) {
285
+ privateBucketIdRef.current = `__rg${privateGroupBucketSeq++}`;
286
+ }
287
+ const effectiveKey =
288
+ key ?? (hasGroups ? privateBucketIdRef.current! : undefined);
289
+ const bucketKey =
290
+ effectiveKey === undefined ? loaderId : `${loaderId}::${effectiveKey}`;
291
+
292
+ // Plain-GET refresh thunk registered with the store for cross-loader group
293
+ // refresh (useRefreshLoaders). Always shares into this hook's bucket, never
294
+ // touches lastSharedRequestIdRef (so a group refresh never render-throws —
295
+ // errors surface via `error` and reject the refreshGroups() promise instead),
296
+ // and sends no params/body. Stable across navigations (depends only on
297
+ // loaderId + bucketKey), so the store keeps one current thunk per bucket.
298
+ const groupRefetch = useCallback(async (): Promise<void> => {
299
+ if (!loaderId) return;
300
+ const requestId = loaderStore.reserveRequestId(bucketKey);
301
+ loaderStore.beginRequest(bucketKey, requestId);
302
+ try {
303
+ const url = new URL(window.location.href);
304
+ url.searchParams.set("_rsc_loader", loaderId);
305
+ const response = fetch(url.toString(), {
306
+ method: "GET",
307
+ headers: { Accept: "text/x-component" },
308
+ });
309
+ const { createFromFetch } = await import("./deps/browser.js");
310
+ const payload = await createFromFetch<LoaderRscPayload<T>>(response);
311
+ if (payload.loaderError) {
312
+ throw new Error(payload.loaderError.message);
313
+ }
314
+ loaderStore.finishData(bucketKey, requestId, payload.loaderResult);
315
+ } catch (e) {
316
+ const err = e instanceof Error ? e : new Error(String(e));
317
+ loaderStore.finishError(bucketKey, requestId, err);
318
+ throw err;
319
+ } finally {
320
+ loaderStore.setLoading(bucketKey, requestId, false);
321
+ }
322
+ }, [loaderId, bucketKey]);
323
+
324
+ const [sharedState, setSharedState] = useState<{
325
+ bucketKey: string;
326
+ snapshot: LoaderEntry;
327
+ }>(() => ({
328
+ bucketKey,
329
+ snapshot: loaderStore.getSnapshot(bucketKey),
330
+ }));
331
+ const sharedSnapshot =
332
+ sharedState.bucketKey === bucketKey
333
+ ? sharedState.snapshot
334
+ : loaderStore.getSnapshot(bucketKey);
335
+ useEffect(() => {
336
+ // Sync any value the store committed between this hook's lazy
337
+ // initializer and effect-time (e.g. a sibling that mounted earlier
338
+ // already triggered a load()).
339
+ const initial = loaderStore.getSnapshot(bucketKey);
340
+ if (initial !== sharedSnapshot) {
341
+ startTransition(() => {
342
+ setSharedState({ bucketKey, snapshot: initial });
343
+ });
344
+ }
345
+ // ephemeral: a reader with no route context has no route-context reset
346
+ // trigger, so its keyed bucket is reference-counted by the store. A
347
+ // route-registered reader makes the bucket sticky (reset via clearFamily).
348
+ return loaderStore.subscribe(
349
+ bucketKey,
350
+ () => {
351
+ const next = loaderStore.getSnapshot(bucketKey);
352
+ startTransition(() => {
353
+ setSharedState({ bucketKey, snapshot: next });
354
+ });
355
+ },
356
+ {
357
+ loaderId,
358
+ ephemeral: !hasContextData,
359
+ group: hasGroups ? groupList : undefined,
360
+ refetch: hasGroups ? groupRefetch : undefined,
361
+ },
362
+ );
363
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional:
364
+ // sharedSnapshot is captured for the one-shot init sync; we don't want
365
+ // to re-subscribe on every snapshot change. bucketKey, hasContextData,
366
+ // groupKey, and groupRefetch are the only inputs that require a fresh
367
+ // subscription (groupList is memoized on groupKey; groupRefetch is stable
368
+ // per bucketKey).
369
+ }, [bucketKey, hasContextData, groupKey, groupRefetch]);
370
+
371
+ // Local state holds the result of:
372
+ // - parameterized / mutation `load()` calls (load({ params }), POST,
373
+ // etc.) — stay scoped so concurrent same-loader different-params
374
+ // fetches don't clobber each other through the shared store;
375
+ // - any `load()` made by hooks that are NOT in route context (i.e.
376
+ // useFetchLoader of an unregistered loader) — keeping those local
377
+ // prevents two unrelated components from accidentally sharing data
378
+ // through the global store just because they reference the same
379
+ // loader id.
380
+ // `has` distinguishes a committed local result (including `null`/`undefined`)
381
+ // from "no local load yet", so a load() that resolves to a falsy value is not
382
+ // discarded in favor of the shared snapshot or the seeded context.
383
+ const [localFetchedData, setLocalFetchedData] = useState<{
384
+ has: boolean;
385
+ value: T | undefined;
386
+ }>({ has: false, value: undefined });
387
+ const [localIsLoading, setLocalIsLoading] = useState(false);
388
+ const [localError, setLocalError] = useState<Error | null>(null);
389
+
390
+ // Local request id, mirrors the per-hook gating the previous
391
+ // implementation provided. Two quick parameterized loads from the same
392
+ // hook (e.g. load({ params: { q: "a" } }) then load({ params: { q: "b" } }))
393
+ // can resolve out of order — only the latest must commit.
394
+ const localRequestIdRef = useRef(0);
395
+
396
+ // Tracks the request id of the most recent SHARED load() this hook
397
+ // initiated. The render-throw rule below uses it to scope the throw
398
+ // to the originating hook only — sibling readers see the error in
399
+ // `error` but don't blow up their own boundaries.
400
+ const lastSharedRequestIdRef = useRef<number | null>(null);
401
+
402
+ // Reset on navigation. clear() bumps the entry's latest request id so
403
+ // any pre-navigation load() promise that resolves later fails its gate
404
+ // and is dropped — fixes the race where a stale fetch overwrites the
405
+ // new route's context.
164
406
  const prevContextDataRef = useRef(contextData);
165
407
  useEffect(() => {
166
408
  if (prevContextDataRef.current !== contextData) {
167
- // Navigation happened, clear fetched data so context takes precedence
168
- setFetchedData(undefined);
169
- setError(null);
409
+ setLocalFetchedData({ has: false, value: undefined });
410
+ setLocalIsLoading(false);
411
+ setLocalError(null);
412
+ lastSharedRequestIdRef.current = null;
413
+ // Reset every sticky bucket of this loader (keyed or not). Ephemeral
414
+ // (unregistered keyed) buckets are left to their refcount lifecycle.
415
+ loaderStore.clearFamily(loaderId);
170
416
  prevContextDataRef.current = contextData;
171
417
  }
172
- }, [contextData]);
173
-
174
- // Data priority: fetched data (if any) > context data
175
- const data = fetchedData ?? contextData;
418
+ }, [contextData, loaderId]);
419
+
420
+ // Read priority: a committed parameterized load() result overrides the shared
421
+ // snapshot; a committed shared snapshot overrides the server-seeded context.
422
+ // `has`/`hasValue` gate each level so a committed falsy value is not skipped.
423
+ const data = localFetchedData.has
424
+ ? localFetchedData.value
425
+ : sharedSnapshot.hasValue
426
+ ? (sharedSnapshot.value as T | undefined)
427
+ : contextData;
428
+ const isLoading = localIsLoading || sharedSnapshot.isLoading;
429
+ const error = localError ?? sharedSnapshot.error;
176
430
 
177
431
  const throwOnError = options?.throwOnError ?? true;
178
432
 
@@ -180,30 +434,62 @@ function useLoaderInternal<T>(
180
434
  // churn. loader.$$id can change if a reusable component receives a different
181
435
  // loader without remounting; data changes on every navigation. Refs keep the
182
436
  // callback stable while always reading the latest values.
183
- const loaderIdRef = useRef(loader.$$id);
184
- loaderIdRef.current = loader.$$id;
437
+ const loaderIdRef = useRef(loaderId);
438
+ loaderIdRef.current = loaderId;
439
+ const bucketKeyRef = useRef(bucketKey);
440
+ bucketKeyRef.current = bucketKey;
185
441
  const dataRef = useRef(data);
186
442
  dataRef.current = data;
443
+ const hasContextDataRef = useRef(hasContextData);
444
+ hasContextDataRef.current = hasContextData;
187
445
 
188
446
  // Load function for fetching data via the ?_rsc_loader endpoint.
189
447
  // Supports GET (data fetching) and POST/PUT/PATCH/DELETE (mutations).
190
448
  const load = useCallback(
191
449
  async (loadOptions?: LoadOptions): Promise<T> => {
192
- const requestId = ++requestIdRef.current;
193
- const loaderId = loaderIdRef.current;
194
- // Verify the loader has $$id
195
- if (!loaderId) {
450
+ const id = loaderIdRef.current;
451
+ if (!id) {
196
452
  throw new Error(
197
453
  `Loader is missing $$id. Make sure the exposeLoaderId Vite plugin is enabled.`,
198
454
  );
199
455
  }
200
456
 
201
- setIsLoading(true);
202
- setError(null);
457
+ const bucket = bucketKeyRef.current;
458
+ // A dedicated bucket means this read owns a bucket distinct from the bare
459
+ // loader id — either an explicit `key` (`$$id::key`) or a refreshGroup's
460
+ // private bucket (`$$id::<private>`).
461
+ const hasDedicatedBucket = bucket !== id;
462
+
463
+ // Deciding shared vs local:
464
+ // - With a dedicated bucket, every shareable GET (params allowed) writes
465
+ // to that bucket — the key/group is an explicit opt-in to sharing, and
466
+ // a direct load() must land in the same bucket a group refresh uses.
467
+ // - On the bare loader-id bucket, sharing is only correct when the
468
+ // loader is registered on the route and the call is a plain refetch —
469
+ // otherwise two unrelated components calling load() on the same
470
+ // fetchable loader would overwrite each other's local view.
471
+ // Mutations (non-GET / body) stay local in both cases.
472
+ const shared = hasDedicatedBucket
473
+ ? isShareableGet(loadOptions)
474
+ : isPlainRefetch(loadOptions) && hasContextDataRef.current;
475
+ let sharedRequestId = -1;
476
+ let localRequestId = -1;
477
+ if (shared) {
478
+ sharedRequestId = loaderStore.reserveRequestId(bucket);
479
+ lastSharedRequestIdRef.current = sharedRequestId;
480
+ // beginRequest flips loading on AND clears any prior error so a
481
+ // throwOnError: false consumer doesn't keep showing the stale
482
+ // error during the retry. Gated on requestId === latest.
483
+ loaderStore.beginRequest(bucket, sharedRequestId);
484
+ } else {
485
+ localRequestId = ++localRequestIdRef.current;
486
+ setLocalIsLoading(true);
487
+ setLocalError(null);
488
+ }
203
489
 
204
490
  try {
205
491
  const url = new URL(window.location.href);
206
- url.searchParams.set("_rsc_loader", loaderId);
492
+ url.searchParams.set("_rsc_loader", id);
207
493
 
208
494
  const method = loadOptions?.method ?? "GET";
209
495
  const isBodyMethod = method !== "GET";
@@ -284,16 +570,26 @@ function useLoaderInternal<T>(
284
570
  }
285
571
 
286
572
  const result = payload.loaderResult;
287
- if (requestId === requestIdRef.current) {
573
+ if (shared) {
574
+ // finishData is gated on requestId; a stale response is dropped.
575
+ loaderStore.finishData(bucket, sharedRequestId, result);
576
+ } else if (localRequestId === localRequestIdRef.current) {
577
+ // Local-branch gate, mirrors the shared-branch requestId check:
578
+ // if a newer load() was issued from this hook before this one
579
+ // resolved, drop the stale result.
288
580
  startTransition(() => {
289
- setFetchedData(result);
581
+ setLocalFetchedData({ has: true, value: result });
582
+ setLocalIsLoading(false);
290
583
  });
291
584
  }
292
585
  return result;
293
586
  } catch (e) {
294
587
  const err = e instanceof Error ? e : new Error(String(e));
295
- if (requestId === requestIdRef.current) {
296
- setError(err);
588
+ if (shared) {
589
+ loaderStore.finishError(bucket, sharedRequestId, err);
590
+ } else if (localRequestId === localRequestIdRef.current) {
591
+ setLocalError(err);
592
+ setLocalIsLoading(false);
297
593
  }
298
594
  if (throwOnError) {
299
595
  throw err;
@@ -302,18 +598,31 @@ function useLoaderInternal<T>(
302
598
  // successful value or undefined). Caller should check error state.
303
599
  return dataRef.current as T;
304
600
  } finally {
305
- if (requestId === requestIdRef.current) {
306
- setIsLoading(false);
601
+ if (shared) {
602
+ // setLoading is gated; only the latest request flips the flag off.
603
+ loaderStore.setLoading(bucket, sharedRequestId, false);
307
604
  }
308
605
  }
309
606
  },
310
607
  [throwOnError],
311
608
  );
312
609
 
313
- // Throw during render if there's an error and throwOnError is true
314
- // This allows ErrorBoundaries to catch async errors from load()
315
- if (error && throwOnError) {
316
- throw error;
610
+ // Throw during render if there's an error and throwOnError is true.
611
+ // - Local errors always belong to this hook, so always throw on opt-in.
612
+ // - Shared errors throw only when this hook initiated the failing
613
+ // request (entry.requestId matches lastSharedRequestIdRef). Sibling
614
+ // readers expose the error via `error` but do not throw, so a
615
+ // throwOnError: true reader never explodes because of someone else's
616
+ // throwOnError: false load() failure.
617
+ if (throwOnError) {
618
+ if (localError) throw localError;
619
+ if (
620
+ sharedSnapshot.error &&
621
+ lastSharedRequestIdRef.current !== null &&
622
+ sharedSnapshot.requestId === lastSharedRequestIdRef.current
623
+ ) {
624
+ throw sharedSnapshot.error;
625
+ }
317
626
  }
318
627
 
319
628
  return {
@@ -357,7 +666,7 @@ function useLoaderInternal<T>(
357
666
  export function useLoader<T>(
358
667
  loader: LoaderDefinition<T>,
359
668
  options?: UseLoaderOptions,
360
- ): UseLoaderResult<T> {
669
+ ): UseLoaderResult<Rango.FlightSerialize<T>> {
361
670
  const result = useLoaderInternal(loader, options);
362
671
 
363
672
  // Strict mode: throw if data is not in context
@@ -369,7 +678,7 @@ export function useLoader<T>(
369
678
  );
370
679
  }
371
680
 
372
- return result as UseLoaderResult<T>;
681
+ return result as UseLoaderResult<Rango.FlightSerialize<T>>;
373
682
  }
374
683
 
375
684
  /**
@@ -421,6 +730,68 @@ export function useLoader<T>(
421
730
  export function useFetchLoader<T>(
422
731
  loader: LoaderDefinition<T>,
423
732
  options?: UseLoaderOptions,
424
- ): UseFetchLoaderResult<T> {
425
- return useLoaderInternal(loader, options);
733
+ ): UseFetchLoaderResult<Rango.FlightSerialize<T>> {
734
+ return useLoaderInternal(loader, options) as UseFetchLoaderResult<
735
+ Rango.FlightSerialize<T>
736
+ >;
737
+ }
738
+
739
+ /**
740
+ * Get a stable function that refreshes loaders by cross-loader group tag.
741
+ *
742
+ * The returned `refresh(groups)` takes one group name or an array of names and
743
+ * re-runs every currently-mounted read tagged with ANY of them, with a plain GET
744
+ * against the current route URL. This is the cross-loader counterpart to the
745
+ * single-loader `key`: use it to refresh a set of DIFFERENT loaders together
746
+ * (e.g. profile + orders after an account switch). Members are tagged via
747
+ * `useLoader(Loader, { refreshGroup })` / `useFetchLoader(Loader, { refreshGroup })`,
748
+ * where `refreshGroup` is one name or several.
749
+ *
750
+ * Passing the group(s) to the returned function rather than to the hook lets a
751
+ * single `useRefreshLoaders()` instance refresh different groups depending on
752
+ * context, and lets one call refresh several groups at once — their members are
753
+ * unioned and deduped, so a loader tagged into two of the named groups is fetched
754
+ * exactly once.
755
+ *
756
+ * Group refresh never render-throws: a failing member surfaces its error via
757
+ * that read's `error` state, and the returned promise rejects with an
758
+ * `AggregateError` of the failures so the caller can handle them at the await
759
+ * site. Each loader is refreshed in place — no params, no body, no mutations.
760
+ *
761
+ * @example
762
+ * ```tsx
763
+ * "use client";
764
+ * import { useLoader, useRefreshLoaders } from "rsc-router/client";
765
+ *
766
+ * function Profile() {
767
+ * const { data } = useLoader(ProfileLoader, { key: userId, refreshGroup: "account" });
768
+ * return <span>{data.name}</span>;
769
+ * }
770
+ * function Orders() {
771
+ * // Tagged into two groups: refreshed by "account" (the whole set) or "orders".
772
+ * const { data } = useLoader(OrdersLoader, {
773
+ * key: userId,
774
+ * refreshGroup: ["account", "orders"],
775
+ * });
776
+ * return <span>{data.count} orders</span>;
777
+ * }
778
+ * function RefreshButtons() {
779
+ * const refresh = useRefreshLoaders();
780
+ * return (
781
+ * <>
782
+ * <button onClick={() => refresh("account")}>Refresh account</button>
783
+ * <button onClick={() => refresh("orders")}>Refresh orders</button>
784
+ * <button onClick={() => refresh(["account", "orders"])}>Refresh both</button>
785
+ * </>
786
+ * );
787
+ * }
788
+ * ```
789
+ */
790
+ export function useRefreshLoaders(): (
791
+ groups: string | string[],
792
+ ) => Promise<void> {
793
+ return useCallback(
794
+ (groups: string | string[]) => loaderStore.refreshGroups(groups),
795
+ [],
796
+ );
426
797
  }