@rangojs/router 0.15.1 → 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 (249) 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 +52 -0
  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 +36 -10
  32. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  33. package/dist/types/route-types.d.ts +3 -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/thenable-status.d.ts +9 -0
  67. package/dist/types/types/cache-types.d.ts +14 -10
  68. package/dist/types/types/handler-context.d.ts +14 -0
  69. package/dist/types/types/loader-types.d.ts +8 -6
  70. package/dist/types/urls/response-types.d.ts +3 -12
  71. package/dist/types/use-loader.d.ts +12 -3
  72. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  73. package/dist/types/vite/rango.d.ts +1 -1
  74. package/dist/vite/index.js +884 -768
  75. package/package.json +4 -4
  76. package/skills/api-client/SKILL.md +22 -10
  77. package/skills/breadcrumbs/SKILL.md +65 -22
  78. package/skills/bundle-analysis/SKILL.md +22 -22
  79. package/skills/cache-guide/SKILL.md +207 -97
  80. package/skills/caching/SKILL.md +295 -93
  81. package/skills/catalog.json +16 -16
  82. package/skills/client-urls/SKILL.md +53 -31
  83. package/skills/cloudflare/SKILL.md +72 -20
  84. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  85. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  86. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  87. package/skills/comparison/SKILL.md +5 -0
  88. package/skills/comparison/references/framework-comparison.md +39 -33
  89. package/skills/composability/SKILL.md +38 -29
  90. package/skills/css/SKILL.md +10 -13
  91. package/skills/debug-manifest/SKILL.md +74 -31
  92. package/skills/defer-hydration/SKILL.md +2 -1
  93. package/skills/deployment-caching/SKILL.md +6 -1
  94. package/skills/document-cache/SKILL.md +92 -28
  95. package/skills/fonts/SKILL.md +16 -8
  96. package/skills/handler-use/SKILL.md +53 -17
  97. package/skills/hooks/SKILL.md +28 -20
  98. package/skills/hooks/data.md +43 -1
  99. package/skills/hooks/handle-and-actions.md +31 -16
  100. package/skills/hooks/navigation.md +39 -10
  101. package/skills/hooks/outlets.md +13 -6
  102. package/skills/hooks/state.md +37 -15
  103. package/skills/hooks/urls.md +7 -5
  104. package/skills/host-router/SKILL.md +64 -12
  105. package/skills/i18n/SKILL.md +60 -34
  106. package/skills/intercept/SKILL.md +161 -26
  107. package/skills/layout/SKILL.md +106 -50
  108. package/skills/links/SKILL.md +51 -19
  109. package/skills/loader/SKILL.md +242 -106
  110. package/skills/middleware/SKILL.md +119 -23
  111. package/skills/migrate-nextjs/SKILL.md +100 -45
  112. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  113. package/skills/migrate-react-router/SKILL.md +49 -38
  114. package/skills/migrate-react-router/component-migration.md +25 -6
  115. package/skills/migrate-react-router/data-and-actions.md +18 -5
  116. package/skills/migrate-react-router/route-mapping.md +12 -6
  117. package/skills/mime-routes/SKILL.md +12 -7
  118. package/skills/observability/SKILL.md +28 -14
  119. package/skills/parallel/SKILL.md +75 -39
  120. package/skills/ppr/SKILL.md +345 -150
  121. package/skills/prerender/SKILL.md +189 -100
  122. package/skills/rango/SKILL.md +65 -34
  123. package/skills/react-compiler/SKILL.md +6 -5
  124. package/skills/response-routes/SKILL.md +133 -25
  125. package/skills/route/SKILL.md +165 -60
  126. package/skills/router-setup/SKILL.md +418 -240
  127. package/skills/scripts/SKILL.md +31 -20
  128. package/skills/server-actions/SKILL.md +220 -59
  129. package/skills/shell-manifest/SKILL.md +12 -7
  130. package/skills/streams-and-websockets/SKILL.md +32 -14
  131. package/skills/tailwind/SKILL.md +37 -33
  132. package/skills/testing/SKILL.md +68 -32
  133. package/skills/testing/cache-prerender.md +55 -29
  134. package/skills/testing/client-components.md +59 -6
  135. package/skills/testing/e2e-parity.md +12 -2
  136. package/skills/testing/flight.md +19 -11
  137. package/skills/testing/handles.md +8 -5
  138. package/skills/testing/loader.md +8 -5
  139. package/skills/testing/middleware.md +8 -3
  140. package/skills/testing/predicates.md +120 -0
  141. package/skills/testing/render-handler.md +70 -22
  142. package/skills/testing/response-routes.md +11 -4
  143. package/skills/testing/reverse-and-types.md +20 -14
  144. package/skills/testing/server-actions.md +12 -3
  145. package/skills/testing/server-tree.md +9 -0
  146. package/skills/testing/setup.md +9 -5
  147. package/skills/theme/SKILL.md +106 -29
  148. package/skills/typesafety/SKILL.md +20 -9
  149. package/skills/typesafety/env-and-bindings.md +126 -47
  150. package/skills/typesafety/generated-files-and-cli.md +26 -14
  151. package/skills/typesafety/params-and-search.md +44 -19
  152. package/skills/typesafety/route-types.md +50 -14
  153. package/skills/use-cache/SKILL.md +200 -82
  154. package/skills/vercel/SKILL.md +21 -11
  155. package/skills/view-transitions/SKILL.md +41 -20
  156. package/src/__augment-tests__/augmented.check.ts +26 -1
  157. package/src/browser/index.ts +2 -2
  158. package/src/browser/navigation-bridge.ts +3 -4
  159. package/src/browser/navigation-transaction.ts +22 -2
  160. package/src/browser/partial-update.ts +75 -80
  161. package/src/browser/react/ScrollRestoration.tsx +6 -6
  162. package/src/browser/rsc-router.tsx +1 -1
  163. package/src/browser/scroll-restoration.ts +53 -6
  164. package/src/cache/cache-exec-scope.ts +37 -4
  165. package/src/cache/cache-key-utils.ts +1 -6
  166. package/src/cache/cache-runtime.ts +230 -61
  167. package/src/cache/cache-scope.ts +76 -12
  168. package/src/cache/document-cache.ts +15 -0
  169. package/src/cache/handle-capture.ts +90 -10
  170. package/src/cache/handle-snapshot.ts +132 -28
  171. package/src/cache/segment-codec.ts +26 -7
  172. package/src/cache/tag-invalidation.ts +4 -3
  173. package/src/cache/taint.ts +1 -1
  174. package/src/cache/types.ts +16 -3
  175. package/src/client.rsc.tsx +10 -9
  176. package/src/client.tsx +5 -4
  177. package/src/components/DefaultDocument.tsx +7 -2
  178. package/src/context-var.ts +6 -5
  179. package/src/escape-script.ts +1 -1
  180. package/src/handles/MetaTags.tsx +13 -11
  181. package/src/handles/Scripts.tsx +11 -10
  182. package/src/handles/meta.ts +2 -2
  183. package/src/handles/script.ts +17 -16
  184. package/src/href-client.ts +1 -1
  185. package/src/html.ts +12 -0
  186. package/src/index.rsc.ts +5 -0
  187. package/src/index.ts +1 -0
  188. package/src/loader-store.ts +106 -0
  189. package/src/prerender/build-shell-capture.ts +8 -11
  190. package/src/reverse.ts +44 -4
  191. package/src/route-definition/dsl-helpers.ts +134 -42
  192. package/src/route-definition/helpers-types.ts +36 -10
  193. package/src/route-definition/resolve-handler-use.ts +121 -6
  194. package/src/route-types.ts +2 -3
  195. package/src/router/debug-manifest.ts +10 -2
  196. package/src/router/error-handling.ts +51 -15
  197. package/src/router/handler-context.ts +34 -25
  198. package/src/router/intercept-resolution.ts +125 -39
  199. package/src/router/loader-resolution.ts +56 -11
  200. package/src/router/match-api.ts +12 -17
  201. package/src/router/match-middleware/background-revalidation.ts +161 -96
  202. package/src/router/match-middleware/cache-lookup.ts +93 -10
  203. package/src/router/match-middleware/cache-store.ts +17 -90
  204. package/src/router/middleware-types.ts +3 -5
  205. package/src/router/middleware.ts +52 -9
  206. package/src/router/prerender-match.ts +53 -19
  207. package/src/router/route-snapshot.ts +11 -1
  208. package/src/router/router-context.ts +2 -0
  209. package/src/router/router-interfaces.ts +25 -2
  210. package/src/router/router-options.ts +8 -5
  211. package/src/router/segment-resolution/fresh.ts +44 -12
  212. package/src/router/segment-resolution/loader-cache.ts +205 -45
  213. package/src/router/segment-resolution/loader-mask.ts +12 -22
  214. package/src/router/segment-resolution/loader-snapshot.ts +6 -6
  215. package/src/router/segment-resolution/mask-nested.ts +7 -7
  216. package/src/router/segment-resolution/revalidation.ts +34 -6
  217. package/src/router/segment-wrappers.ts +4 -0
  218. package/src/rsc/render-pipeline.ts +3 -0
  219. package/src/rsc/response-route-handler.ts +11 -3
  220. package/src/rsc/rsc-rendering.ts +8 -45
  221. package/src/rsc/shell-capture-constants.ts +13 -0
  222. package/src/rsc/shell-capture.ts +111 -90
  223. package/src/rsc/ssr-setup.ts +14 -8
  224. package/src/rsc/types.ts +11 -0
  225. package/src/server/context.ts +88 -11
  226. package/src/server/cookie-store.ts +5 -4
  227. package/src/server/handle-store.ts +232 -7
  228. package/src/server/request-context.ts +35 -30
  229. package/src/ssr/index.tsx +40 -7
  230. package/src/ssr-suspension-warning.ts +2 -13
  231. package/src/static-handler.ts +4 -1
  232. package/src/testing/flight-runtime.d.ts +21 -0
  233. package/src/testing/generated-routes.ts +2 -2
  234. package/src/testing/internal/context.ts +1 -1
  235. package/src/testing/internal/flight-client-globals.ts +7 -5
  236. package/src/testing/render-route.tsx +85 -24
  237. package/src/testing/run-loader.ts +5 -1
  238. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  239. package/src/testing/vitest.ts +62 -1
  240. package/src/theme/ThemeProvider.tsx +14 -12
  241. package/src/theme/ThemeScript.tsx +3 -3
  242. package/src/thenable-status.ts +16 -0
  243. package/src/types/cache-types.ts +14 -10
  244. package/src/types/handler-context.ts +14 -0
  245. package/src/types/loader-types.ts +8 -6
  246. package/src/urls/response-types.ts +3 -23
  247. package/src/use-loader.tsx +24 -4
  248. package/src/vite/plugins/use-cache-transform.ts +118 -74
  249. package/src/vite/rango.ts +7 -3
