@rangojs/router 0.16.0 → 0.17.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.
Files changed (245) hide show
  1. package/README.md +254 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  6. package/dist/types/browser/rsc-router.d.ts +1 -1
  7. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  8. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  9. package/dist/types/cache/cache-scope.d.ts +34 -5
  10. package/dist/types/cache/handle-capture.d.ts +41 -3
  11. package/dist/types/cache/handle-snapshot.d.ts +53 -13
  12. package/dist/types/cache/segment-codec.d.ts +10 -3
  13. package/dist/types/cache/taint.d.ts +1 -1
  14. package/dist/types/cache/types.d.ts +15 -3
  15. package/dist/types/client.d.ts +4 -4
  16. package/dist/types/client.rsc.d.ts +4 -5
  17. package/dist/types/components/DefaultDocument.d.ts +5 -2
  18. package/dist/types/context-var.d.ts +6 -5
  19. package/dist/types/escape-script.d.ts +1 -1
  20. package/dist/types/handles/MetaTags.d.ts +5 -4
  21. package/dist/types/handles/Scripts.d.ts +8 -7
  22. package/dist/types/handles/meta.d.ts +2 -2
  23. package/dist/types/handles/script.d.ts +17 -16
  24. package/dist/types/href-client.d.ts +1 -1
  25. package/dist/types/html.d.ts +12 -0
  26. package/dist/types/index.d.ts +1 -1
  27. package/dist/types/index.rsc.d.ts +3 -2
  28. package/dist/types/loader-store.d.ts +7 -4
  29. package/dist/types/reverse.d.ts +36 -4
  30. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  31. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  32. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  33. package/dist/types/route-types.d.ts +1 -1
  34. package/dist/types/router/debug-manifest.d.ts +3 -2
  35. package/dist/types/router/error-handling.d.ts +20 -0
  36. package/dist/types/router/handler-context.d.ts +6 -0
  37. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  38. package/dist/types/router/middleware-types.d.ts +3 -2
  39. package/dist/types/router/router-context.d.ts +3 -1
  40. package/dist/types/router/router-interfaces.d.ts +24 -2
  41. package/dist/types/router/router-options.d.ts +8 -5
  42. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  43. package/dist/types/router/segment-resolution/loader-cache.d.ts +37 -1
  44. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  45. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +3 -3
  46. package/dist/types/router/segment-resolution/mask-nested.d.ts +7 -7
  47. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  48. package/dist/types/router/segment-wrappers.d.ts +1 -1
  49. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  50. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  51. package/dist/types/rsc/shell-capture-constants.d.ts +8 -0
  52. package/dist/types/rsc/shell-capture.d.ts +5 -4
  53. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  54. package/dist/types/rsc/types.d.ts +10 -0
  55. package/dist/types/server/context.d.ts +33 -3
  56. package/dist/types/server/handle-store.d.ts +26 -2
  57. package/dist/types/server/request-context.d.ts +24 -21
  58. package/dist/types/ssr/index.d.ts +15 -0
  59. package/dist/types/static-handler.d.ts +4 -1
  60. package/dist/types/testing/generated-routes.d.ts +2 -2
  61. package/dist/types/testing/internal/context.d.ts +1 -1
  62. package/dist/types/testing/render-route.d.ts +32 -6
  63. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  64. package/dist/types/testing/vitest.d.ts +35 -1
  65. package/dist/types/theme/ThemeScript.d.ts +3 -3
  66. package/dist/types/types/cache-types.d.ts +14 -10
  67. package/dist/types/types/handler-context.d.ts +14 -0
  68. package/dist/types/types/loader-types.d.ts +8 -6
  69. package/dist/types/urls/response-types.d.ts +3 -12
  70. package/dist/types/use-loader.d.ts +12 -3
  71. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  72. package/dist/types/vite/rango.d.ts +1 -1
  73. package/dist/vite/index.js +1381 -1346
  74. package/package.json +2 -2
  75. package/skills/api-client/SKILL.md +22 -10
  76. package/skills/breadcrumbs/SKILL.md +65 -22
  77. package/skills/bundle-analysis/SKILL.md +22 -22
  78. package/skills/cache-guide/SKILL.md +207 -97
  79. package/skills/caching/SKILL.md +295 -93
  80. package/skills/catalog.json +16 -16
  81. package/skills/client-urls/SKILL.md +53 -31
  82. package/skills/cloudflare/SKILL.md +72 -20
  83. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  84. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  85. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  86. package/skills/comparison/SKILL.md +5 -0
  87. package/skills/comparison/references/framework-comparison.md +39 -33
  88. package/skills/composability/SKILL.md +38 -29
  89. package/skills/css/SKILL.md +10 -13
  90. package/skills/debug-manifest/SKILL.md +74 -31
  91. package/skills/defer-hydration/SKILL.md +2 -1
  92. package/skills/deployment-caching/SKILL.md +6 -1
  93. package/skills/document-cache/SKILL.md +92 -28
  94. package/skills/fonts/SKILL.md +16 -8
  95. package/skills/handler-use/SKILL.md +53 -17
  96. package/skills/hooks/SKILL.md +28 -20
  97. package/skills/hooks/data.md +23 -3
  98. package/skills/hooks/handle-and-actions.md +31 -16
  99. package/skills/hooks/navigation.md +39 -10
  100. package/skills/hooks/outlets.md +13 -6
  101. package/skills/hooks/state.md +37 -15
  102. package/skills/hooks/urls.md +7 -5
  103. package/skills/host-router/SKILL.md +64 -12
  104. package/skills/i18n/SKILL.md +60 -34
  105. package/skills/intercept/SKILL.md +161 -26
  106. package/skills/layout/SKILL.md +106 -50
  107. package/skills/links/SKILL.md +51 -19
  108. package/skills/loader/SKILL.md +242 -106
  109. package/skills/middleware/SKILL.md +119 -23
  110. package/skills/migrate-nextjs/SKILL.md +100 -45
  111. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  112. package/skills/migrate-react-router/SKILL.md +49 -38
  113. package/skills/migrate-react-router/component-migration.md +25 -6
  114. package/skills/migrate-react-router/data-and-actions.md +18 -5
  115. package/skills/migrate-react-router/route-mapping.md +12 -6
  116. package/skills/mime-routes/SKILL.md +12 -7
  117. package/skills/observability/SKILL.md +28 -14
  118. package/skills/parallel/SKILL.md +75 -39
  119. package/skills/ppr/SKILL.md +345 -150
  120. package/skills/prerender/SKILL.md +189 -100
  121. package/skills/rango/SKILL.md +65 -34
  122. package/skills/react-compiler/SKILL.md +6 -5
  123. package/skills/response-routes/SKILL.md +133 -25
  124. package/skills/route/SKILL.md +165 -60
  125. package/skills/router-setup/SKILL.md +418 -240
  126. package/skills/scripts/SKILL.md +31 -20
  127. package/skills/server-actions/SKILL.md +220 -59
  128. package/skills/shell-manifest/SKILL.md +12 -7
  129. package/skills/streams-and-websockets/SKILL.md +32 -14
  130. package/skills/tailwind/SKILL.md +37 -33
  131. package/skills/testing/SKILL.md +68 -32
  132. package/skills/testing/cache-prerender.md +55 -29
  133. package/skills/testing/client-components.md +59 -6
  134. package/skills/testing/e2e-parity.md +12 -2
  135. package/skills/testing/flight.md +19 -11
  136. package/skills/testing/handles.md +8 -5
  137. package/skills/testing/loader.md +8 -5
  138. package/skills/testing/middleware.md +8 -3
  139. package/skills/testing/predicates.md +120 -0
  140. package/skills/testing/render-handler.md +70 -22
  141. package/skills/testing/response-routes.md +11 -4
  142. package/skills/testing/reverse-and-types.md +20 -14
  143. package/skills/testing/server-actions.md +12 -3
  144. package/skills/testing/server-tree.md +9 -0
  145. package/skills/testing/setup.md +9 -5
  146. package/skills/theme/SKILL.md +106 -29
  147. package/skills/typesafety/SKILL.md +20 -9
  148. package/skills/typesafety/env-and-bindings.md +126 -47
  149. package/skills/typesafety/generated-files-and-cli.md +26 -14
  150. package/skills/typesafety/params-and-search.md +44 -19
  151. package/skills/typesafety/route-types.md +50 -14
  152. package/skills/use-cache/SKILL.md +200 -82
  153. package/skills/vercel/SKILL.md +21 -11
  154. package/skills/view-transitions/SKILL.md +41 -20
  155. package/src/__augment-tests__/augmented.check.ts +26 -1
  156. package/src/browser/index.ts +2 -2
  157. package/src/browser/navigation-bridge.ts +3 -4
  158. package/src/browser/navigation-transaction.ts +22 -2
  159. package/src/browser/partial-update.ts +35 -25
  160. package/src/browser/react/ScrollRestoration.tsx +6 -6
  161. package/src/browser/rsc-router.tsx +1 -1
  162. package/src/browser/scroll-restoration.ts +53 -6
  163. package/src/cache/cache-exec-scope.ts +37 -4
  164. package/src/cache/cache-key-utils.ts +1 -6
  165. package/src/cache/cache-runtime.ts +230 -61
  166. package/src/cache/cache-scope.ts +76 -12
  167. package/src/cache/document-cache.ts +15 -0
  168. package/src/cache/handle-capture.ts +90 -10
  169. package/src/cache/handle-snapshot.ts +132 -28
  170. package/src/cache/segment-codec.ts +26 -7
  171. package/src/cache/tag-invalidation.ts +4 -3
  172. package/src/cache/taint.ts +1 -1
  173. package/src/cache/types.ts +16 -3
  174. package/src/client.rsc.tsx +10 -9
  175. package/src/client.tsx +5 -4
  176. package/src/components/DefaultDocument.tsx +7 -2
  177. package/src/context-var.ts +6 -5
  178. package/src/escape-script.ts +1 -1
  179. package/src/handles/MetaTags.tsx +13 -11
  180. package/src/handles/Scripts.tsx +11 -10
  181. package/src/handles/meta.ts +2 -2
  182. package/src/handles/script.ts +17 -16
  183. package/src/href-client.ts +1 -1
  184. package/src/html.ts +12 -0
  185. package/src/index.rsc.ts +5 -0
  186. package/src/index.ts +1 -0
  187. package/src/loader-store.ts +7 -4
  188. package/src/prerender/build-shell-capture.ts +8 -11
  189. package/src/reverse.ts +44 -4
  190. package/src/route-definition/dsl-helpers.ts +117 -30
  191. package/src/route-definition/helpers-types.ts +33 -9
  192. package/src/route-definition/resolve-handler-use.ts +121 -6
  193. package/src/route-types.ts +0 -3
  194. package/src/router/debug-manifest.ts +10 -2
  195. package/src/router/error-handling.ts +51 -15
  196. package/src/router/handler-context.ts +34 -25
  197. package/src/router/intercept-resolution.ts +125 -39
  198. package/src/router/loader-resolution.ts +56 -11
  199. package/src/router/match-api.ts +12 -17
  200. package/src/router/match-middleware/background-revalidation.ts +161 -96
  201. package/src/router/match-middleware/cache-lookup.ts +93 -10
  202. package/src/router/match-middleware/cache-store.ts +17 -90
  203. package/src/router/middleware-types.ts +3 -5
  204. package/src/router/middleware.ts +52 -9
  205. package/src/router/prerender-match.ts +53 -19
  206. package/src/router/route-snapshot.ts +11 -1
  207. package/src/router/router-context.ts +2 -0
  208. package/src/router/router-interfaces.ts +25 -2
  209. package/src/router/router-options.ts +8 -5
  210. package/src/router/segment-resolution/fresh.ts +44 -12
  211. package/src/router/segment-resolution/loader-cache.ts +205 -45
  212. package/src/router/segment-resolution/loader-mask.ts +12 -22
  213. package/src/router/segment-resolution/loader-snapshot.ts +6 -6
  214. package/src/router/segment-resolution/mask-nested.ts +7 -7
  215. package/src/router/segment-resolution/revalidation.ts +34 -6
  216. package/src/router/segment-wrappers.ts +4 -0
  217. package/src/rsc/render-pipeline.ts +3 -0
  218. package/src/rsc/response-route-handler.ts +11 -3
  219. package/src/rsc/rsc-rendering.ts +8 -45
  220. package/src/rsc/shell-capture-constants.ts +13 -0
  221. package/src/rsc/shell-capture.ts +111 -90
  222. package/src/rsc/ssr-setup.ts +14 -8
  223. package/src/rsc/types.ts +11 -0
  224. package/src/server/context.ts +88 -11
  225. package/src/server/cookie-store.ts +5 -4
  226. package/src/server/handle-store.ts +232 -7
  227. package/src/server/request-context.ts +35 -30
  228. package/src/ssr/index.tsx +40 -7
  229. package/src/static-handler.ts +4 -1
  230. package/src/testing/flight-runtime.d.ts +21 -0
  231. package/src/testing/generated-routes.ts +2 -2
  232. package/src/testing/internal/context.ts +1 -1
  233. package/src/testing/internal/flight-client-globals.ts +7 -5
  234. package/src/testing/render-route.tsx +85 -24
  235. package/src/testing/run-loader.ts +5 -1
  236. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  237. package/src/testing/vitest.ts +62 -1
  238. package/src/theme/ThemeScript.tsx +3 -3
  239. package/src/types/cache-types.ts +14 -10
  240. package/src/types/handler-context.ts +14 -0
  241. package/src/types/loader-types.ts +8 -6
  242. package/src/urls/response-types.ts +3 -23
  243. package/src/use-loader.tsx +12 -3
  244. package/src/vite/plugins/use-cache-transform.ts +118 -74
  245. package/src/vite/rango.ts +7 -3
