@rangojs/router 0.16.0 → 0.18.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 (309) hide show
  1. package/README.md +255 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/cookie-name.d.ts +6 -0
  4. package/dist/types/browser/history-state.d.ts +16 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  6. package/dist/types/browser/partial-update.d.ts +11 -0
  7. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  8. package/dist/types/browser/rsc-router.d.ts +1 -1
  9. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  10. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  11. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  12. package/dist/types/cache/cache-policy.d.ts +17 -0
  13. package/dist/types/cache/cache-scope.d.ts +132 -19
  14. package/dist/types/cache/cache-tag.d.ts +92 -7
  15. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  18. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  19. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  20. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  21. package/dist/types/cache/handle-capture.d.ts +41 -3
  22. package/dist/types/cache/handle-snapshot.d.ts +54 -13
  23. package/dist/types/cache/index.d.ts +1 -0
  24. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  25. package/dist/types/cache/segment-codec.d.ts +10 -3
  26. package/dist/types/cache/shell-memo.d.ts +178 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  28. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  29. package/dist/types/cache/taint.d.ts +1 -1
  30. package/dist/types/cache/types.d.ts +220 -44
  31. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  32. package/dist/types/client.d.ts +4 -4
  33. package/dist/types/client.rsc.d.ts +4 -5
  34. package/dist/types/components/DefaultDocument.d.ts +5 -2
  35. package/dist/types/context-var.d.ts +6 -5
  36. package/dist/types/escape-script.d.ts +1 -1
  37. package/dist/types/handles/MetaTags.d.ts +5 -4
  38. package/dist/types/handles/Scripts.d.ts +8 -7
  39. package/dist/types/handles/meta.d.ts +2 -2
  40. package/dist/types/handles/script.d.ts +17 -16
  41. package/dist/types/href-client.d.ts +1 -1
  42. package/dist/types/html.d.ts +12 -0
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +3 -2
  45. package/dist/types/loader-store.d.ts +7 -4
  46. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  47. package/dist/types/reverse.d.ts +36 -4
  48. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  49. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  50. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  51. package/dist/types/route-types.d.ts +1 -1
  52. package/dist/types/router/debug-manifest.d.ts +3 -2
  53. package/dist/types/router/error-handling.d.ts +20 -0
  54. package/dist/types/router/handler-context.d.ts +6 -0
  55. package/dist/types/router/instrument.d.ts +8 -0
  56. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  57. package/dist/types/router/middleware-types.d.ts +9 -3
  58. package/dist/types/router/router-context.d.ts +3 -1
  59. package/dist/types/router/router-interfaces.d.ts +24 -2
  60. package/dist/types/router/router-options.d.ts +8 -5
  61. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  62. package/dist/types/router/segment-resolution/loader-cache.d.ts +66 -1
  63. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  64. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +23 -12
  65. package/dist/types/router/segment-resolution/mask-nested.d.ts +67 -26
  66. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  67. package/dist/types/router/segment-wrappers.d.ts +1 -1
  68. package/dist/types/rsc/full-payload.d.ts +14 -0
  69. package/dist/types/rsc/handler.d.ts +1 -1
  70. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  71. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  72. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  73. package/dist/types/rsc/shell-capture-constants.d.ts +61 -6
  74. package/dist/types/rsc/shell-capture.d.ts +151 -19
  75. package/dist/types/rsc/shell-serve.d.ts +140 -17
  76. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  77. package/dist/types/rsc/types.d.ts +17 -0
  78. package/dist/types/server/context.d.ts +139 -15
  79. package/dist/types/server/cookie-store.d.ts +21 -0
  80. package/dist/types/server/handle-store.d.ts +26 -2
  81. package/dist/types/server/request-context.d.ts +182 -53
  82. package/dist/types/ssr/index.d.ts +24 -0
  83. package/dist/types/ssr/ssr-root.d.ts +9 -8
  84. package/dist/types/static-handler.d.ts +4 -1
  85. package/dist/types/testing/flight.entry.d.ts +7 -0
  86. package/dist/types/testing/generated-routes.d.ts +2 -2
  87. package/dist/types/testing/internal/context.d.ts +1 -1
  88. package/dist/types/testing/render-route.d.ts +32 -6
  89. package/dist/types/testing/run-loader.d.ts +18 -3
  90. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  91. package/dist/types/testing/shell-status.d.ts +23 -10
  92. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  93. package/dist/types/testing/vitest.d.ts +35 -1
  94. package/dist/types/theme/ThemeScript.d.ts +3 -3
  95. package/dist/types/types/cache-types.d.ts +14 -10
  96. package/dist/types/types/handler-context.d.ts +28 -1
  97. package/dist/types/types/loader-types.d.ts +8 -6
  98. package/dist/types/urls/pattern-types.d.ts +22 -18
  99. package/dist/types/urls/response-types.d.ts +3 -12
  100. package/dist/types/use-loader.d.ts +12 -3
  101. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  102. package/dist/types/vite/rango.d.ts +1 -1
  103. package/dist/types/vite/router-discovery.d.ts +29 -2
  104. package/dist/vite/index.js +1420 -1348
  105. package/package.json +2 -2
  106. package/skills/api-client/SKILL.md +22 -10
  107. package/skills/breadcrumbs/SKILL.md +75 -22
  108. package/skills/bundle-analysis/SKILL.md +22 -22
  109. package/skills/cache-guide/SKILL.md +306 -124
  110. package/skills/caching/SKILL.md +530 -98
  111. package/skills/catalog.json +16 -16
  112. package/skills/client-urls/SKILL.md +65 -38
  113. package/skills/cloudflare/SKILL.md +87 -20
  114. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  115. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  116. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  117. package/skills/comparison/SKILL.md +5 -0
  118. package/skills/comparison/references/framework-comparison.md +75 -57
  119. package/skills/composability/SKILL.md +38 -29
  120. package/skills/css/SKILL.md +10 -13
  121. package/skills/debug-manifest/SKILL.md +74 -31
  122. package/skills/defer-hydration/SKILL.md +52 -31
  123. package/skills/deployment-caching/SKILL.md +11 -3
  124. package/skills/document-cache/SKILL.md +98 -28
  125. package/skills/fonts/SKILL.md +16 -8
  126. package/skills/handler-use/SKILL.md +55 -17
  127. package/skills/hooks/SKILL.md +28 -20
  128. package/skills/hooks/data.md +23 -3
  129. package/skills/hooks/handle-and-actions.md +34 -17
  130. package/skills/hooks/navigation.md +39 -10
  131. package/skills/hooks/outlets.md +13 -6
  132. package/skills/hooks/state.md +39 -15
  133. package/skills/hooks/urls.md +7 -5
  134. package/skills/host-router/SKILL.md +64 -12
  135. package/skills/i18n/SKILL.md +60 -34
  136. package/skills/intercept/SKILL.md +161 -26
  137. package/skills/layout/SKILL.md +106 -50
  138. package/skills/links/SKILL.md +51 -19
  139. package/skills/loader/SKILL.md +386 -128
  140. package/skills/middleware/SKILL.md +129 -26
  141. package/skills/migrate-nextjs/SKILL.md +156 -70
  142. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  143. package/skills/migrate-react-router/SKILL.md +51 -39
  144. package/skills/migrate-react-router/component-migration.md +28 -7
  145. package/skills/migrate-react-router/data-and-actions.md +21 -6
  146. package/skills/migrate-react-router/route-mapping.md +19 -10
  147. package/skills/mime-routes/SKILL.md +12 -7
  148. package/skills/observability/SKILL.md +92 -14
  149. package/skills/parallel/SKILL.md +88 -47
  150. package/skills/ppr/SKILL.md +866 -379
  151. package/skills/prerender/SKILL.md +210 -108
  152. package/skills/rango/SKILL.md +115 -65
  153. package/skills/react-compiler/SKILL.md +6 -5
  154. package/skills/response-routes/SKILL.md +133 -25
  155. package/skills/route/SKILL.md +170 -60
  156. package/skills/router-setup/SKILL.md +420 -240
  157. package/skills/scripts/SKILL.md +39 -21
  158. package/skills/server-actions/SKILL.md +231 -59
  159. package/skills/shell-manifest/SKILL.md +28 -14
  160. package/skills/streams-and-websockets/SKILL.md +32 -14
  161. package/skills/tailwind/SKILL.md +37 -33
  162. package/skills/testing/SKILL.md +73 -33
  163. package/skills/testing/cache-prerender.md +60 -33
  164. package/skills/testing/client-components.md +59 -6
  165. package/skills/testing/e2e-parity.md +12 -2
  166. package/skills/testing/flight.md +19 -11
  167. package/skills/testing/handles.md +8 -5
  168. package/skills/testing/loader.md +26 -6
  169. package/skills/testing/middleware.md +8 -3
  170. package/skills/testing/predicates.md +120 -0
  171. package/skills/testing/render-handler.md +70 -22
  172. package/skills/testing/response-routes.md +11 -4
  173. package/skills/testing/reverse-and-types.md +20 -14
  174. package/skills/testing/serve-shell-request.md +188 -0
  175. package/skills/testing/server-actions.md +12 -3
  176. package/skills/testing/server-tree.md +9 -0
  177. package/skills/testing/setup.md +9 -5
  178. package/skills/theme/SKILL.md +155 -30
  179. package/skills/typesafety/SKILL.md +20 -9
  180. package/skills/typesafety/env-and-bindings.md +137 -50
  181. package/skills/typesafety/generated-files-and-cli.md +26 -14
  182. package/skills/typesafety/params-and-search.md +44 -19
  183. package/skills/typesafety/route-types.md +50 -14
  184. package/skills/use-cache/SKILL.md +224 -83
  185. package/skills/vercel/SKILL.md +35 -13
  186. package/skills/view-transitions/SKILL.md +42 -21
  187. package/src/__augment-tests__/augmented.check.ts +26 -1
  188. package/src/browser/cookie-name.ts +23 -0
  189. package/src/browser/history-state.ts +23 -0
  190. package/src/browser/index.ts +2 -2
  191. package/src/browser/navigation-bridge.ts +3 -4
  192. package/src/browser/navigation-transaction.ts +22 -2
  193. package/src/browser/partial-update.ts +35 -25
  194. package/src/browser/react/ScrollRestoration.tsx +6 -6
  195. package/src/browser/rsc-router.tsx +6 -1
  196. package/src/browser/scroll-restoration.ts +53 -6
  197. package/src/cache/cache-exec-scope.ts +37 -4
  198. package/src/cache/cache-key-utils.ts +23 -8
  199. package/src/cache/cache-policy.ts +22 -0
  200. package/src/cache/cache-runtime.ts +264 -68
  201. package/src/cache/cache-scope.ts +419 -43
  202. package/src/cache/cache-tag.ts +375 -11
  203. package/src/cache/cf/cf-base64.ts +36 -4
  204. package/src/cache/cf/cf-cache-constants.ts +12 -0
  205. package/src/cache/cf/cf-cache-store.ts +990 -348
  206. package/src/cache/cf/cf-cache-types.ts +32 -4
  207. package/src/cache/cf/cf-shell-frame.ts +258 -0
  208. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  209. package/src/cache/document-cache.ts +15 -0
  210. package/src/cache/handle-capture.ts +90 -10
  211. package/src/cache/handle-snapshot.ts +133 -28
  212. package/src/cache/index.ts +2 -0
  213. package/src/cache/isolate-tag-memo.ts +308 -0
  214. package/src/cache/segment-codec.ts +26 -7
  215. package/src/cache/shell-memo.ts +341 -0
  216. package/src/cache/shell-snapshot.ts +217 -27
  217. package/src/cache/tag-invalidation.ts +108 -22
  218. package/src/cache/taint.ts +1 -1
  219. package/src/cache/types.ts +225 -44
  220. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  221. package/src/client.rsc.tsx +10 -9
  222. package/src/client.tsx +5 -4
  223. package/src/components/DefaultDocument.tsx +7 -2
  224. package/src/context-var.ts +6 -5
  225. package/src/escape-script.ts +1 -1
  226. package/src/handles/MetaTags.tsx +13 -11
  227. package/src/handles/Scripts.tsx +11 -10
  228. package/src/handles/meta.ts +2 -2
  229. package/src/handles/script.ts +17 -16
  230. package/src/href-client.ts +1 -1
  231. package/src/html.ts +12 -0
  232. package/src/index.rsc.ts +5 -0
  233. package/src/index.ts +1 -0
  234. package/src/loader-store.ts +7 -4
  235. package/src/prerender/build-shell-capture.ts +73 -22
  236. package/src/reverse.ts +44 -4
  237. package/src/route-definition/dsl-helpers.ts +121 -33
  238. package/src/route-definition/helpers-types.ts +33 -9
  239. package/src/route-definition/resolve-handler-use.ts +121 -6
  240. package/src/route-types.ts +0 -3
  241. package/src/router/debug-manifest.ts +10 -2
  242. package/src/router/error-handling.ts +51 -15
  243. package/src/router/handler-context.ts +71 -28
  244. package/src/router/instrument.ts +54 -2
  245. package/src/router/intercept-resolution.ts +141 -43
  246. package/src/router/loader-resolution.ts +115 -39
  247. package/src/router/match-api.ts +40 -26
  248. package/src/router/match-handlers.ts +5 -0
  249. package/src/router/match-middleware/background-revalidation.ts +161 -96
  250. package/src/router/match-middleware/cache-lookup.ts +110 -10
  251. package/src/router/match-middleware/cache-store.ts +48 -101
  252. package/src/router/middleware-types.ts +9 -6
  253. package/src/router/middleware.ts +75 -13
  254. package/src/router/prerender-match.ts +59 -19
  255. package/src/router/route-snapshot.ts +11 -1
  256. package/src/router/router-context.ts +2 -0
  257. package/src/router/router-interfaces.ts +25 -2
  258. package/src/router/router-options.ts +8 -5
  259. package/src/router/segment-resolution/fresh.ts +62 -21
  260. package/src/router/segment-resolution/helpers.ts +4 -2
  261. package/src/router/segment-resolution/loader-cache.ts +407 -76
  262. package/src/router/segment-resolution/loader-mask.ts +12 -22
  263. package/src/router/segment-resolution/loader-snapshot.ts +208 -56
  264. package/src/router/segment-resolution/mask-nested.ts +138 -44
  265. package/src/router/segment-resolution/revalidation.ts +61 -16
  266. package/src/router/segment-wrappers.ts +4 -0
  267. package/src/rsc/full-payload.ts +25 -1
  268. package/src/rsc/handler.ts +14 -2
  269. package/src/rsc/progressive-enhancement.ts +3 -2
  270. package/src/rsc/render-pipeline.ts +3 -0
  271. package/src/rsc/response-cache-serve.ts +9 -6
  272. package/src/rsc/response-route-handler.ts +11 -3
  273. package/src/rsc/rsc-rendering.ts +608 -225
  274. package/src/rsc/shell-build-manifest.ts +27 -9
  275. package/src/rsc/shell-capture-constants.ts +73 -6
  276. package/src/rsc/shell-capture.ts +1068 -296
  277. package/src/rsc/shell-serve.ts +316 -34
  278. package/src/rsc/ssr-setup.ts +14 -8
  279. package/src/rsc/types.ts +18 -0
  280. package/src/server/context.ts +300 -32
  281. package/src/server/cookie-store.ts +87 -61
  282. package/src/server/handle-store.ts +232 -7
  283. package/src/server/request-context.ts +297 -67
  284. package/src/ssr/index.tsx +136 -44
  285. package/src/ssr/ssr-root.tsx +9 -8
  286. package/src/static-handler.ts +4 -1
  287. package/src/testing/flight-runtime.d.ts +21 -0
  288. package/src/testing/flight.entry.ts +14 -0
  289. package/src/testing/generated-routes.ts +2 -2
  290. package/src/testing/internal/context.ts +1 -1
  291. package/src/testing/internal/flight-client-globals.ts +7 -5
  292. package/src/testing/render-route.tsx +85 -24
  293. package/src/testing/run-loader.ts +68 -6
  294. package/src/testing/serve-shell-request.ts +378 -0
  295. package/src/testing/shell-status.ts +34 -12
  296. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  297. package/src/testing/vitest.ts +62 -1
  298. package/src/theme/ThemeProvider.tsx +7 -6
  299. package/src/theme/ThemeScript.tsx +3 -3
  300. package/src/types/cache-types.ts +14 -10
  301. package/src/types/handler-context.ts +28 -1
  302. package/src/types/loader-types.ts +8 -6
  303. package/src/urls/pattern-types.ts +22 -18
  304. package/src/urls/response-types.ts +3 -23
  305. package/src/use-loader.tsx +12 -3
  306. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  307. package/src/vite/plugins/use-cache-transform.ts +118 -74
  308. package/src/vite/rango.ts +7 -3
  309. package/src/vite/router-discovery.ts +14 -4