@@ -12,6 +12,11 @@ import type {
12
12
  } from "../types";
13
13
  import { invariant, DslContextError } from "../errors";
14
14
  import type { DefaultRouteName } from "../types/global-namespace.js";
15
+ import type { ContextVar } from "../context-var.js";
16
+ import {
17
+ getCacheExecScope,
18
+ type CacheExecScope,
19
+ } from "../cache/cache-exec-scope.js";
15
20
 
16
21
  // ============================================================================
17
22
  // Performance Metrics Types
@@ -65,6 +70,17 @@ export type EntryPropCommon = {
65
70
  id: string;
66
71
  shortCode: string; // Short identifier for network efficiency (e.g., "L0", "P1", "R2")
67
72
  parent: EntryData | null;
73
+ /**
74
+ * Orphan siblings only (attachOrphanSibling in
75
+ * route-definition/dsl-helpers.ts): the entry whose `layout[]` holds this
76
+ * one. `parent` stays null on an orphan (matchError's matched-id stack in
77
+ * router/match-api.ts starts at the boundary-holding entry, possibly an
78
+ * orphan, and must stop there), so the boundary walkers in
79
+ * router/error-handling.ts follow this instead. A lookup that starts at an
80
+ * orphan (its loaders, an intercept it declares) then reaches the owner's
81
+ * boundaries and the owner's ancestors.
82
+ */
83
+ orphanOwner?: EntryData;
68
84
  /** Cache configuration for this entry (set by cache() DSL) */
69
85
  cache?: EntryCacheConfig;
70
86
  /** URL prefix from include() scope, used for MountContext on client */
@@ -187,9 +203,6 @@ export type InterceptEntry = {
187
203
  routeName: string; // e.g., "card"
188
204
  handler: ReactNode | Handler<any, any, any>;
189
205
  middleware: MiddlewareFn<any, any>[];
190
- revalidate: ShouldRevalidateFn<any, any>[];
191
- errorBoundary: (ReactNode | ErrorBoundaryHandler)[];
192
- notFoundBoundary: (ReactNode | NotFoundBoundaryHandler)[];
193
206
  loader: LoaderEntry[];
194
207
  loading?: ReactNode | false;
195
208
  transition?: TransitionConfig;
@@ -839,16 +852,19 @@ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
839
852
  // does NOT affect isInsideLoaderScope(), so rendered()/barrier/deadlock
840
853
  // gating (which must distinguish DSL from handler-invoked loaders) is
841
854
  // unchanged.
842
- const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
843
- const loaderBodyScopeALS: AsyncLocalStorage<{
855
+ interface LoaderBodyScope {
844
856
  active: true;
845
857
  loaderId?: string;
846
858
  handlerInvoked?: boolean;
847
- }> = ((globalThis as any)[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{
848
- active: true;
849
- loaderId?: string;
850
- handlerInvoked?: boolean;
851
- }>());
859
+ /** The body scope this one was entered from (a ctx.use(Loader) chain). */
860
+ parent?: LoaderBodyScope;
861
+ /** The "use cache" scope the body was entered in (assertNonCacheableReadAllowed). */
862
+ execScope?: CacheExecScope;
863
+ }
864
+ const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
865
+ const loaderBodyScopeALS: AsyncLocalStorage<LoaderBodyScope> = ((
866
+ globalThis as any
867
+ )[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<LoaderBodyScope>());
852
868
 
853
869
  /**
854
870
  * Check if the current execution is inside a cache() DSL boundary.
@@ -869,6 +885,45 @@ export function isInsideCacheScope(): boolean {
869
885
  return true;
870
886
  }
871
887
 
888
+ /**
889
+ * Read guard for a non-cacheable variable (`createVar({ cache: false })` or a
890
+ * `ctx.set(..., { cache: false })` write). Callers check isNonCacheable() first
891
+ * so ordinary reads never reach the scope lookups.
892
+ *
893
+ * Throws inside a "use cache" body: the key does not include the value, so the
894
+ * first caller's value would be stored and served to later callers (#925). A
895
+ * loader body entered INSIDE the cached function is exempt, the same baked-copy
896
+ * trade isInsideCacheScope() makes for cache(). A cached function called FROM a
897
+ * loader is not: the loader re-runs, the cached body does not. The body scope
898
+ * records the exec scope it was entered in, so the exemption holds only while
899
+ * that is still the innermost one.
900
+ */
901
+ export function assertNonCacheableReadAllowed(
902
+ keyOrVar: string | ContextVar<unknown>,
903
+ ): void {
904
+ const execScope = getCacheExecScope();
905
+ if (
906
+ execScope !== undefined &&
907
+ loaderBodyScopeALS.getStore()?.execScope !== execScope
908
+ ) {
909
+ const name = typeof keyOrVar === "string" ? ` "${keyOrVar}"` : "";
910
+ throw new Error(
911
+ `ctx.get() for a non-cacheable variable${name} cannot be called inside a "use cache" function. ` +
912
+ `The variable was created with { cache: false } or set with { cache: false }, ` +
913
+ `and the cache key does not include its value, so the first caller's value ` +
914
+ `would be served to later callers. Read it before calling the cached function ` +
915
+ `and pass the value in as an argument so it becomes part of the cache key.`,
916
+ );
917
+ }
918
+ if (isInsideCacheScope()) {
919
+ throw new Error(
920
+ `ctx.get() for a non-cacheable variable cannot be called inside a cache() boundary. ` +
921
+ `The variable was created with { cache: false } or set with { cache: false }, ` +
922
+ `and its value would be stale on cache hit. Move the read outside the cached scope.`,
923
+ );
924
+ }
925
+ }
926
+
872
927
  /**
873
928
  * Check if the current execution is inside a DSL loader scope
874
929
  * (wrapped by runInsideLoaderScope). Used by rendered() barrier
@@ -1052,7 +1107,16 @@ export function runInsideLoaderBodyScope<T>(
1052
1107
  loaderId?: string,
1053
1108
  handlerInvoked?: boolean,
1054
1109
  ): T {
1055
- return loaderBodyScopeALS.run({ active: true, loaderId, handlerInvoked }, fn);
1110
+ return loaderBodyScopeALS.run(
1111
+ {
1112
+ active: true,
1113
+ loaderId,
1114
+ handlerInvoked,
1115
+ parent: loaderBodyScopeALS.getStore(),
1116
+ execScope: getCacheExecScope(),
1117
+ },
1118
+ fn,
1119
+ );
1056
1120
  }
1057
1121
 
1058
1122
  /**
@@ -1067,6 +1131,19 @@ export function getCurrentLoaderBodyId(): string | undefined {
1067
1131
  return loaderBodyScopeALS.getStore()?.loaderId;
1068
1132
  }
1069
1133
 
1134
+ /**
1135
+ * True while `loaderId`'s body, or a loader it awaits via ctx.use at any
1136
+ * depth, is executing. The loader-level cache records these pushes
1137
+ * (loader-cache.ts): a HIT skips the cached body, so a dep that no other
1138
+ * reader runs in that request only reaches the page through the replay.
1139
+ */
1140
+ export function isInsideLoaderBody(loaderId: string): boolean {
1141
+ for (let s = loaderBodyScopeALS.getStore(); s; s = s.parent) {
1142
+ if (s.loaderId === loaderId) return true;
1143
+ }
1144
+ return false;
1145
+ }
1146
+
1070
1147
  /**
1071
1148
  * True while a HANDLER-invoked loader body (`await ctx.use(Loader)` from a
1072
1149
  * handler, not the DSL segment funnel) is executing. The consumption-lane
@@ -15,6 +15,7 @@ import {
15
15
  isInsideHandlerInvokedLoaderBody,
16
16
  } from "./context.js";
17
17
  import { isInsideCacheExecScope } from "../cache/cache-exec-scope.js";
18
+ import { PPR_LANE_HINT } from "../rsc/shell-capture-constants.js";
18
19
 
19
20
  /**
20
21
  * A single cookie entry returned by get() and getAll().
@@ -193,12 +194,12 @@ function assertNotInsideShellCapture(ctx: unknown, fnName: string): void {
193
194
  )._shellCaptureGuardTrippedLoaderId = getCurrentLoaderBodyId();
194
195
  throw new Error(
195
196
  `${fnName}() cannot be called while capturing a shared shell ` +
196
- `(shell-cache middleware). The captured shell is served to every user ` +
197
+ `(ppr shell capture). The captured shell is served to every user ` +
197
198
  `of this URL, so request-scoped data read here would leak one user's ` +
198
199
  `${fnName === "cookies" ? "cookies" : "headers"} to others. Read it ` +
199
- `inside a loader instead — loaders are never captured and always run ` +
200
- `fresh per request:\n\n` +
201
- ` loader("user", () => getUser(cookies().get("session")?.value));`,
200
+ `inside a loader without ssr: false and consume it with useLoader, e.g. ` +
201
+ `createLoader(async () => getUser(cookies().get("session")?.value)). ` +
202
+ PPR_LANE_HINT,
202
203
  );
203
204
  }
204
205
  }
@@ -1,3 +1,5 @@
1
+ import { getCurrentLoaderBodyId, isInsideLoaderScope } from "./context.js";
2
+
1
3
  /**
2
4
  * Handle data structure: handleName -> segmentId -> entries[]
3
5
  *
@@ -45,6 +47,24 @@ function createLateHandlePushError(
45
47
  return error;
46
48
  }
47
49
 
50
+ /**
51
+ * Per-slot flags, parallel to one handle/segment values array. Allocated on
52
+ * the array's first flagged slot; positions past its length are unflagged.
53
+ */
54
+ interface SlotTag {
55
+ /** Pushed inside a DSL loader scope; captureHandles drops it. */
56
+ loader?: boolean;
57
+ /** A loader-cache replay of this loader's recorded push (pushReplayed). */
58
+ replayOf?: string;
59
+ /** Pushed by this loader's live run after it replaced its replayed slots. */
60
+ liveOf?: string;
61
+ /** Pushed by this loader's body and kept in records (push `owner`). */
62
+ owner?: string;
63
+ }
64
+
65
+ // Shared by every plain DSL-loader push; tags are never mutated in place.
66
+ const LOADER_SLOT: SlotTag = Object.freeze({ loader: true });
67
+
48
68
  function cloneHandleData(data: HandleData): HandleData {
49
69
  const clone: HandleData = {};
50
70
  for (const handleName in data) {
@@ -116,8 +136,35 @@ export interface HandleStore {
116
136
  * Push handle data for a specific handle and segment.
117
137
  * Multiple pushes to the same handle/segment accumulate in an array.
118
138
  * Each push triggers an emission on the stream.
139
+ *
140
+ * `loaderPush` overrides the push-time DSL-loader tag
141
+ * (isInsideLoaderScope()) that getDataForSegment(id, true) excludes. The
142
+ * PPR shell capture passes false for the bake-lane pushes its record keeps
143
+ * (shell-capture.ts deriveShellCaptureContext), with `owner` set to the
144
+ * pushing loader's id so the record can restore them through pushReplayed
145
+ * (getRecordOwners).
119
146
  */
120
- push(handleName: string, segmentId: string, data: unknown): void;
147
+ push(
148
+ handleName: string,
149
+ segmentId: string,
150
+ data: unknown,
151
+ loaderPush?: boolean,
152
+ owner?: string,
153
+ ): void;
154
+
155
+ /**
156
+ * push() a loader-cache HIT's replayed value, recorded from `loaderId`'s
157
+ * body (loader-cache.ts replayLoaderHandles). If that loader then runs live
158
+ * in this request, its first push removes every value replayed for it and
159
+ * takes the first one's position (later live pushes follow it), so the
160
+ * handle output stays live without a duplicate.
161
+ */
162
+ pushReplayed(
163
+ handleName: string,
164
+ segmentId: string,
165
+ data: unknown,
166
+ loaderId: string,
167
+ ): void;
121
168
 
122
169
  /**
123
170
  * Get all collected handle data after all handlers have settled.
@@ -151,8 +198,23 @@ export interface HandleStore {
151
198
  /**
152
199
  * Get handle data for a specific segment (for caching).
153
200
  * Returns data in format: { handleName: [values...] }
201
+ *
202
+ * `excludeLoaderPushes` drops values tagged as DSL-loader pushes at push
203
+ * time (see push); see captureHandles.
204
+ */
205
+ getDataForSegment(
206
+ segmentId: string,
207
+ excludeLoaderPushes?: boolean,
208
+ ): Record<string, unknown[]>;
209
+
210
+ /**
211
+ * The push `owner` of each value in getDataForSegment(segmentId, true),
212
+ * index-aligned per handle (null: no owner). Handles without an owned
213
+ * value are omitted; undefined when none has one.
154
214
  */
155
- getDataForSegment(segmentId: string): Record<string, unknown[]>;
215
+ getRecordOwners(
216
+ segmentId: string,
217
+ ): Record<string, (string | null)[]> | undefined;
156
218
 
157
219
  /**
158
220
  * Replay cached handle data back into the store (for cache hits).
@@ -186,6 +248,88 @@ export interface HandleStore {
186
248
  */
187
249
  export function createHandleStore(): HandleStore {
188
250
  const data: HandleData = {};
251
+ // SlotTag arrays, keyed by the per-handle/segment values array. Positional
252
+ // (not value identity) so primitive values are covered too.
253
+ // replaySegmentData installs fresh arrays, so restored values are untagged.
254
+ const slotTags = new WeakMap<unknown[], (SlotTag | undefined)[]>();
255
+ // Replayed loaders that have not pushed live yet: loaderId -> the arrays
256
+ // holding their replayed slots.
257
+ const pendingReplays = new Map<string, Set<unknown[]>>();
258
+ // Replayed loaders whose live run already replaced those slots.
259
+ const replacedReplays = new Set<string>();
260
+
261
+ // Aligned to `values` so a mid-array insert or removal keeps positions.
262
+ function tagsFor(values: unknown[]): (SlotTag | undefined)[] {
263
+ let tags = slotTags.get(values);
264
+ if (!tags) slotTags.set(values, (tags = []));
265
+ if (tags.length < values.length) tags.length = values.length;
266
+ return tags;
267
+ }
268
+
269
+ function insertSlot(
270
+ values: unknown[],
271
+ at: number | undefined,
272
+ value: unknown,
273
+ tag: SlotTag | undefined,
274
+ ): void {
275
+ if (at === undefined || at >= values.length) {
276
+ values.push(value);
277
+ if (tag) tagsFor(values)[values.length - 1] = tag;
278
+ return;
279
+ }
280
+ const tags = tagsFor(values);
281
+ values.splice(at, 0, value);
282
+ tags.splice(at, 0, tag);
283
+ }
284
+
285
+ // Remove `loaderId`'s replayed slots; returns the first one's position.
286
+ function removeReplayedSlots(
287
+ values: unknown[],
288
+ loaderId: string,
289
+ ): number | undefined {
290
+ const tags = slotTags.get(values);
291
+ if (!tags) return undefined;
292
+ let first: number | undefined;
293
+ let kept = 0;
294
+ for (let i = 0; i < values.length; i++) {
295
+ if (tags[i]?.replayOf === loaderId) {
296
+ first ??= kept;
297
+ continue;
298
+ }
299
+ values[kept] = values[i];
300
+ tags[kept] = tags[i];
301
+ kept++;
302
+ }
303
+ values.length = kept;
304
+ tags.length = kept;
305
+ return first;
306
+ }
307
+
308
+ // Where a live push by a replayed loader lands in `values`: its first push
309
+ // removes all of its replayed slots and takes the first one's position in
310
+ // this array; later pushes follow its previous live push. undefined appends.
311
+ function liveReplacementIndex(
312
+ loaderId: string,
313
+ values: unknown[],
314
+ ): number | undefined {
315
+ const pending = pendingReplays.get(loaderId);
316
+ if (pending) {
317
+ pendingReplays.delete(loaderId);
318
+ replacedReplays.add(loaderId);
319
+ let at: number | undefined;
320
+ for (const replayed of pending) {
321
+ const first = removeReplayedSlots(replayed, loaderId);
322
+ if (replayed === values) at = first;
323
+ }
324
+ return at;
325
+ }
326
+ const tags = slotTags.get(values);
327
+ if (!tags) return undefined;
328
+ for (let i = tags.length - 1; i >= 0; i--) {
329
+ if (tags[i]?.liveOf === loaderId) return i + 1;
330
+ }
331
+ return undefined;
332
+ }
189
333
 
190
334
  // Settlement barriers: `settled` (handler lane) resolves when sealed AND
191
335
  // handler inflight === 0. `fullySettled` additionally waits for the
@@ -320,7 +464,13 @@ export function createHandleStore(): HandleStore {
320
464
  });
321
465
  },
322
466
 
323
- push(handleName: string, segmentId: string, value: unknown): void {
467
+ push(
468
+ handleName: string,
469
+ segmentId: string,
470
+ value: unknown,
471
+ loaderPush?: boolean,
472
+ owner?: string,
473
+ ): void {
324
474
  if (completed) {
325
475
  const error = createLateHandlePushError(handleName, segmentId);
326
476
  if (this.onError) this.onError(error);
@@ -333,13 +483,58 @@ export function createHandleStore(): HandleStore {
333
483
  if (!data[handleName][segmentId]) {
334
484
  data[handleName][segmentId] = [];
335
485
  }
336
- data[handleName][segmentId].push(value);
486
+ const values = data[handleName][segmentId];
487
+ const loader = (loaderPush ?? isInsideLoaderScope()) || undefined;
488
+ let at: number | undefined;
489
+ let liveOf: string | undefined;
490
+ if (pendingReplays.size > 0 || replacedReplays.size > 0) {
491
+ const bodyId = getCurrentLoaderBodyId();
492
+ if (
493
+ bodyId !== undefined &&
494
+ (pendingReplays.has(bodyId) || replacedReplays.has(bodyId))
495
+ ) {
496
+ at = liveReplacementIndex(bodyId, values);
497
+ liveOf = bodyId;
498
+ }
499
+ }
500
+ insertSlot(
501
+ values,
502
+ at,
503
+ value,
504
+ liveOf || owner
505
+ ? { loader, liveOf, owner }
506
+ : loader
507
+ ? LOADER_SLOT
508
+ : undefined,
509
+ );
337
510
 
338
511
  // Bump the version; each consumer's cursor decides when to clone+yield.
339
512
  version++;
340
513
  signalEmission();
341
514
  },
342
515
 
516
+ pushReplayed(
517
+ handleName: string,
518
+ segmentId: string,
519
+ value: unknown,
520
+ loaderId: string,
521
+ ): void {
522
+ const before = data[handleName]?.[segmentId]?.length ?? 0;
523
+ // Through this.push: active captures see the replay like a live push.
524
+ this.push(handleName, segmentId, value);
525
+ const values = data[handleName]?.[segmentId];
526
+ // A diverting capture kept it out of the store.
527
+ if (!values || values.length !== before + 1) return;
528
+ const tags = tagsFor(values);
529
+ tags[values.length - 1] = {
530
+ ...tags[values.length - 1],
531
+ replayOf: loaderId,
532
+ };
533
+ let arrays = pendingReplays.get(loaderId);
534
+ if (!arrays) pendingReplays.set(loaderId, (arrays = new Set()));
535
+ arrays.add(values);
536
+ },
537
+
343
538
  getData(): Promise<HandleData> {
344
539
  sealInternal();
345
540
  return this.settled.then(() => cloneHandleData(data));
@@ -417,12 +612,42 @@ export function createHandleStore(): HandleStore {
417
612
  }
418
613
  },
419
614
 
420
- getDataForSegment(segmentId: string): Record<string, unknown[]> {
615
+ getDataForSegment(
616
+ segmentId: string,
617
+ excludeLoaderPushes?: boolean,
618
+ ): Record<string, unknown[]> {
421
619
  const result: Record<string, unknown[]> = {};
422
620
  for (const handleName in data) {
423
- if (data[handleName][segmentId]) {
424
- result[handleName] = [...data[handleName][segmentId]];
621
+ const values = data[handleName][segmentId];
622
+ if (!values) continue;
623
+ const tags = excludeLoaderPushes ? slotTags.get(values) : undefined;
624
+ if (!tags) {
625
+ result[handleName] = [...values];
626
+ continue;
627
+ }
628
+ const kept = values.filter((_, i) => !tags[i]?.loader);
629
+ if (kept.length > 0) result[handleName] = kept;
630
+ }
631
+ return result;
632
+ },
633
+
634
+ getRecordOwners(
635
+ segmentId: string,
636
+ ): Record<string, (string | null)[]> | undefined {
637
+ let result: Record<string, (string | null)[]> | undefined;
638
+ for (const handleName in data) {
639
+ const values = data[handleName][segmentId];
640
+ const tags = values && slotTags.get(values);
641
+ if (!tags) continue;
642
+ const owners: (string | null)[] = [];
643
+ let owned = false;
644
+ for (let i = 0; i < values.length; i++) {
645
+ if (tags[i]?.loader) continue;
646
+ const owner = tags[i]?.owner ?? null;
647
+ if (owner) owned = true;
648
+ owners.push(owner);
425
649
  }
650
+ if (owned) (result ??= {})[handleName] = owners;
426
651
  }
427
652
  return result;
428
653
  },
@@ -61,8 +61,8 @@ import type { RoutineTrace } from "../rsc/routine-plan.js";
61
61
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
62
62
  import {
63
63
  assertCachedHeaderWriteAllowed,
64
+ assertNonCacheableReadAllowed,
64
65
  clearPprHeaderScope,
65
- isInsideCacheScope,
66
66
  } from "./context.js";
67
67
  import {
68
68
  createReverseFunction,
@@ -197,8 +197,13 @@ export interface RequestContext<
197
197
  /** HTTP method (GET, POST, PUT, PATCH, DELETE, etc.) */
198
198
  method: string;
199
199
 
200
- /** @internal Handle store for tracking handle data across segments */
201
- _handleStore: HandleStore;
200
+ /**
201
+ * @internal Handle store for tracking handle data across segments.
202
+ * Readonly: a background render that needs its own store derives a context
203
+ * (rerenderAndCacheRoute, shell-capture.ts) instead of swapping this field,
204
+ * which the foreground still reads while it produces the page.
205
+ */
206
+ readonly _handleStore: HandleStore;
202
207
 
203
208
  /**
204
209
  * @internal transition({ when }) predicates for segments matched this request,
@@ -250,13 +255,12 @@ export interface RequestContext<
250
255
  /**
251
256
  * @internal Bake-lane loader containers collected DURING a shell capture:
252
257
  * segment-key -> the loader's (pre-wrap) result promise. Populated by
253
- * resolveLoaderData for loaders on entries with no renderable loading() (the
254
- * bake lane — they execute at capture instead of being masked; see
255
- * docs/design/loader-container-bake.md). Drained by captureAndStoreShell
256
- * after the shell quiesces: settled containers are promise-elided,
257
- * Flight-serialized, and pinned into the snapshot's loader family; a
258
- * REJECTED container refuses the capture (error UI must never bake into the
259
- * shared shell). Own property of the capture's derived context only.
258
+ * resolveLoaderData for bake-lane loaders (lane rule: see its JSDoc in
259
+ * loader-cache.ts). Drained by captureAndStoreShell after the shell
260
+ * quiesces: settled containers are promise-elided, Flight-serialized, and
261
+ * pinned into the snapshot's loader family; a REJECTED container refuses
262
+ * the capture (error UI must never bake into the shared shell). Own
263
+ * property of the capture's derived context only.
260
264
  */
261
265
  _shellCaptureLoaderRecords?: Map<string, Promise<unknown>>;
262
266
 
@@ -377,18 +381,6 @@ export interface RequestContext<
377
381
  handlerInvokedLoader: boolean;
378
382
  };
379
383
 
380
- /**
381
- * @internal Handle values pushed from a DSL loader scope DURING a shell
382
- * capture (identity set; populated by the capture push wrapper in
383
- * shell-capture.ts). cacheRoute threads it into captureHandles so those
384
- * values stay out of cache-write handle records — loaders re-run fresh on
385
- * every HIT, so replaying their captured (masked) values would duplicate
386
- * the fresh push and stall the Flight handle encode. Own property of the
387
- * capture's derived context only; render-time handle consumers are
388
- * unaffected (the exclusion applies only at the captureHandles call site).
389
- */
390
- _shellCaptureLoaderHandleValues?: WeakSet<object>;
391
-
392
384
  /**
393
385
  * @internal Set (to the offending fn name) by the cookies()/headers()
394
386
  * capture guard when it throws DURING a capture render. Load-bearing for the
@@ -650,6 +642,17 @@ export interface RequestContext<
650
642
  /** @internal Per-request error dedup set for onError reporting */
651
643
  _reportedErrors: WeakSet<object>;
652
644
 
645
+ /**
646
+ * @internal Errors this response's Flight or Fizz render reported through
647
+ * onError: a component that threw does not fail the stream, which completes
648
+ * with an error row or an errored Suspense boundary. Whole-response captures
649
+ * (document-cache.ts, captureAndStoreShell) store nothing while it is
650
+ * non-empty (issue #915). An array so a render under a derived context (the
651
+ * shell HIT tail) pushes into the request's own list through the prototype;
652
+ * deriveShellCaptureContext gives the capture its own.
653
+ */
654
+ _renderErrors?: unknown[];
655
+
653
656
  /**
654
657
  * @internal Report a non-fatal background error through the router's
655
658
  * onError callback. Wired by the RSC handler / router during request
@@ -749,7 +752,6 @@ export type PublicRequestContext<
749
752
  | "_shellLoaderSeed"
750
753
  | "_shellCaptureLoaderRecords"
751
754
  | "_shellCaptureHandleLiveness"
752
- | "_shellCaptureLoaderHandleValues"
753
755
  | "_shellFragmentPayload"
754
756
  | "_shellCaptureGuardTripped"
755
757
  | "_shellCaptureGuardTrippedLoaderId"
@@ -770,6 +772,7 @@ export type PublicRequestContext<
770
772
  | "_gateFormData"
771
773
  | "_inActionRevalidation"
772
774
  | "_reportedErrors"
775
+ | "_renderErrors"
773
776
  | "_renderBarrier"
774
777
  | "_resolveRenderBarrier"
775
778
  | "_renderBarrierSegmentOrder"
@@ -1140,12 +1143,8 @@ export function createRequestContext<TEnv>(
1140
1143
  },
1141
1144
  _dynamic: false,
1142
1145
  get: ((keyOrVar: any) => {
1143
- if (isNonCacheable(variables, keyOrVar) && isInsideCacheScope()) {
1144
- throw new Error(
1145
- `ctx.get() for a non-cacheable variable cannot be called inside a cache() boundary. ` +
1146
- `The variable was created with { cache: false } or set with { cache: false }, ` +
1147
- `and its value would be stale on cache hit. Move the read outside the cached scope.`,
1148
- );
1146
+ if (isNonCacheable(variables, keyOrVar)) {
1147
+ assertNonCacheableReadAllowed(keyOrVar);
1149
1148
  }
1150
1149
  return contextGet(variables, keyOrVar);
1151
1150
  }) as RequestContext<TEnv>["get"],
@@ -1338,6 +1337,7 @@ export function createRequestContext<TEnv>(
1338
1337
  _locationState: undefined,
1339
1338
 
1340
1339
  _reportedErrors: new WeakSet<object>(),
1340
+ _renderErrors: [],
1341
1341
  _metricsStore: undefined,
1342
1342
  _renderForeground: undefined,
1343
1343
  _activeRoutine: undefined,
@@ -1627,7 +1627,9 @@ export function createUseFunction<TEnv>(
1627
1627
  ? parseSearchParams(ctx.searchParams, searchSchema)
1628
1628
  : {};
1629
1629
 
1630
- const loaderCtx: LoaderContext<Record<string, string | undefined>, TEnv> = {
1630
+ const loaderCtx: LoaderContext<Record<string, string | undefined>, TEnv> & {
1631
+ _routeName?: string;
1632
+ } = {
1631
1633
  params: ctx.params,
1632
1634
  routeParams: (ctx.params ?? {}) as Record<string, string>,
1633
1635
  request: ctx.request,
@@ -1666,6 +1668,7 @@ export function createUseFunction<TEnv>(
1666
1668
  ? isRouteRootScoped(ctx._routeName, ctx._routerId)
1667
1669
  : undefined,
1668
1670
  ),
1671
+ _routeName: ctx._routeName,
1669
1672
  rendered: () => {
1670
1673
  throw new Error(
1671
1674
  `ctx.rendered() is only available in DSL loaders (registered via loader() in urls()). ` +
@@ -1673,6 +1676,8 @@ export function createUseFunction<TEnv>(
1673
1676
  );
1674
1677
  },
1675
1678
  };
1679
+ // "use cache" keys it by route, like the request ctx it reads (#940).
1680
+ (loaderCtx as any)[NOCACHE_SYMBOL] = true;
1676
1681
 
1677
1682
  // Meter through the same unified phase API as the loader-resolution funnel
1678
1683
  // (observePhase), so a loader resolved via this base request-context ctx.use