@@ -129,6 +129,19 @@ function shouldCacheResponse(response: Response): CacheDirectives | null {
129
129
  return parseCacheControl(cacheControl);
130
130
  }
131
131
 
132
+ /**
133
+ * Throw the first error the drained render reported through Flight or Fizz
134
+ * onError. A component that throws after the 200 committed does not fail the
135
+ * stream: it completes with an error row or an errored Suspense boundary, and
136
+ * every hit would serve it (issue #915). The caller's catch reports the skipped
137
+ * write as cache-write. Read only after the body is fully drained, when both
138
+ * renders have finished.
139
+ */
140
+ function throwIfRenderErrored(requestCtx: RequestContext | undefined): void {
141
+ const errors = requestCtx?._renderErrors;
142
+ if (errors && errors.length > 0) throw errors[0];
143
+ }
144
+
132
145
  // ============================================================================
133
146
  // Response Helpers
134
147
  // ============================================================================
@@ -404,6 +417,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
404
417
  // drain the fresh render fully before snapshotting tags
405
418
  // (same render-complete barrier as the miss path).
406
419
  const body = await new Response(fresh.body).arrayBuffer();
420
+ throwIfRenderErrored(requestCtx);
407
421
  await store.putResponse!(
408
422
  cacheKey,
409
423
  new Response(body, fresh),
@@ -468,6 +482,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
468
482
  // unaffected) is the render-complete barrier that keeps the cached
469
483
  // body and its tag set consistent.
470
484
  const body = await new Response(cacheStream).arrayBuffer();
485
+ throwIfRenderErrored(requestCtx);
471
486
  await store.putResponse!(
472
487
  cacheKey,
473
488
  new Response(body, originalResponse),
@@ -6,10 +6,82 @@
6
6
  * pulling in @vitejs/plugin-rsc/rsc dependencies.
7
7
  */
8
8
 
9
+ import { getCurrentLoaderBodyId } from "../server/context.js";
9
10
  import type { HandleStore } from "../server/handle-store.js";
10
11
  import type { SegmentHandleData } from "./types.js";
11
12
 
12
- export interface HandleCapture {
13
+ /**
14
+ * Scoping for one capture of a cached unit (a loader's own cache(),
15
+ * loader-cache.ts; a "use cache" function, cache-runtime.ts) that runs
16
+ * concurrently with live pushes into the same store.
17
+ */
18
+ export interface HandleCaptureOptions {
19
+ /** Push-time predicate; a rejected push is not recorded by this capture. */
20
+ accept?: () => boolean;
21
+ /**
22
+ * Recorded pushes do not reach the store. A stale hit's background
23
+ * revalidation: the foreground already replayed the entry's pushes, so the
24
+ * fresh body's pushes belong only to the refreshed entry.
25
+ */
26
+ divert?: boolean;
27
+ /**
28
+ * Record key for an accepted push, in place of its segment id. A loader's
29
+ * own cache() (loader-cache.ts recordOwnerKey) and "use cache"
30
+ * (useCacheRecordKey) group pushes by the loader body that made them.
31
+ */
32
+ key?: () => string;
33
+ }
34
+
35
+ // The loader whose recorded push a "use cache" HIT is replaying
36
+ // (replayLoaderPush), for an enclosing "use cache" MISS's record key.
37
+ let replayingOwner: string | undefined;
38
+
39
+ /**
40
+ * HandleStore.pushReplayed, attributed to `loaderId` for the record key of an
41
+ * enclosing "use cache" MISS: an outer cached function records an inner HIT's
42
+ * replayed loader pushes under that loader, not as its own.
43
+ */
44
+ export function replayLoaderPush(
45
+ handleStore: HandleStore,
46
+ handleName: string,
47
+ segmentId: string,
48
+ value: unknown,
49
+ loaderId: string,
50
+ ): void {
51
+ const prev = replayingOwner;
52
+ replayingOwner = loaderId;
53
+ try {
54
+ handleStore.pushReplayed(handleName, segmentId, value, loaderId);
55
+ } finally {
56
+ replayingOwner = prev;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Record key for a "use cache" capture: run-length groups `${seq}:${owner}`,
62
+ * so the HIT (handle-snapshot.ts appendHandles) delivers each loader's pushes
63
+ * at most once per request. The owner is the loader whose body, entered
64
+ * inside this execution via ctx.use, made the push; empty for the function's
65
+ * own pushes and those of cached functions it calls. Groups keep push order
66
+ * across owners. Create at call time: the caller's loader body is the
67
+ * function's own scope.
68
+ */
69
+ export function useCacheRecordKey(): () => string {
70
+ const callerBody = getCurrentLoaderBodyId();
71
+ let seq = 0;
72
+ let last: string | undefined;
73
+ return () => {
74
+ const body = replayingOwner ?? getCurrentLoaderBodyId();
75
+ const owner = body === callerBody ? "" : (body ?? "");
76
+ if (owner !== last) {
77
+ last = owner;
78
+ seq++;
79
+ }
80
+ return `${seq}:${owner}`;
81
+ };
82
+ }
83
+
84
+ export interface HandleCapture extends HandleCaptureOptions {
13
85
  data: Record<string, SegmentHandleData>;
14
86
  }
15
87
 
@@ -42,33 +114,41 @@ function ensureInterceptorInstalled(handleStore: HandleStore): void {
42
114
  segmentId: string,
43
115
  value: unknown,
44
116
  ) => {
117
+ let diverted = false;
45
118
  for (const capture of captures) {
46
- if (!capture.data[segmentId]) {
47
- capture.data[segmentId] = {};
119
+ if (capture.accept && !capture.accept()) continue;
120
+ if (capture.divert) diverted = true;
121
+ const key = capture.key ? capture.key() : segmentId;
122
+ if (!capture.data[key]) {
123
+ capture.data[key] = {};
48
124
  }
49
- if (!capture.data[segmentId][handleName]) {
50
- capture.data[segmentId][handleName] = [];
125
+ if (!capture.data[key][handleName]) {
126
+ capture.data[key][handleName] = [];
51
127
  }
52
- capture.data[segmentId][handleName].push(value);
128
+ capture.data[key][handleName].push(value);
53
129
  }
54
- originalPush(handleName, segmentId, value);
130
+ if (!diverted) originalPush(handleName, segmentId, value);
55
131
  };
56
132
  }
57
133
 
58
134
  /**
59
- * Start capturing handle pushes for a cached function execution.
135
+ * Start capturing handle pushes for a cached function or cached loader
136
+ * execution (`options`: see HandleCaptureOptions).
60
137
  *
61
138
  * Concurrency-safe: multiple overlapping captures on the same
62
139
  * HandleStore are independent. Each capture registers a token in a
63
140
  * Set; stopping removes it. No ordering requirement (LIFO not needed).
64
141
  */
65
- export function startHandleCapture(handleStore: HandleStore): {
142
+ export function startHandleCapture(
143
+ handleStore: HandleStore,
144
+ options?: HandleCaptureOptions,
145
+ ): {
66
146
  capture: HandleCapture;
67
147
  stop: () => void;
68
148
  } {
69
149
  ensureInterceptorInstalled(handleStore);
70
150
 
71
- const capture: HandleCapture = { data: {} };
151
+ const capture: HandleCapture = { data: {}, ...options };
72
152
  const captures = activeCapturesMap.get(handleStore)!;
73
153
  captures.add(capture);
74
154
 
@@ -6,9 +6,10 @@
6
6
  * during segment resolution and stored alongside cached segments.
7
7
  */
8
8
 
9
+ import { replayLoaderPush } from "./handle-capture.js";
9
10
  import type { ResolvedSegment } from "../types.js";
10
11
  import type { HandleStore } from "../server/handle-store.js";
11
- import type { SegmentHandleData } from "./types.js";
12
+ import type { HandleOwners, SegmentHandleData } from "./types.js";
12
13
  // segment-codec eagerly pulls @vitejs/plugin-rsc (a virtual: module unresolvable
13
14
  // in plain node/vitest). It is imported LAZILY inside the two async encode/decode
14
15
  // helpers below so that modules which import handle-snapshot only for the
@@ -47,19 +48,32 @@ function withTimeout<T>(p: Promise<T>, ms: number, onTimeout: T): Promise<T> {
47
48
  ]);
48
49
  }
49
50
 
50
- export async function encodeHandles(handles: HandleRecord): Promise<string> {
51
+ /**
52
+ * `onError` reaches the Flight encode (see serializeResult). A cache writer
53
+ * passes the collector it gives the value's encode, so a handle value that
54
+ * fails to encode (a rejected pushed promise) refuses the whole entry like a
55
+ * failed value does; prerender (prerender-match.ts) passes one too and
56
+ * hands the error to its prerender.onError policy.
57
+ */
58
+ export async function encodeHandles(
59
+ handles: HandleRecord,
60
+ onError?: (error: unknown) => void,
61
+ ): Promise<string> {
51
62
  if (!hasHandleData(handles)) return "";
52
- return encodeHandleValue(handles);
63
+ return encodeHandleValue(handles, onError);
53
64
  }
54
65
 
55
66
  export function decodeHandles(encoded: string): Promise<HandleRecord | null> {
56
67
  return decodeHandleValue<HandleRecord>(encoded);
57
68
  }
58
69
 
59
- export async function encodeHandleValue(value: unknown): Promise<string> {
70
+ export async function encodeHandleValue(
71
+ value: unknown,
72
+ onError?: (error: unknown) => void,
73
+ ): Promise<string> {
60
74
  const { serializeResult } = await import("./segment-codec.js");
61
75
  const encoded = await withTimeout(
62
- serializeResult(value),
76
+ serializeResult(value, onError),
63
77
  HANDLE_ENCODE_TIMEOUT_MS,
64
78
  null,
65
79
  );
@@ -84,49 +98,139 @@ export async function decodeHandleValue<T>(encoded: string): Promise<T | null> {
84
98
  * Capture handle data for a set of segments from the handle store.
85
99
  * Used when caching segments to preserve their handle data.
86
100
  *
87
- * `exclude` (shell captures: RequestContext._shellCaptureLoaderHandleValues)
88
- * drops DSL-loader-scoped push values from the CACHE WRITE only: loaders
89
- * re-run fresh on every HIT, so replaying their captured values would
90
- * duplicate the fresh push — and their masked nested promises would stall the
91
- * Flight handle encode to its timeout. Threaded as an explicit argument so
92
- * every other getDataForSegment consumer (the render-barrier snapshot,
93
- * prerender) provably sees every push.
101
+ * DSL-loader pushes stay out of the CACHE WRITE: a HIT runs loaders exactly
102
+ * as an uncached render of the same request would (cache-lookup.ts
103
+ * resolveFreshLoadersAndYield), so replaying a recorded copy would duplicate
104
+ * the live push. Handler pushes, including
105
+ * handler-invoked ctx.use(Loader) bodies (skipped with their handler on a
106
+ * HIT), are kept. The store tags loader pushes by array position at push
107
+ * time, so primitive values are covered. A PPR shell capture untags the
108
+ * settled bake-lane pushes its record keeps (shell-capture.ts) and gives
109
+ * them an owner (captureHandleOwners). Only this call site passes
110
+ * excludeLoaderPushes; every other getDataForSegment consumer (the
111
+ * render-barrier snapshot, prerender) sees every push.
94
112
  */
95
113
  export function captureHandles(
96
114
  segments: ResolvedSegment[],
97
115
  handleStore: HandleStore,
98
- exclude?: WeakSet<object>,
99
116
  ): Record<string, SegmentHandleData> {
100
117
  const handles: Record<string, SegmentHandleData> = {};
101
118
  for (const seg of segments) {
102
- const data = handleStore.getDataForSegment(seg.id);
103
- if (!exclude) {
104
- handles[seg.id] = data;
105
- continue;
106
- }
107
- const filtered: SegmentHandleData = {};
108
- for (const [handleName, values] of Object.entries(data)) {
109
- const kept = values.filter(
110
- (v) => typeof v !== "object" || v === null || !exclude.has(v),
111
- );
112
- if (kept.length > 0) filtered[handleName] = kept;
113
- }
114
- handles[seg.id] = filtered;
119
+ handles[seg.id] = handleStore.getDataForSegment(seg.id, true);
115
120
  }
116
121
  return handles;
117
122
  }
118
123
 
124
+ /** The owners of captureHandles' values; undefined when none is owned. */
125
+ export function captureHandleOwners(
126
+ segments: ResolvedSegment[],
127
+ handleStore: HandleStore,
128
+ ): HandleOwners | undefined {
129
+ let owners: HandleOwners | undefined;
130
+ for (const seg of segments) {
131
+ const segOwners = handleStore.getRecordOwners(seg.id);
132
+ if (segOwners) (owners ??= {})[seg.id] = segOwners;
133
+ }
134
+ return owners;
135
+ }
136
+
119
137
  /**
120
138
  * Restore handle data from a cached snapshot into the handle store.
121
- * Used when serving cached segments to replay their handle data.
139
+ * Used when serving cached segments to replay their handle data: a route
140
+ * cache() record owns its segments' arrays, so it REPLACES them.
141
+ *
142
+ * A segment with `owners` is replaced with empty arrays and re-pushed in
143
+ * recorded order, an owned value through pushReplayed: the owning loader
144
+ * re-runs on the HIT (loaders stay live), and its live pushes replace the
145
+ * recorded ones in place instead of appending a second copy. `claim`
146
+ * (setupLoaderAccess _claimLoaderPushes) is asked once per owner, so the
147
+ * loader's own cache() HIT does not replay its pushes a second time
148
+ * (loader-cache.ts replayLoaderHandles); an owner it refuses is skipped. A
149
+ * record without owners restores as a plain replay.
122
150
  */
123
151
  export function restoreHandles(
124
152
  handles: Record<string, SegmentHandleData>,
125
153
  handleStore: HandleStore,
154
+ owners?: HandleOwners,
155
+ claim?: (loaderId: string) => boolean,
126
156
  ): void {
157
+ let delivers: Map<string, boolean> | undefined;
127
158
  for (const [segId, segHandles] of Object.entries(handles)) {
128
- if (Object.keys(segHandles).length > 0) {
159
+ if (Object.keys(segHandles).length === 0) continue;
160
+ const segOwners = owners?.[segId];
161
+ if (!segOwners) {
129
162
  handleStore.replaySegmentData(segId, segHandles);
163
+ continue;
164
+ }
165
+ const cleared: SegmentHandleData = {};
166
+ for (const handleName in segHandles) cleared[handleName] = [];
167
+ handleStore.replaySegmentData(segId, cleared);
168
+ for (const [handleName, values] of Object.entries(segHandles)) {
169
+ const valueOwners = segOwners[handleName];
170
+ for (let i = 0; i < values.length; i++) {
171
+ const owner = valueOwners?.[i];
172
+ if (!owner) {
173
+ handleStore.push(handleName, segId, values[i]);
174
+ continue;
175
+ }
176
+ delivers ??= new Map();
177
+ let deliver = delivers.get(owner);
178
+ if (deliver === undefined) {
179
+ deliver = claim ? claim(owner) : true;
180
+ delivers.set(owner, deliver);
181
+ }
182
+ if (deliver) {
183
+ handleStore.pushReplayed(handleName, segId, values[i], owner);
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Append recorded handle pushes to the store, in recorded order, via push()
192
+ * so active captures and loader-scope tagging see them like live pushes. For
193
+ * a cached unit that shares its segments with live pushes ("use cache");
194
+ * restoreHandles would wipe those. `segmentId` redirects every value to one
195
+ * segment (the caller's).
196
+ *
197
+ * Owner-keyed groups (useCacheRecordKey, `${seq}:${loaderId}`) are a loader's
198
+ * pushes, which reach the page once per request: `claim` (setupLoaderAccess
199
+ * _claimLoaderPushes) skips a loader that already ran or was replayed in this
200
+ * request, and a claimed loader that runs later replaces its replayed values
201
+ * (HandleStore.pushReplayed). `${seq}:` groups are the function's own pushes.
202
+ * A key without ":" is a segment id (records written before owner keys) and
203
+ * replays unchanged. A loader's own cache() replays through loader-cache.ts
204
+ * replayLoaderHandles.
205
+ */
206
+ export function appendHandles(
207
+ handles: Record<string, SegmentHandleData>,
208
+ handleStore: HandleStore,
209
+ segmentId?: string,
210
+ claim?: (loaderId: string) => boolean,
211
+ ): void {
212
+ let claims: Map<string, boolean> | undefined;
213
+ for (const [key, segHandles] of Object.entries(handles)) {
214
+ const colon = key.indexOf(":");
215
+ const owner = colon < 0 ? "" : key.slice(colon + 1);
216
+ if (owner) {
217
+ claims ??= new Map();
218
+ let deliver = claims.get(owner);
219
+ if (deliver === undefined) {
220
+ deliver = claim ? claim(owner) : true;
221
+ claims.set(owner, deliver);
222
+ }
223
+ if (!deliver) continue;
224
+ }
225
+ const target = segmentId ?? key;
226
+ for (const [handleName, values] of Object.entries(segHandles)) {
227
+ for (const value of values) {
228
+ if (owner) {
229
+ replayLoaderPush(handleStore, handleName, target, value, owner);
230
+ } else {
231
+ handleStore.push(handleName, target, value);
232
+ }
233
+ }
130
234
  }
131
235
  }
132
236
  }
@@ -69,11 +69,15 @@ export function stringToStream(str: string): ReadableStream<Uint8Array> {
69
69
  */
70
70
  export async function rscSerialize(
71
71
  value: unknown,
72
+ onError?: (error: unknown) => void,
72
73
  ): Promise<string | undefined> {
73
74
  if (value === undefined || value === null) return undefined;
74
75
 
75
76
  const temporaryReferences = createTemporaryReferenceSet();
76
- const stream = renderToReadableStream(value, { temporaryReferences });
77
+ const stream = renderToReadableStream(value, {
78
+ temporaryReferences,
79
+ onError,
80
+ });
77
81
  return streamToString(stream);
78
82
  }
79
83
 
@@ -102,10 +106,16 @@ export async function rscDeserialize<T>(
102
106
  *
103
107
  * Returns null only on serialization failure.
104
108
  */
105
- export async function serializeResult(value: unknown): Promise<string | null> {
109
+ export async function serializeResult(
110
+ value: unknown,
111
+ onError?: (error: unknown) => void,
112
+ ): Promise<string | null> {
106
113
  try {
107
114
  const temporaryReferences = createTemporaryReferenceSet();
108
- const stream = renderToReadableStream(value, { temporaryReferences });
115
+ const stream = renderToReadableStream(value, {
116
+ temporaryReferences,
117
+ onError,
118
+ });
109
119
  return await streamToString(stream);
110
120
  } catch (error) {
111
121
  // Returning null silently turns a non-serializable cache value into a
@@ -145,9 +155,17 @@ export const deserializeComponent: (encoded: string) => Promise<unknown> =
145
155
  * Serialize segments for storage.
146
156
  * Each segment's component, layout, loading, and loaderData are RSC-serialized.
147
157
  * Metadata is preserved as-is.
158
+ *
159
+ * `onError` goes to every Flight encode. A component that throws while
160
+ * encoding (e.g. an async server component in the tree) does not reject the
161
+ * encode: Flight reports it through onError and writes an error row
162
+ * (`1:E{...}`) that throws when the decoded tree renders. cacheRoute and the
163
+ * prerender producers (prerender-match.ts) pass it to refuse such an entry;
164
+ * without it the encode is unchanged.
148
165
  */
149
166
  export async function serializeSegments(
150
167
  segments: ResolvedSegment[],
168
+ onError?: (error: unknown) => void,
151
169
  ): Promise<SerializedSegmentData[]> {
152
170
  return Promise.all(
153
171
  segments.map(async (segment): Promise<SerializedSegmentData> => {
@@ -162,6 +180,7 @@ export async function serializeSegments(
162
180
  // Serialize the component to RSC stream
163
181
  const stream = renderToReadableStream(componentResolved, {
164
182
  temporaryReferences,
183
+ onError,
165
184
  });
166
185
 
167
186
  // RSC-serialize loading: "null" string distinguishes explicit null from undefined
@@ -169,7 +188,7 @@ export async function serializeSegments(
169
188
  segment.loading !== undefined
170
189
  ? segment.loading === null
171
190
  ? "null"
172
- : await rscSerialize(segment.loading)
191
+ : await rscSerialize(segment.loading, onError)
173
192
  : undefined;
174
193
 
175
194
  // Await loaderData / loaderDataPromise if they're Promises
@@ -190,9 +209,9 @@ export async function serializeSegments(
190
209
  encodedLoaderDataPromise,
191
210
  ] = await Promise.all([
192
211
  streamToString(stream),
193
- segment.layout ? rscSerialize(segment.layout) : undefined,
194
- rscSerialize(loaderDataResolved),
195
- rscSerialize(loaderDataPromiseResolved),
212
+ segment.layout ? rscSerialize(segment.layout, onError) : undefined,
213
+ rscSerialize(loaderDataResolved, onError),
214
+ rscSerialize(loaderDataPromiseResolved, onError),
196
215
  ]);
197
216
 
198
217
  return {
@@ -68,8 +68,8 @@ function warnNoTagStore(fn: string, tags: string[]): void {
68
68
  console.warn(
69
69
  `[${fn}] No tag-capable cache store is configured; tags ` +
70
70
  `[${tags.join(", ")}] were not invalidated. The configured store must ` +
71
- `implement invalidateTags() (the built-in MemorySegmentCacheStore and ` +
72
- `CFCacheStore do).`,
71
+ `implement invalidateTags() (the built-in MemorySegmentCacheStore, ` +
72
+ `CFCacheStore, and VercelCacheStore do).`,
73
73
  );
74
74
  }
75
75
 
@@ -98,7 +98,8 @@ function warnPartialTagStore(fn: string, incapable: number): void {
98
98
  console.warn(
99
99
  `[${fn}] ${incapable} configured cache store(s) do not implement ` +
100
100
  `invalidateTags(); their tagged entries were NOT invalidated. Use a ` +
101
- `tag-capable store (e.g. MemorySegmentCacheStore / CFCacheStore) for any ` +
101
+ `tag-capable store (MemorySegmentCacheStore / CFCacheStore / ` +
102
+ `VercelCacheStore) for any ` +
102
103
  `cache({ store }) boundary whose entries you invalidate by tag.`,
103
104
  );
104
105
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Taint symbol for request-scoped objects.
3
3
  *
4
- * Objects branded with NOCACHE_SYMBOL (ctx, env, req) are excluded from
4
+ * Objects branded with NOCACHE_SYMBOL (ctx objects) are excluded from
5
5
  * "use cache" cache keys and trigger handle capture mode so that side
6
6
  * effects (breadcrumbs, metadata) are recorded and replayed on cache hit.
7
7
  */
@@ -424,9 +424,8 @@ export type DocumentShellCacheEntry = ShellCacheEntry & {
424
424
  /**
425
425
  * The families a shell snapshot pins. The item/segment/response families are
426
426
  * cache-store reads/writes (recorded by RecordingShellStore); the loader family
427
- * pins the settled CONTAINER of a bake-lane loader (a loader on an entry with
428
- * no renderable loading(), executed during capture — see
429
- * docs/design/loader-container-bake.md). Excludes the shell family itself
427
+ * pins the settled CONTAINER of a bake-lane loader (lane rule: see
428
+ * resolveLoaderData, loader-cache.ts). Excludes the shell family itself
430
429
  * (getShell/putShell) — the snapshot rides INSIDE a shell entry, so recording
431
430
  * it would be self-referential.
432
431
  */
@@ -542,6 +541,11 @@ export interface CachedEntryData {
542
541
  * (see handle-snapshot.ts encodeHandles) so Promise/ReactNode handle values
543
542
  * round-trip through JSON-serializing stores instead of being flattened. */
544
543
  handles: string;
544
+ /**
545
+ * Owning loader ids index-aligned with the decoded `handles` values
546
+ * (HandleOwners). Absent: every value restores as a plain replay.
547
+ */
548
+ handleOwners?: HandleOwners;
545
549
  /** Expiration timestamp (ms since epoch) */
546
550
  expiresAt: number;
547
551
  /** Cache tags for invalidation */
@@ -583,3 +587,12 @@ export interface CacheDefaults {
583
587
  * Structure: { handleName: [values...] }
584
588
  */
585
589
  export type SegmentHandleData = Record<string, unknown[]>;
590
+
591
+ /**
592
+ * segmentId -> handleName -> the owning loader id of each recorded value, by
593
+ * index (null: not owned). Only a PPR shell capture writes owners: the
594
+ * settled pushes of an `ssr: false` loader's own body, which the record keeps
595
+ * because the prelude rendered them. restoreHandles replays an owned value
596
+ * through HandleStore.pushReplayed, so that loader's live re-run replaces it.
597
+ */
598
+ export type HandleOwners = Record<string, Record<string, (string | null)[]>>;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * RSC-environment version of client exports
3
3
  *
4
- * This file is used when importing "rsc-router/client" from RSC (server components).
4
+ * This file is used when importing "@rangojs/router/client" from RSC (server components).
5
5
  * It re-exports the server's createLoader so that loader definitions work in both
6
6
  * environments with the same import.
7
7
  *
@@ -9,6 +9,10 @@
9
9
  * in RSC context, while the regular client.tsx is used in client components.
10
10
  */
11
11
 
12
+ // From html.ts directly, never via ./client.js: every export of that
13
+ // "use client" module is an opaque client reference here.
14
+ import * as Html from "./html.js";
15
+
12
16
  // Re-export everything from client.tsx (Outlet, useLoader, etc.)
13
17
  // These are safe to use in RSC context
14
18
  export {
@@ -64,7 +68,10 @@ export {
64
68
  useLinkStatus,
65
69
  type LinkStatus,
66
70
  } from "./browser/react/use-link-status.js";
67
- export { useScrollRestoration } from "./browser/react/ScrollRestoration.js";
71
+ export {
72
+ useScrollRestoration,
73
+ type ScrollRestorationProps,
74
+ } from "./browser/react/ScrollRestoration.js";
68
75
 
69
76
  export {
70
77
  Link,
@@ -72,11 +79,6 @@ export {
72
79
  type PrefetchStrategy,
73
80
  } from "./browser/react/Link.js";
74
81
 
75
- export {
76
- ScrollRestoration,
77
- type ScrollRestorationProps,
78
- } from "./browser/react/ScrollRestoration.js";
79
-
80
82
  export {
81
83
  NavigationProvider,
82
84
  type NavigationProviderProps,
@@ -91,14 +93,13 @@ export { MountContext } from "./browser/react/mount-context.js";
91
93
  export { createHandle, isHandle, type Handle } from "./handle.js";
92
94
 
93
95
  export { Meta } from "./handles/meta.js";
94
- export { MetaTags } from "./handles/MetaTags.js";
95
96
  export type { MetaDescriptor, MetaDescriptorBase } from "./router/types.js";
96
97
  export {
97
98
  Script,
98
99
  type ScriptConfig,
99
100
  type ScriptAttributes,
100
101
  } from "./handles/script.js";
101
- export { Scripts } from "./handles/Scripts.js";
102
+ export { Html };
102
103
  export { Breadcrumbs, type BreadcrumbItem } from "./handles/breadcrumbs.js";
103
104
 
104
105
  export {
package/src/client.tsx CHANGED
@@ -8,6 +8,9 @@ import {
8
8
  Suspense,
9
9
  type ReactNode,
10
10
  } from "react";
11
+ // Not `export * as Html`: plugin-rsc's "use client" proxy transform only
12
+ // expands bare `export *` and throws on a named one.
13
+ import * as Html from "./html.js";
11
14
  import { OutletContext, type OutletContextValue } from "./outlet-context.js";
12
15
  import {
13
16
  type ClientErrorBoundaryFallbackProps,
@@ -300,7 +303,7 @@ interface ErrorBoundaryState {
300
303
  * @example
301
304
  * ```tsx
302
305
  * "use client";
303
- * import { ErrorBoundary } from "rsc-router/client";
306
+ * import { ErrorBoundary } from "@rangojs/router/client";
304
307
  *
305
308
  * function MyComponent() {
306
309
  * return (
@@ -427,7 +430,6 @@ export {
427
430
  } from "./browser/react/use-link-status.js";
428
431
 
429
432
  export {
430
- ScrollRestoration,
431
433
  useScrollRestoration,
432
434
  type ScrollRestorationProps,
433
435
  } from "./browser/react/ScrollRestoration.js";
@@ -436,14 +438,13 @@ export { type Handle } from "./handle.js";
436
438
  export { useHandle } from "./browser/react/use-handle.js";
437
439
 
438
440
  export { Meta } from "./handles/meta.js";
439
- export { MetaTags } from "./handles/MetaTags.js";
440
441
  export type { MetaDescriptor, MetaDescriptorBase } from "./router/types.js";
441
442
  export {
442
443
  Script,
443
444
  type ScriptConfig,
444
445
  type ScriptAttributes,
445
446
  } from "./handles/script.js";
446
- export { Scripts } from "./handles/Scripts.js";
447
+ export { Html };
447
448
  export { Breadcrumbs, type BreadcrumbItem } from "./handles/breadcrumbs.js";
448
449
 
449
450
  export {
@@ -3,12 +3,16 @@
3
3
  import type { ReactNode, ReactElement } from "react";
4
4
  import { MetaTags } from "../handles/MetaTags.js";
5
5
  import { Scripts } from "../handles/Scripts.js";
6
+ import { ScrollRestoration } from "../browser/react/ScrollRestoration.js";
6
7
 
7
8
  /**
8
9
  * Default document component that provides a basic HTML structure.
9
10
  * Used when no custom document is provided to createRouter.
10
- * Includes MetaTags for automatic charset, viewport, and route meta support,
11
- * and Scripts (head + body sites) so the Script handle works out of the box.
11
+ * Renders every Html member: Html.Meta (charset, viewport, route meta), head +
12
+ * body Html.Scripts sites for the Script handle, and Html.ScrollRestoration.
13
+ * An app that wants a custom getKey or no scroll restoration passes its own
14
+ * document; rendering a second Html.ScrollRestoration under this one only
15
+ * warns ("[Scroll] Already initialized").
12
16
  *
13
17
  * Uses suppressHydrationWarning on <html> because the theme script
14
18
  * may modify class/style attributes before React hydrates.
@@ -27,6 +31,7 @@ export function DefaultDocument({
27
31
  <body>
28
32
  <Scripts position="body" />
29
33
  {children}
34
+ <ScrollRestoration />
30
35
  </body>
31
36
  </html>
32
37
  );