@@ -2,11 +2,13 @@
2
2
  * Loader-family capture snapshot: elide + overlay for bake-lane loader
3
3
  * containers (docs/design/loader-container-bake.md).
4
4
  *
5
- * A loader on an entry with NO renderable loading() executes during shell
6
- * capture; its settled container bakes into the prelude while every promise
7
- * still pending at the quiet window postpones as a hole. To keep a HIT's fresh
8
- * payload byte-identical to that frozen prelude, the capture pins the container
9
- * in the shell snapshot:
5
+ * A bake-lane loader (lane rule: see resolveLoaderData, loader-cache.ts)
6
+ * executes during shell capture; its settled container bakes into the prelude
7
+ * while nested promises postpone as holes. To keep a HIT's fresh payload in
8
+ * agreement with that frozen prelude, the capture pins the container in the
9
+ * shell snapshot. Everything outside a hole is pinned; the one exception is a
10
+ * server-component element holding a promise, which is a hole as a whole (see
11
+ * elideLoaderContainer):
10
12
  *
11
13
  * - elide: deep-walk the settled container; a PENDING nested promise is a
12
14
  * hole, replaced by {@link LOADER_HOLE_KEY} — and since
@@ -28,13 +30,20 @@
28
30
  * (they cannot contradict prelude bytes that never rendered them).
29
31
  */
30
32
 
33
+ import { isValidElement, type ReactElement } from "react";
31
34
  import { isThenable } from "../../handles/is-thenable.js";
35
+ import {
36
+ cloneElementWithProps,
37
+ isPlainDataObject,
38
+ maskNestedContainerThenables,
39
+ type MaskReport,
40
+ } from "./mask-nested.js";
32
41
 
33
42
  // Capture-side nested-thenable masking lives in the LEAF module mask-nested.ts
34
- // (request-context also needs it for handle pushes and cannot import through
35
- // loader-mask without a cycle). Re-exported here so loader-cache and the unit
36
- // tests keep one import site for the snapshot family.
37
- export { maskNestedContainerThenables } from "./mask-nested.js";
43
+ // (shared with the capture handle-store push wrap in rsc/shell-capture.ts).
44
+ // Re-exported here so loader-cache and the unit tests keep one import site for
45
+ // the snapshot family.
46
+ export { maskNestedContainerThenables };
38
47
 
39
48
  /**
40
49
  * Marker object standing in for a pending nested promise in a recorded loader
@@ -86,12 +95,6 @@ export function isLoaderSettledMarker(
86
95
  );
87
96
  }
88
97
 
89
- function isPlainObject(value: unknown): value is Record<string, unknown> {
90
- if (typeof value !== "object" || value === null) return false;
91
- const proto = Object.getPrototypeOf(value);
92
- return proto === Object.prototype || proto === null;
93
- }
94
-
95
98
  /**
96
99
  * Probe whether a promise is already settled without waiting for it: races it
97
100
  * against an immediately-resolved sentinel across two microtask hops (then
@@ -129,10 +132,14 @@ export type ElideResult =
129
132
  * promises pin their value behind a settled marker (they baked, but consumers
130
133
  * hold a promise-shaped key); pending ones become hole markers; a REJECTED
131
134
  * nested promise poisons the record (error UI must never bake into a shared
132
- * shell) — the caller refuses the capture. Only plain objects/arrays are
133
- * traversed; anything else (Date, Map, class instance) is a pinned leaf.
134
- * Cycles are cut as pinned references (best effort — Flight rejects true
135
- * cycles later regardless).
135
+ * shell) — the caller refuses the capture. Plain data objects, arrays and the
136
+ * props of host, Suspense/Fragment and client-reference elements are
137
+ * traversed copy-on-write (an element through cloneElementWithProps); a shared
138
+ * subtree or a cycle resolves to the same elided result. An element Flight
139
+ * renders by calling user code (see rendersOnServer) is a leaf, or ONE hole
140
+ * marker when a thenable sits anywhere in its props: the pin encode calls that
141
+ * component, so a marker inside its props would reach it. Anything else
142
+ * (Date, Map, class instance, lazy node) is a pinned leaf.
136
143
  *
137
144
  * The ROOT container promise-chain unwraps with NO marker: loader-cache
138
145
  * overlays against the AWAITED fresh container, so the recorded root must be
@@ -140,7 +147,7 @@ export type ElideResult =
140
147
  */
141
148
  export async function elideLoaderContainer(
142
149
  value: unknown,
143
- seen: Set<object> = new Set(),
150
+ seen: Map<object, ElideResult> = new Map(),
144
151
  ): Promise<ElideResult> {
145
152
  if (isThenable(value)) {
146
153
  const probed = await probeSettled(value);
@@ -155,7 +162,7 @@ export async function elideLoaderContainer(
155
162
 
156
163
  async function elideNested(
157
164
  value: unknown,
158
- seen: Set<object>,
165
+ seen: Map<object, ElideResult>,
159
166
  ): Promise<ElideResult> {
160
167
  if (isThenable(value)) {
161
168
  const probed = await probeSettled(value);
@@ -170,44 +177,146 @@ async function elideNested(
170
177
  : { [LOADER_SETTLED_KEY]: 1, value: inner.value };
171
178
  return { state: "ok", value: marker, hasHole: inner.hasHole };
172
179
  }
180
+ if (typeof value !== "object" || value === null) {
181
+ return { state: "ok", value, hasHole: false };
182
+ }
183
+ const cached = seen.get(value);
184
+ if (cached) return cached;
173
185
 
174
- if (Array.isArray(value)) {
175
- if (seen.has(value)) return { state: "ok", value, hasHole: false };
176
- seen.add(value);
177
- const out: unknown[] = new Array(value.length);
178
- let hasHole = false;
179
- for (let i = 0; i < value.length; i++) {
180
- const r = await elideNested(value[i], seen);
186
+ if (isValidElement<Record<string, unknown>>(value)) {
187
+ if (rendersOnServer(value.type)) {
188
+ return elementHoldsThenable(value.props)
189
+ ? { state: "ok", value: { [LOADER_HOLE_KEY]: 1 }, hasHole: true }
190
+ : { state: "ok", value, hasHole: false };
191
+ }
192
+ // Cached before the walk: a cycle back here pins the original.
193
+ const result: ElideResult & { state: "ok" } = {
194
+ state: "ok",
195
+ value,
196
+ hasHole: false,
197
+ };
198
+ seen.set(value, result);
199
+ const props = value.props;
200
+ let changed: Record<string, unknown> | undefined;
201
+ for (const key of Object.keys(props)) {
202
+ const r = await elideNested(props[key], seen);
181
203
  if (r.state === "rejected") return r;
182
- out[i] = r.value;
183
- hasHole ||= r.hasHole;
204
+ if (r.value !== props[key]) (changed ??= {})[key] = r.value;
205
+ result.hasHole ||= r.hasHole;
184
206
  }
185
- return { state: "ok", value: out, hasHole };
207
+ if (changed) result.value = cloneElementWithProps(value, changed);
208
+ return result;
186
209
  }
187
210
 
188
- if (isPlainObject(value)) {
189
- if (seen.has(value)) return { state: "ok", value, hasHole: false };
190
- seen.add(value);
191
- const out: Record<string, unknown> = {};
192
- let hasHole = false;
193
- for (const key of Object.keys(value)) {
194
- const r = await elideNested(value[key], seen);
211
+ if (Array.isArray(value) || isPlainDataObject(value)) {
212
+ const source = value as Record<string, unknown>;
213
+ const out = (Array.isArray(value) ? new Array(value.length) : {}) as Record<
214
+ string,
215
+ unknown
216
+ >;
217
+ // Cached before the walk: a cycle back here pins the copy.
218
+ const result: ElideResult & { state: "ok" } = {
219
+ state: "ok",
220
+ value: out,
221
+ hasHole: false,
222
+ };
223
+ seen.set(value, result);
224
+ let changed = false;
225
+ for (const key of Object.keys(source)) {
226
+ const r = await elideNested(source[key], seen);
195
227
  if (r.state === "rejected") return r;
196
228
  out[key] = r.value;
197
- hasHole ||= r.hasHole;
229
+ if (r.value !== source[key]) changed = true;
230
+ result.hasHole ||= r.hasHole;
198
231
  }
199
- return { state: "ok", value: out, hasHole };
232
+ if (!changed) result.value = value;
233
+ return result;
200
234
  }
201
235
 
202
236
  return { state: "ok", value, hasHole: false };
203
237
  }
204
238
 
239
+ const CLIENT_REFERENCE = Symbol.for("react.client.reference");
240
+ const TEMPORARY_REFERENCE = Symbol.for("react.temporary.reference");
241
+ const REACT_MEMO = Symbol.for("react.memo");
242
+ const REACT_FORWARD_REF = Symbol.for("react.forward_ref");
243
+ const REACT_LAZY = Symbol.for("react.lazy");
244
+
245
+ /**
246
+ * Whether Flight calls user code to encode an element of this type (its
247
+ * renderElement): a function component, or a memo/forwardRef/lazy wrapper
248
+ * around one. Host (string), Suspense/Fragment (symbol) and client-reference
249
+ * types are encoded with their props as data, so a hole marker in those props
250
+ * reaches no component. A lazy type is resolved the way Flight resolves it:
251
+ * the Flight decode (a cache() hit, a "use cache" result) hands every client
252
+ * component back as a lazy around its client reference, and the capture render
253
+ * already resolved it. One that cannot resolve yet counts as a server
254
+ * component.
255
+ */
256
+ function rendersOnServer(type: unknown): boolean {
257
+ if (typeof type !== "function" && (typeof type !== "object" || !type)) {
258
+ return false;
259
+ }
260
+ const tag = (type as { $$typeof?: unknown }).$$typeof;
261
+ if (tag === CLIENT_REFERENCE || tag === TEMPORARY_REFERENCE) return false;
262
+ if (typeof type === "function") return true;
263
+ if (tag === REACT_MEMO) {
264
+ return rendersOnServer((type as { type?: unknown }).type);
265
+ }
266
+ if (tag === REACT_LAZY) {
267
+ const resolved = readLazyNode(type);
268
+ return resolved === UNRESOLVED || rendersOnServer(resolved);
269
+ }
270
+ return tag === REACT_FORWARD_REF;
271
+ }
272
+
273
+ /**
274
+ * Whether the mask found a thenable in an element's props: the mask's own
275
+ * walk, so elide and the mask share one traversal rule.
276
+ */
277
+ function elementHoldsThenable(props: unknown): boolean {
278
+ const report: MaskReport = { thenable: false };
279
+ maskNestedContainerThenables(props, undefined, report);
280
+ return report.thenable;
281
+ }
282
+
283
+ const UNRESOLVED: unique symbol = Symbol("unresolved");
284
+
285
+ function isLazyNode(value: unknown): boolean {
286
+ return (
287
+ typeof value === "object" &&
288
+ value !== null &&
289
+ (value as { $$typeof?: unknown }).$$typeof === REACT_LAZY
290
+ );
291
+ }
292
+
293
+ /**
294
+ * The value behind a lazy node, or UNRESOLVED. Flight moves an element past
295
+ * ~3.2 KB of its row into a row of its own (`$L`), which the decode wraps in a
296
+ * lazy node, so a hole marker inside that element sits behind one. The pinned
297
+ * record is fully decoded when a HIT overlays it, so the read is synchronous;
298
+ * a node that is still pending throws and stays a leaf.
299
+ */
300
+ function readLazyNode(value: unknown): unknown {
301
+ if (!isLazyNode(value)) return UNRESOLVED;
302
+ const lazy = value as { _payload: unknown; _init: (p: unknown) => unknown };
303
+ try {
304
+ return lazy._init(lazy._payload);
305
+ } catch {
306
+ return UNRESOLVED;
307
+ }
308
+ }
309
+
205
310
  /**
206
311
  * Overlay a recorded (elided) container onto the fresh run's container for a
207
312
  * shell HIT. Recorded wins per path; hole markers take the fresh value (the
208
313
  * live nested promise); fresh-only object keys pass through. Where the shapes
209
314
  * disagree structurally, recorded wins wholesale — it is what the prelude
210
- * froze, and parity beats freshness inside the shell.
315
+ * froze, and parity beats freshness inside the shell. Copy-on-write: a
316
+ * recorded subtree without markers comes back by identity. A recorded element
317
+ * is rebuilt through cloneElementWithProps only at its marker paths, so its
318
+ * other props stay pinned; props the fresh element adds do not pass through
319
+ * (the prelude rendered the element without them).
211
320
  */
212
321
  export function overlayLoaderContainer(
213
322
  fresh: unknown,
@@ -233,27 +342,70 @@ export function overlayLoaderContainer(
233
342
 
234
343
  if (Array.isArray(recorded)) {
235
344
  const freshArr = Array.isArray(fresh) ? fresh : [];
236
- return recorded.map((item, i) => overlayLoaderContainer(freshArr[i], item));
345
+ let out: unknown[] | undefined;
346
+ for (let i = 0; i < recorded.length; i++) {
347
+ const next = overlayLoaderContainer(freshArr[i], recorded[i]);
348
+ if (next !== recorded[i]) (out ??= recorded.slice())[i] = next;
349
+ }
350
+ return out ?? recorded;
237
351
  }
238
352
 
239
- if (isPlainObject(recorded)) {
240
- if (!isPlainObject(fresh)) {
241
- // Shape drift: still honor markers (holes fall back to undefined).
242
- const out: Record<string, unknown> = {};
243
- for (const key of Object.keys(recorded)) {
244
- out[key] = overlayLoaderContainer(undefined, recorded[key]);
245
- }
246
- return out;
247
- }
248
- const out: Record<string, unknown> = {};
353
+ if (isValidElement<Record<string, unknown>>(recorded)) {
354
+ return overlayElement(fresh, recorded);
355
+ }
356
+
357
+ const lazyValue = readLazyNode(recorded);
358
+ if (lazyValue !== UNRESOLVED) {
359
+ const next = overlayLoaderContainer(fresh, lazyValue);
360
+ return next === lazyValue ? recorded : next;
361
+ }
362
+
363
+ if (isPlainDataObject(recorded)) {
364
+ // Shape drift (fresh not a plain object): markers fall back to undefined.
365
+ const freshObj = isPlainDataObject(fresh) ? fresh : undefined;
366
+ let out: Record<string, unknown> | undefined;
249
367
  for (const key of Object.keys(recorded)) {
250
- out[key] = overlayLoaderContainer(fresh[key], recorded[key]);
368
+ const next = overlayLoaderContainer(freshObj?.[key], recorded[key]);
369
+ if (next !== recorded[key]) (out ??= { ...recorded })[key] = next;
251
370
  }
252
- for (const key of Object.keys(fresh)) {
253
- if (!(key in out)) out[key] = fresh[key];
371
+ if (freshObj) {
372
+ for (const key of Object.keys(freshObj)) {
373
+ if (!(key in recorded)) (out ??= { ...recorded })[key] = freshObj[key];
374
+ }
254
375
  }
255
- return out;
376
+ return out ?? recorded;
256
377
  }
257
378
 
258
379
  return recorded;
259
380
  }
381
+
382
+ function overlayElement(
383
+ fresh: unknown,
384
+ recorded: ReactElement<Record<string, unknown>>,
385
+ ): unknown {
386
+ if (isLazyNode(fresh)) {
387
+ // A fresh lazy node is read only when a recorded path needs the fresh
388
+ // value: reading it parses a decoded row or calls a React.lazy factory
389
+ // the HIT render never asks for otherwise.
390
+ if (overlayProps(undefined, recorded) === recorded) return recorded;
391
+ const read = readLazyNode(fresh);
392
+ fresh = read === UNRESOLVED ? undefined : read;
393
+ }
394
+ return overlayProps(
395
+ isValidElement<Record<string, unknown>>(fresh) ? fresh.props : undefined,
396
+ recorded,
397
+ );
398
+ }
399
+
400
+ function overlayProps(
401
+ freshProps: Record<string, unknown> | undefined,
402
+ recorded: ReactElement<Record<string, unknown>>,
403
+ ): unknown {
404
+ const props = recorded.props;
405
+ let changed: Record<string, unknown> | undefined;
406
+ for (const key of Object.keys(props)) {
407
+ const next = overlayLoaderContainer(freshProps?.[key], props[key]);
408
+ if (next !== props[key]) (changed ??= {})[key] = next;
409
+ }
410
+ return changed ? cloneElementWithProps(recorded, changed) : recorded;
411
+ }
@@ -1,14 +1,17 @@
1
1
  /**
2
- * Capture-side nested-thenable masking — the mechanism behind "nested-promise
3
- * shape is the liveness declaration" for BOTH bake-lane loader containers
4
- * (loader-cache.ts) and pushed handle containers (request-context.ts
5
- * createUseFunction).
2
+ * Capture-side nested thenables. Masking is the mechanism behind "nested-
3
+ * promise shape is the liveness declaration" for the loader lane: bake-lane
4
+ * loader containers (loader-cache.ts) and DSL loader handle pushes (the
5
+ * capture handle store's push wrap in rsc/shell-capture.ts). Handler handle
6
+ * pushes are handler output and bake: the capture waits for their nested
7
+ * promises instead (settleNestedThenables).
6
8
  *
7
- * Deliberately a LEAF module: request-context needs the mask for handle
8
- * pushes, and loader-mask (the other natural home) imports request-context —
9
- * importing from there would cycle. This module imports only is-thenable.
9
+ * Deliberately a LEAF module: loader-mask (the other natural home) imports
10
+ * request-context, so any funnel importing the mask from there inherits that
11
+ * graph. This module imports only is-thenable and react.
10
12
  */
11
13
 
14
+ import { cloneElement, isValidElement, type ReactElement } from "react";
12
15
  import { isThenable } from "../../handles/is-thenable.js";
13
16
 
14
17
  /**
@@ -23,18 +26,102 @@ export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
23
26
  return new Promise<T>(() => {});
24
27
  }
25
28
 
26
- function isPlainObject(value: unknown): value is Record<string, unknown> {
29
+ /**
30
+ * A data object the container walks descend into: prototype Object.prototype
31
+ * or null, and no symbol `$$typeof`. React elements, lazy nodes and references
32
+ * are plain objects too, told apart by that symbol (Flight switches on it).
33
+ * They are React's, not data: an element's dev-only fields (`_debugStack`,
34
+ * `_debugTask`, `_debugInfo`, `ref`) are non-enumerable, so a key-by-key copy
35
+ * drops them and dev Flight refuses it ("Attempted to render <x> without
36
+ * development properties", issue #942).
37
+ */
38
+ export function isPlainDataObject(
39
+ value: unknown,
40
+ ): value is Record<string, unknown> {
27
41
  if (typeof value !== "object" || value === null) return false;
28
42
  const proto = Object.getPrototypeOf(value);
29
- return proto === Object.prototype || proto === null;
43
+ if (proto !== Object.prototype && proto !== null) return false;
44
+ return typeof (value as { $$typeof?: unknown }).$$typeof !== "symbol";
30
45
  }
31
46
 
47
+ type KeyValidated = { _store?: { validated?: unknown } };
48
+
32
49
  /**
33
- * Deep-copy a container with every NESTED thenable replaced by a masked
50
+ * cloneElement with only the given props replaced, keeping React's dev key
51
+ * validation. React 19 dev cloneElement resets `_store.validated`, so a
52
+ * keyless child of a static children list (validated at creation) would warn
53
+ * "Each child in a list should have a unique key" once cloned. Pass only the
54
+ * props that change: cloneElement re-parents the element (owner) when its
55
+ * config carries `ref`.
56
+ */
57
+ export function cloneElementWithProps(
58
+ element: ReactElement,
59
+ props: Record<string, unknown>,
60
+ ): ReactElement {
61
+ const clone = cloneElement(element, props);
62
+ const from = (element as KeyValidated)._store;
63
+ const to = (clone as KeyValidated)._store;
64
+ if (from && to) to.validated = from.validated;
65
+ return clone;
66
+ }
67
+
68
+ /**
69
+ * Resolves once `value` and every thenable reachable from it through plain
70
+ * data objects, arrays and React element props has settled, following what
71
+ * each thenable resolves to. A rejection counts as settled: the consumer that
72
+ * encodes the value reports it. Other objects are leaves (the same walk as
73
+ * maskNestedContainerThenables), so a promise inside a Map is not waited for.
74
+ * An element is walked through its props only (`<X data={promise} />` in a
75
+ * handle value is handler output too), never its owner or type, and is never
76
+ * rebuilt: nothing is copied, only awaited.
77
+ *
78
+ * The capture waits on this for HANDLER handle pushes (shell-capture.ts):
79
+ * handler output is baked, so its promises must settle before the doc record
80
+ * encodes the handles every HIT replays.
81
+ */
82
+ export function settleNestedThenables(value: unknown): Promise<void> {
83
+ const pending: Promise<void>[] = [];
84
+ const seen = new Set<object>();
85
+ const walk = (v: unknown): void => {
86
+ if (isThenable(v)) {
87
+ pending.push(
88
+ Promise.resolve(v).then(settleNestedThenables, () => undefined),
89
+ );
90
+ return;
91
+ }
92
+ if (isValidElement<Record<string, unknown>>(v)) {
93
+ walk(v.props);
94
+ return;
95
+ }
96
+ if (Array.isArray(v) || isPlainDataObject(v)) {
97
+ if (seen.has(v)) return;
98
+ seen.add(v);
99
+ for (const item of Array.isArray(v) ? v : Object.values(v)) walk(item);
100
+ }
101
+ };
102
+ walk(value);
103
+ return pending.length === 0
104
+ ? Promise.resolve()
105
+ : Promise.all(pending).then(() => undefined);
106
+ }
107
+
108
+ /**
109
+ * Optional single-walk report for maskNestedContainerThenables: `thenable`
110
+ * flips true when the walk masked at least one thenable. The capture push
111
+ * funnel reads it for a loader push: a container with a masked nested
112
+ * promise keeps its loader tag, so the handle encode never waits on a mask.
113
+ * Same single-pass shape as elideLoaderContainer's `hasHole`.
114
+ */
115
+ export interface MaskReport {
116
+ thenable: boolean;
117
+ }
118
+
119
+ /**
120
+ * Copy a container with every NESTED thenable replaced by a masked
34
121
  * (never-resolving) promise. Applied during shell capture to (a) bake-lane
35
- * loader containers (loader-cache.ts) and (b) pushed handle containers
36
- * (createUseFunction) — the two rango-owned funnels where consumers declare
37
- * per-request data by promise SHAPE.
122
+ * loader containers (loader-cache.ts) and (b) DSL loader handle pushes
123
+ * (shell-capture.ts capture store push wrap) — the rango-owned funnels where
124
+ * a loader declares per-request data by promise SHAPE.
38
125
  *
39
126
  * Why: a nested promise that happened to SETTLE before the capture's quiet
40
127
  * window closed used to bake its value into the SHARED shell (and, for
@@ -46,23 +133,16 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
46
133
  * Masking makes the consuming subtree postpone as a hole no matter when the
47
134
  * promise settles: liveness by declaration, not by racing the window.
48
135
  *
49
- * Only plain objects/arrays are traversed; other values are leaves. The INPUT
50
- * IS NEVER MUTATED — handler-side loader consumption (the consumption-lane
51
- * rule, semantic-matrix PPR3) shares the raw container and must keep real
52
- * values. Cycles are preserved as cycles in the copy.
53
- */
54
- /**
55
- * Optional single-walk report for maskNestedContainerThenables: `thenable`
56
- * flips true when the walk masked at least one thenable. The shell fast path
57
- * reads it at handle-push time — a pushed container with a nested thenable
58
- * declares per-request data, and a shell entry whose HANDLER layer made such
59
- * a declaration cannot be served handler-free (the hole would never fill).
60
- * Same single-pass shape as elideLoaderContainer's `hasHole`.
136
+ * Plain data objects, arrays and React element props are traversed: JSX
137
+ * carrying a promise (`<Reviews data={fetchReviews()} />`) declares
138
+ * per-request data the same way a plain container does. Other values, lazy
139
+ * nodes included, are leaves. Copy-on-write: a subtree without a thenable
140
+ * comes back by identity, so an element is rebuilt only when its props hold
141
+ * one, and then through cloneElementWithProps, which keeps its type, key,
142
+ * owner, dev fields and key validation. The INPUT IS NEVER MUTATED — handler-side loader consumption (the
143
+ * consumption-lane rule, semantic-matrix PPR3) shares the raw container and
144
+ * must keep real values. Cycles are preserved as cycles in the copy.
61
145
  */
62
- export interface MaskReport {
63
- thenable: boolean;
64
- }
65
-
66
146
  export function maskNestedContainerThenables(
67
147
  value: unknown,
68
148
  seen: Map<object, unknown> = new Map(),
@@ -72,27 +152,41 @@ export function maskNestedContainerThenables(
72
152
  if (report) report.thenable = true;
73
153
  return createMaskedLoaderPromise();
74
154
  }
155
+ if (typeof value !== "object" || value === null) return value;
156
+ const cached = seen.get(value);
157
+ if (cached !== undefined) return cached;
75
158
 
76
- if (Array.isArray(value)) {
77
- const cached = seen.get(value);
78
- if (cached !== undefined) return cached;
79
- const out: unknown[] = new Array(value.length);
80
- seen.set(value, out);
81
- for (let i = 0; i < value.length; i++) {
82
- out[i] = maskNestedContainerThenables(value[i], seen, report);
159
+ if (isValidElement<Record<string, unknown>>(value)) {
160
+ // Placeholder: a cycle back to this element through its props keeps the
161
+ // original.
162
+ seen.set(value, value);
163
+ const props = value.props;
164
+ let maskedProps: Record<string, unknown> | undefined;
165
+ for (const key of Object.keys(props)) {
166
+ const masked = maskNestedContainerThenables(props[key], seen, report);
167
+ if (masked !== props[key]) (maskedProps ??= {})[key] = masked;
83
168
  }
84
- return out;
169
+ if (!maskedProps) return value;
170
+ const clone = cloneElementWithProps(value, maskedProps);
171
+ seen.set(value, clone);
172
+ return clone;
85
173
  }
86
174
 
87
- if (isPlainObject(value)) {
88
- const cached = seen.get(value);
89
- if (cached !== undefined) return cached;
90
- const out: Record<string, unknown> = {};
175
+ if (Array.isArray(value) || isPlainDataObject(value)) {
176
+ const source = value as Record<string, unknown>;
177
+ const out = (Array.isArray(value) ? new Array(value.length) : {}) as Record<
178
+ string,
179
+ unknown
180
+ >;
91
181
  seen.set(value, out);
92
- for (const key of Object.keys(value)) {
93
- out[key] = maskNestedContainerThenables(value[key], seen, report);
182
+ let changed = false;
183
+ for (const key of Object.keys(source)) {
184
+ out[key] = maskNestedContainerThenables(source[key], seen, report);
185
+ if (out[key] !== source[key]) changed = true;
94
186
  }
95
- return out;
187
+ if (changed) return out;
188
+ seen.set(value, value);
189
+ return value;
96
190
  }
97
191
 
98
192
  return value;