@rangojs/router 0.17.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 (152) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/history-state.d.ts +16 -0
  4. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  5. package/dist/types/cache/cache-policy.d.ts +17 -0
  6. package/dist/types/cache/cache-scope.d.ts +98 -14
  7. package/dist/types/cache/cache-tag.d.ts +92 -7
  8. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  9. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  11. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  12. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  13. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  14. package/dist/types/cache/handle-snapshot.d.ts +4 -3
  15. package/dist/types/cache/index.d.ts +1 -0
  16. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  17. package/dist/types/cache/shell-memo.d.ts +178 -0
  18. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  19. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  20. package/dist/types/cache/types.d.ts +207 -43
  21. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  22. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  23. package/dist/types/router/instrument.d.ts +8 -0
  24. package/dist/types/router/middleware-types.d.ts +6 -1
  25. package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
  26. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
  27. package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
  28. package/dist/types/rsc/full-payload.d.ts +14 -0
  29. package/dist/types/rsc/handler.d.ts +1 -1
  30. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  31. package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
  32. package/dist/types/rsc/shell-capture.d.ts +146 -15
  33. package/dist/types/rsc/shell-serve.d.ts +140 -17
  34. package/dist/types/rsc/types.d.ts +7 -0
  35. package/dist/types/server/context.d.ts +109 -15
  36. package/dist/types/server/cookie-store.d.ts +21 -0
  37. package/dist/types/server/request-context.d.ts +159 -33
  38. package/dist/types/ssr/index.d.ts +9 -0
  39. package/dist/types/ssr/ssr-root.d.ts +9 -8
  40. package/dist/types/testing/flight.entry.d.ts +7 -0
  41. package/dist/types/testing/run-loader.d.ts +18 -3
  42. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  43. package/dist/types/testing/shell-status.d.ts +23 -10
  44. package/dist/types/types/handler-context.d.ts +14 -1
  45. package/dist/types/urls/pattern-types.d.ts +22 -18
  46. package/dist/types/vite/router-discovery.d.ts +29 -2
  47. package/dist/vite/index.js +46 -9
  48. package/package.json +1 -1
  49. package/skills/breadcrumbs/SKILL.md +17 -7
  50. package/skills/cache-guide/SKILL.md +123 -51
  51. package/skills/caching/SKILL.md +253 -23
  52. package/skills/catalog.json +1 -1
  53. package/skills/client-urls/SKILL.md +11 -6
  54. package/skills/cloudflare/SKILL.md +15 -0
  55. package/skills/comparison/references/framework-comparison.md +44 -32
  56. package/skills/defer-hydration/SKILL.md +50 -30
  57. package/skills/deployment-caching/SKILL.md +5 -2
  58. package/skills/document-cache/SKILL.md +8 -2
  59. package/skills/handler-use/SKILL.md +2 -0
  60. package/skills/hooks/handle-and-actions.md +3 -1
  61. package/skills/hooks/state.md +3 -1
  62. package/skills/loader/SKILL.md +161 -39
  63. package/skills/middleware/SKILL.md +16 -9
  64. package/skills/migrate-nextjs/SKILL.md +67 -36
  65. package/skills/migrate-react-router/SKILL.md +2 -1
  66. package/skills/migrate-react-router/component-migration.md +3 -1
  67. package/skills/migrate-react-router/data-and-actions.md +3 -1
  68. package/skills/migrate-react-router/route-mapping.md +7 -4
  69. package/skills/observability/SKILL.md +64 -0
  70. package/skills/parallel/SKILL.md +13 -8
  71. package/skills/ppr/SKILL.md +655 -363
  72. package/skills/prerender/SKILL.md +27 -14
  73. package/skills/rango/SKILL.md +52 -33
  74. package/skills/route/SKILL.md +12 -7
  75. package/skills/router-setup/SKILL.md +4 -2
  76. package/skills/scripts/SKILL.md +8 -1
  77. package/skills/server-actions/SKILL.md +11 -0
  78. package/skills/shell-manifest/SKILL.md +21 -12
  79. package/skills/testing/SKILL.md +25 -21
  80. package/skills/testing/cache-prerender.md +15 -14
  81. package/skills/testing/loader.md +18 -1
  82. package/skills/testing/serve-shell-request.md +188 -0
  83. package/skills/theme/SKILL.md +52 -4
  84. package/skills/typesafety/env-and-bindings.md +10 -2
  85. package/skills/use-cache/SKILL.md +39 -16
  86. package/skills/vercel/SKILL.md +14 -2
  87. package/skills/view-transitions/SKILL.md +1 -1
  88. package/src/browser/cookie-name.ts +23 -0
  89. package/src/browser/history-state.ts +23 -0
  90. package/src/browser/rsc-router.tsx +5 -0
  91. package/src/cache/cache-key-utils.ts +22 -2
  92. package/src/cache/cache-policy.ts +22 -0
  93. package/src/cache/cache-runtime.ts +34 -7
  94. package/src/cache/cache-scope.ts +345 -33
  95. package/src/cache/cache-tag.ts +375 -11
  96. package/src/cache/cf/cf-base64.ts +36 -4
  97. package/src/cache/cf/cf-cache-constants.ts +12 -0
  98. package/src/cache/cf/cf-cache-store.ts +990 -348
  99. package/src/cache/cf/cf-cache-types.ts +32 -4
  100. package/src/cache/cf/cf-shell-frame.ts +258 -0
  101. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  102. package/src/cache/handle-snapshot.ts +4 -3
  103. package/src/cache/index.ts +2 -0
  104. package/src/cache/isolate-tag-memo.ts +308 -0
  105. package/src/cache/shell-memo.ts +341 -0
  106. package/src/cache/shell-snapshot.ts +217 -27
  107. package/src/cache/tag-invalidation.ts +104 -19
  108. package/src/cache/types.ts +211 -43
  109. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  110. package/src/prerender/build-shell-capture.ts +65 -11
  111. package/src/route-definition/dsl-helpers.ts +4 -3
  112. package/src/router/handler-context.ts +38 -4
  113. package/src/router/instrument.ts +54 -2
  114. package/src/router/intercept-resolution.ts +19 -7
  115. package/src/router/loader-resolution.ts +63 -32
  116. package/src/router/match-api.ts +28 -9
  117. package/src/router/match-handlers.ts +5 -0
  118. package/src/router/match-middleware/cache-lookup.ts +17 -0
  119. package/src/router/match-middleware/cache-store.ts +31 -11
  120. package/src/router/middleware-types.ts +6 -1
  121. package/src/router/middleware.ts +23 -4
  122. package/src/router/prerender-match.ts +6 -0
  123. package/src/router/segment-resolution/fresh.ts +18 -9
  124. package/src/router/segment-resolution/helpers.ts +4 -2
  125. package/src/router/segment-resolution/loader-cache.ts +227 -56
  126. package/src/router/segment-resolution/loader-snapshot.ts +203 -51
  127. package/src/router/segment-resolution/mask-nested.ts +136 -42
  128. package/src/router/segment-resolution/revalidation.ts +27 -10
  129. package/src/rsc/full-payload.ts +25 -1
  130. package/src/rsc/handler.ts +14 -2
  131. package/src/rsc/progressive-enhancement.ts +3 -2
  132. package/src/rsc/response-cache-serve.ts +9 -6
  133. package/src/rsc/rsc-rendering.ts +601 -181
  134. package/src/rsc/shell-build-manifest.ts +27 -9
  135. package/src/rsc/shell-capture-constants.ts +60 -6
  136. package/src/rsc/shell-capture.ts +1001 -250
  137. package/src/rsc/shell-serve.ts +316 -34
  138. package/src/rsc/types.ts +7 -0
  139. package/src/server/context.ts +219 -28
  140. package/src/server/cookie-store.ts +87 -62
  141. package/src/server/request-context.ts +263 -38
  142. package/src/ssr/index.tsx +96 -37
  143. package/src/ssr/ssr-root.tsx +9 -8
  144. package/src/testing/flight.entry.ts +14 -0
  145. package/src/testing/run-loader.ts +63 -5
  146. package/src/testing/serve-shell-request.ts +378 -0
  147. package/src/testing/shell-status.ts +34 -12
  148. package/src/theme/ThemeProvider.tsx +7 -6
  149. package/src/types/handler-context.ts +14 -1
  150. package/src/urls/pattern-types.ts +22 -18
  151. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  152. package/src/vite/router-discovery.ts +14 -4
@@ -13,6 +13,7 @@ import type {
13
13
  import { invariant, DslContextError } from "../errors";
14
14
  import type { DefaultRouteName } from "../types/global-namespace.js";
15
15
  import type { ContextVar } from "../context-var.js";
16
+ import { PPR_LANE_HINT } from "../rsc/shell-capture-constants.js";
16
17
  import {
17
18
  getCacheExecScope,
18
19
  type CacheExecScope,
@@ -134,6 +135,15 @@ export type LoaderEntry = {
134
135
  * appears on navigation-lane entries.
135
136
  */
136
137
  awaitBeforeFlush?: true;
138
+ /**
139
+ * loader(Def, { ssr: false }) on every DSL evaluation, document and
140
+ * navigation: the loader's PPR bake lane (loader-cache.ts
141
+ * resolveLoaderData). The bake/seed key rides on it, so a client
142
+ * navigation's shell replay pins exactly the loaders a document HIT pins,
143
+ * whatever the entry's loading(). awaitBeforeFlush cannot carry that: a
144
+ * navigation evaluation never has it.
145
+ */
146
+ bake?: true;
137
147
  };
138
148
 
139
149
  /**
@@ -846,20 +856,19 @@ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
846
856
  // Purity-only scope: marks that a loader FUNCTION BODY is executing, regardless
847
857
  // of how the loader was invoked (DSL via runInsideLoaderScope, or handler-
848
858
  // invoked via ctx.use). Consulted by isInsideCacheScope() to exempt
849
- // request-scoped reads, by getCurrentLoaderBodyId() for guard-warning
850
- // attribution, and by isInsideHandlerInvokedLoaderBody() for the
851
- // consumption-lane rule (the shell-capture guard exemption). It deliberately
852
- // does NOT affect isInsideLoaderScope(), so rendered()/barrier/deadlock
853
- // gating (which must distinguish DSL from handler-invoked loaders) is
854
- // unchanged.
859
+ // request-scoped reads, and by getCurrentLoaderBodyId() for guard-warning
860
+ // attribution. It deliberately does NOT affect isInsideLoaderScope(), so
861
+ // rendered()/barrier/deadlock gating (which must distinguish DSL from
862
+ // handler-invoked loaders) is unchanged.
855
863
  interface LoaderBodyScope {
856
864
  active: true;
857
865
  loaderId?: string;
858
- handlerInvoked?: boolean;
859
866
  /** The body scope this one was entered from (a ctx.use(Loader) chain). */
860
867
  parent?: LoaderBodyScope;
861
868
  /** The "use cache" scope the body was entered in (assertNonCacheableReadAllowed). */
862
869
  execScope?: CacheExecScope;
870
+ /** The tags this execution records (#964, cache-tag.ts "Recorded-tag sets"). */
871
+ tags?: Set<string>;
863
872
  }
864
873
  const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
865
874
  const loaderBodyScopeALS: AsyncLocalStorage<LoaderBodyScope> = ((
@@ -868,8 +877,9 @@ const loaderBodyScopeALS: AsyncLocalStorage<LoaderBodyScope> = ((
868
877
 
869
878
  /**
870
879
  * Check if the current execution is inside a cache() DSL boundary.
871
- * Returns false inside loader execution — loaders are always fresh
872
- * (never cached), so non-cacheable reads are safe.
880
+ * Returns false inside loader execution: a route cache() does not store
881
+ * loader values, so non-cacheable reads are safe. A loader bound with its own
882
+ * cache() stores its value; recordLoaderIdentityRead guards that.
873
883
  */
874
884
  export function isInsideCacheScope(): boolean {
875
885
  if (RangoContext.getStore()?.insideCacheScope !== true) return false;
@@ -885,11 +895,115 @@ export function isInsideCacheScope(): boolean {
885
895
  return true;
886
896
  }
887
897
 
898
+ /**
899
+ * Throw if called during the ACTIVE background shell-capture render
900
+ * (`_shellCaptureRun` true on the derived request context built by
901
+ * shell-capture.ts). The captured shell is shared across every user hitting
902
+ * the URL, so a request-scoped read here would bake one user's cookies,
903
+ * headers, or per-request variables into markup served to others — same
904
+ * hazard as the cache scopes above, at the document tier. DSL segment loaders
905
+ * need no exemption: the live lane is masked (never executed) during capture,
906
+ * and the bake lane is exactly what this guard exists for.
907
+ *
908
+ * It covers everything the capture waits for, not only synchronous handler
909
+ * code: a promise the handler passes or pushes, an async server component,
910
+ * and a loader the handler awaits (`await ctx.use(Loader)`) all run under the
911
+ * capture context and all bake. The last one used to be exempt (the
912
+ * consumption-lane rule, #672/#674): while a HIT re-ran handlers, a slot
913
+ * handler's `ctx.use` of an identity loader rendered per visitor on the HIT.
914
+ * Now every HIT replays the capture's copy, so the exemption would bake the
915
+ * capturing request's identity into every visitor's page. The cache()
916
+ * guards above keep it (a cache() HIT is a separate tier).
917
+ *
918
+ * Keys off `_shellCaptureRun`, NOT the `_shellCapture` descriptor: the descriptor
919
+ * is also present during the FOREGROUND render (it means "a capture is wanted"),
920
+ * and the foreground must read cookies/headers normally to serve the real user.
921
+ * Only the derived capture context sets `_shellCaptureRun`.
922
+ *
923
+ * Applies only to the READ surfaces (cookies(), headers(), a { cache: false }
924
+ * variable, and the theme reads through tripShellCaptureGuard) whose values
925
+ * become markup. Response directives
926
+ * (invalidateClientCache(), keepClientCache()) stay callable: during capture
927
+ * they are header effects on a discarded response, and on the live HIT path
928
+ * the full pipeline runs so their headers flow to the client normally.
929
+ *
930
+ * The throw can be caught by the code that made the read, so the guard also
931
+ * flags the capture context; the capture refuses on the flag
932
+ * (shell-capture.ts refuseOnCaptureGuard), nothing is stored, and every
933
+ * request keeps getting the normal axis-1 render.
934
+ */
935
+ export function assertNotInsideShellCapture(
936
+ ctx: unknown,
937
+ fnName: string,
938
+ ): void {
939
+ if (!tripShellCaptureGuard(ctx, `${fnName}()`, REQUEST_READ_CAPTURE_FIX)) {
940
+ return;
941
+ }
942
+ const what =
943
+ fnName === "cookies"
944
+ ? "cookies"
945
+ : fnName === "headers"
946
+ ? "headers"
947
+ : "per-request variables";
948
+ throw new Error(
949
+ `${fnName}() cannot be called while capturing a shared shell ` +
950
+ `(ppr shell capture). The captured shell is served to every user ` +
951
+ `of this URL, so request-scoped data read here would leak one user's ` +
952
+ `${what} to others. Read it ` +
953
+ `inside a loader without ssr: false and consume it with useLoader, e.g. ` +
954
+ `createLoader(async () => getUser(cookies().get("session")?.value)). ` +
955
+ PPR_LANE_HINT,
956
+ );
957
+ }
958
+
959
+ /**
960
+ * The fix the capture refusal warning (shell-capture.ts refuseOnCaptureGuard)
961
+ * gives for a cookies()/headers()/{ cache: false } variable read. Recorded
962
+ * with the trip (tripShellCaptureGuard), so each guarded read carries its own
963
+ * fix; the theme reads (cookie-store.ts assertThemeReadAllowed) record theirs.
964
+ */
965
+ const REQUEST_READ_CAPTURE_FIX: string =
966
+ "Read it in a loader without ssr: false and consume it with useLoader under " +
967
+ "loading() or an inline <Suspense> (a live hole). A promise the handler passes " +
968
+ "or pushes does not help: the capture waits for it.";
969
+
970
+ /**
971
+ * True when `ctx` is the active capture render (assertNotInsideShellCapture
972
+ * above). On true the capture context is flagged with the read (`surface`,
973
+ * e.g. "cookies()", "ctx.theme") and the fix the refusal warning gives, plus
974
+ * the loader body (if any) that made the read, and the caller throws.
975
+ */
976
+ export function tripShellCaptureGuard(
977
+ ctx: unknown,
978
+ surface: string,
979
+ fix: string,
980
+ ): boolean {
981
+ if (
982
+ ctx === null ||
983
+ typeof ctx !== "object" ||
984
+ (ctx as { _shellCaptureRun?: unknown })._shellCaptureRun !== true
985
+ ) {
986
+ return false;
987
+ }
988
+ // Record WHICH loader body (if any) made the read, so the refusal warning
989
+ // can name the real source instead of hardcoding a lane (issue #672).
990
+ const flagged = ctx as {
991
+ _shellCaptureGuardTripped?: { surface: string; fix: string };
992
+ _shellCaptureGuardTrippedLoaderId?: string;
993
+ };
994
+ flagged._shellCaptureGuardTripped = { surface, fix };
995
+ flagged._shellCaptureGuardTrippedLoaderId = getCurrentLoaderBodyId();
996
+ return true;
997
+ }
998
+
888
999
  /**
889
1000
  * Read guard for a non-cacheable variable (`createVar({ cache: false })` or a
890
1001
  * `ctx.set(..., { cache: false })` write). Callers check isNonCacheable() first
891
1002
  * so ordinary reads never reach the scope lookups.
892
1003
  *
1004
+ * Throws during a PPR shell capture (`requestCtx` is the ambient request
1005
+ * context; assertNotInsideShellCapture): the shell is shared per host+URL.
1006
+ *
893
1007
  * Throws inside a "use cache" body: the key does not include the value, so the
894
1008
  * first caller's value would be stored and served to later callers (#925). A
895
1009
  * loader body entered INSIDE the cached function is exempt, the same baked-copy
@@ -900,15 +1014,18 @@ export function isInsideCacheScope(): boolean {
900
1014
  */
901
1015
  export function assertNonCacheableReadAllowed(
902
1016
  keyOrVar: string | ContextVar<unknown>,
1017
+ requestCtx?: unknown,
903
1018
  ): void {
1019
+ // A shell capture bakes what it reads into a page every visitor gets.
1020
+ assertNotInsideShellCapture(requestCtx, "ctx.get");
904
1021
  const execScope = getCacheExecScope();
1022
+ const variable = ` for a non-cacheable variable${typeof keyOrVar === "string" ? ` "${keyOrVar}"` : ""}`;
905
1023
  if (
906
1024
  execScope !== undefined &&
907
1025
  loaderBodyScopeALS.getStore()?.execScope !== execScope
908
1026
  ) {
909
- const name = typeof keyOrVar === "string" ? ` "${keyOrVar}"` : "";
910
1027
  throw new Error(
911
- `ctx.get() for a non-cacheable variable${name} cannot be called inside a "use cache" function. ` +
1028
+ `ctx.get()${variable} cannot be called inside a "use cache" function. ` +
912
1029
  `The variable was created with { cache: false } or set with { cache: false }, ` +
913
1030
  `and the cache key does not include its value, so the first caller's value ` +
914
1031
  `would be served to later callers. Read it before calling the cached function ` +
@@ -922,6 +1039,88 @@ export function assertNonCacheableReadAllowed(
922
1039
  `and its value would be stale on cache hit. Move the read outside the cached scope.`,
923
1040
  );
924
1041
  }
1042
+ recordLoaderIdentityRead(`ctx.get()${variable}`);
1043
+ }
1044
+
1045
+ /** How the identity error words a read: a function call or a property read. */
1046
+ export type LoaderIdentityReadVerb = "called" | "read";
1047
+
1048
+ /** A request-identity read a loader execution made (recordLoaderIdentityRead). */
1049
+ export interface LoaderIdentityRead {
1050
+ /**
1051
+ * The read as the error names it: "cookies()", "headers()",
1052
+ * `ctx.get() for a non-cacheable variable "x"`, "ctx.theme" or
1053
+ * "getRequestContext().theme".
1054
+ */
1055
+ surface: string;
1056
+ /** "called" for a function, "read" for a property (the theme getters). */
1057
+ verb: LoaderIdentityReadVerb;
1058
+ /** The loader body that made the read. */
1059
+ bodyId: string | undefined;
1060
+ /**
1061
+ * The cached loader whose value carries a read another loader made
1062
+ * (loader-cache.ts identity mark): its readers read it through `via`.
1063
+ */
1064
+ via?: string;
1065
+ }
1066
+
1067
+ /**
1068
+ * Where cache-tag.ts installs its recorder at module init (same key there):
1069
+ * it owns the per-execution recorded sets, and importing it here would be a
1070
+ * cycle.
1071
+ */
1072
+ const IDENTITY_READ_RECORDER_KEY = Symbol.for(
1073
+ "rangojs-router:identity-read-recorder",
1074
+ );
1075
+
1076
+ /**
1077
+ * Record a request-identity read (cookies(), headers(), a non-cacheable
1078
+ * ctx.get()) on the current loader execution (#972).
1079
+ *
1080
+ * isInsideCacheScope() exempts loader bodies because a route cache() never
1081
+ * stores their values. A loader bound with its own cache() does: with no key()
1082
+ * and no store keyGenerator its entry is keyed by loader, host, path and
1083
+ * params only, and an enclosing route cache() key does not partition it
1084
+ * (#974). A cookies() read there stored the first visitor's session and served
1085
+ * it to everyone for the TTL. The read is recorded on the execution, not
1086
+ * thrown at the call: a reader that starts the loader before its binding does
1087
+ * runs it outside any fill, and the binding's MISS then reuses that run. The
1088
+ * fill checks what its execution recorded, through the same links as its tags
1089
+ * (cache-tag.ts recordedIdentityRead), so both orders fail the same way.
1090
+ * Response directives do not record: a key cannot make a skipped body's side
1091
+ * effect reach a HIT.
1092
+ */
1093
+ export function recordLoaderIdentityRead(
1094
+ surface: string,
1095
+ verb: LoaderIdentityReadVerb = "called",
1096
+ ): void {
1097
+ const recorder = (globalThis as Record<symbol, unknown>)[
1098
+ IDENTITY_READ_RECORDER_KEY
1099
+ ] as ((surface: string, verb: LoaderIdentityReadVerb) => void) | undefined;
1100
+ recorder?.(surface, verb);
1101
+ }
1102
+
1103
+ /** The error a loader cache() fill with no declared key fails with (#972). */
1104
+ export function loaderCacheIdentityError(
1105
+ read: LoaderIdentityRead,
1106
+ cachedLoaderId: string,
1107
+ ): Error {
1108
+ const filling = `while filling its own cache() entry with no key()`;
1109
+ const where =
1110
+ read.via !== undefined && read.via !== cachedLoaderId
1111
+ ? `inside loader "${read.bodyId}", which loader "${cachedLoaderId}" reads through another loader ("${read.via}") ${filling}`
1112
+ : read.bodyId !== undefined && read.bodyId !== cachedLoaderId
1113
+ ? `inside loader "${read.bodyId}", which loader "${cachedLoaderId}" reads ${filling}`
1114
+ : `inside loader "${cachedLoaderId}", whose own cache() has no key()`;
1115
+ return new Error(
1116
+ `${read.surface} cannot be ${read.verb} ${where}. ` +
1117
+ `The entry is keyed by loader, host, path and params only, so it is ` +
1118
+ `shared across users: request-scoped data (cookies, headers, ` +
1119
+ `non-cacheable variables) read here would be stored from one request ` +
1120
+ `and served to everyone. Add a key that includes the value, or drop ` +
1121
+ `the loader's cache():\n\n` +
1122
+ ` cache({ ttl: 60, key: (ctx) => \`session:\${cookies().get("session")?.value}\` })`,
1123
+ );
925
1124
  }
926
1125
 
927
1126
  /**
@@ -952,7 +1151,7 @@ export function latchCachedHeaderScope(
952
1151
  /** True inside ANY loader execution — DSL loader scope or a loader body
953
1152
  * (however invoked). The "loaders always re-run fresh" exemptions key off
954
1153
  * this. */
955
- function isInsideAnyLoaderScope(): boolean {
1154
+ export function isInsideAnyLoaderScope(): boolean {
956
1155
  return (
957
1156
  loaderScopeALS.getStore()?.active === true ||
958
1157
  loaderBodyScopeALS.getStore()?.active === true
@@ -1105,24 +1304,29 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
1105
1304
  export function runInsideLoaderBodyScope<T>(
1106
1305
  fn: () => T,
1107
1306
  loaderId?: string,
1108
- handlerInvoked?: boolean,
1307
+ tags?: Set<string>,
1109
1308
  ): T {
1110
1309
  return loaderBodyScopeALS.run(
1111
1310
  {
1112
1311
  active: true,
1113
1312
  loaderId,
1114
- handlerInvoked,
1115
1313
  parent: loaderBodyScopeALS.getStore(),
1116
1314
  execScope: getCacheExecScope(),
1315
+ tags,
1117
1316
  },
1118
1317
  fn,
1119
1318
  );
1120
1319
  }
1121
1320
 
1321
+ /** The innermost loader body's execution tag set (runInsideLoaderBodyScope). */
1322
+ export function getLoaderBodyTags(): Set<string> | undefined {
1323
+ return loaderBodyScopeALS.getStore()?.tags;
1324
+ }
1325
+
1122
1326
  /**
1123
1327
  * The $$id of the loader whose body is currently executing, or undefined
1124
1328
  * outside any loader body. Used by the shell-capture identity guard
1125
- * (cookie-store.ts) so its refusal warning can name the loader that read
1329
+ * (assertNotInsideShellCapture) so its refusal warning can name the loader that read
1126
1330
  * cookies()/headers() instead of blaming a lane it cannot see — the old
1127
1331
  * hardcoded "bake-lane loader" text misled a live-lane debugging session
1128
1332
  * (issue #672, secondary).
@@ -1144,19 +1348,6 @@ export function isInsideLoaderBody(loaderId: string): boolean {
1144
1348
  return false;
1145
1349
  }
1146
1350
 
1147
- /**
1148
- * True while a HANDLER-invoked loader body (`await ctx.use(Loader)` from a
1149
- * handler, not the DSL segment funnel) is executing. The consumption-lane
1150
- * rule keys off this: handler consumption yields a BAKED copy in every shared
1151
- * artifact — cache(), "use cache", and the PPR shell — so the shell-capture
1152
- * identity guard (cookie-store.ts) permits cookies()/headers() here, exactly
1153
- * like the cache-purity guards do. DSL segment loaders (live lane masked at
1154
- * capture, bake lane guarded) never set the flag.
1155
- */
1156
- export function isInsideHandlerInvokedLoaderBody(): boolean {
1157
- return loaderBodyScopeALS.getStore()?.handlerInvoked === true;
1158
- }
1159
-
1160
1351
  // Scope for handle PUSH CALLBACKS (push(() => ...), including async ones).
1161
1352
  // A push callback's value is stored as-is; if it is a promise it is NOT tracked
1162
1353
  // by handleStore.settled and does not block segment resolution, so a
@@ -11,11 +11,11 @@ import type { CookieOptions } from "../router/middleware-types.js";
11
11
  import { getRequestContext, _getRequestContext } from "./request-context.js";
12
12
  import {
13
13
  isInsideCacheScope,
14
- getCurrentLoaderBodyId,
15
- isInsideHandlerInvokedLoaderBody,
14
+ assertNotInsideShellCapture,
15
+ tripShellCaptureGuard,
16
+ recordLoaderIdentityRead,
16
17
  } from "./context.js";
17
18
  import { isInsideCacheExecScope } from "../cache/cache-exec-scope.js";
18
- import { PPR_LANE_HINT } from "../rsc/shell-capture-constants.js";
19
19
 
20
20
  /**
21
21
  * A single cookie entry returned by get() and getAll().
@@ -113,9 +113,13 @@ type HeadersIterator<T> = IterableIterator<T>;
113
113
  * (Scar: the previous INSIDE_CACHE_EXEC stamp on the shared RequestContext
114
114
  * made exactly that parallel read throw for the cached fetch's whole
115
115
  * execution window — see cache-exec-scope.ts.)
116
+ *
117
+ * The loader cache() identity reads (#972) are recorded by the read methods
118
+ * instead (createCookieStore, the headers() view), so a write alone is not one.
116
119
  */
117
120
  function assertNotInsideCacheContext(fnName: string): void {
118
- if (isInsideCacheExecScope()) {
121
+ const scope = refusingCacheScope();
122
+ if (scope === "use cache") {
119
123
  throw new Error(
120
124
  `${fnName}() cannot be called inside a "use cache" function. ` +
121
125
  `Request-scoped data (cookies, headers) varies per request but is not ` +
@@ -126,7 +130,7 @@ function assertNotInsideCacheContext(fnName: string): void {
126
130
  ` const data = await getCachedData(locale); // locale is now in the cache key`,
127
131
  );
128
132
  }
129
- if (isInsideCacheScope()) {
133
+ if (scope === "cache()") {
130
134
  throw new Error(
131
135
  `${fnName}() cannot be called inside a cache() boundary. ` +
132
136
  `A cache() scope caches everything except loaders, so request-scoped ` +
@@ -139,72 +143,89 @@ function assertNotInsideCacheContext(fnName: string): void {
139
143
  }
140
144
 
141
145
  /**
142
- * Throw if called during the ACTIVE background shell-capture render
143
- * (`_shellCaptureRun` true on the derived request context built by
144
- * shell-capture.ts). The captured shell prelude is shared across every user
145
- * hitting the URL, so a request-scoped read here would bake one user's
146
- * cookies/headers into markup served to others — same hazard as the cache
147
- * scopes above, at the document tier. DSL segment loaders need no exemption:
148
- * the live lane is masked (never executed) during capture, and the bake lane
149
- * is exactly what this guard exists for.
150
- *
151
- * HANDLER-INVOKED loader bodies (`await ctx.use(Loader)` from a handler) are
152
- * EXEMPT — the consumption-lane rule: handler consumption yields a BAKED
153
- * shared copy in every artifact tier, and the cache-purity guards above
154
- * already permit identity reads there (cache()/"use cache" bake the same
155
- * reads today). Guarding only the PPR tier made the same code legal under
156
- * cache() but capture-refusing under ppr (issue #672 / #674). The trade is
157
- * documented: an identity read in a handler-consumed loader bakes the CAPTURE
158
- * request's value into the shared shell; client-side consumption (useLoader)
159
- * is the live lane.
160
- *
161
- * Keys off `_shellCaptureRun`, NOT the `_shellCapture` descriptor: the descriptor
162
- * is also present during the FOREGROUND render (it means "a capture is wanted"),
163
- * and the foreground must read cookies/headers normally to serve the real user.
164
- * Only the derived capture context sets `_shellCaptureRun`.
146
+ * The cache scope that refuses a request-scoped read here, or undefined. One
147
+ * owner of the predicates and their order (assertNotInsideCacheContext above),
148
+ * so cookies()/headers() and the theme reads refuse in the same places.
149
+ */
150
+ function refusingCacheScope(): "use cache" | "cache()" | undefined {
151
+ if (isInsideCacheExecScope()) return "use cache";
152
+ if (isInsideCacheScope()) return "cache()";
153
+ return undefined;
154
+ }
155
+
156
+ /** The fix for a theme read that a cache() boundary or a ppr capture refuses. */
157
+ const THEME_READ_FIX =
158
+ "On ppr and cache() routes, read the theme with useTheme() in a client " +
159
+ "component, or in a live loader (no ssr: false) with cookies().get(<storageKey>). " +
160
+ "The <html> theme class needs neither: the theme script sets it before paint. " +
161
+ "See the /theme skill (node_modules/@rangojs/router/skills/theme/SKILL.md).";
162
+
163
+ /** The public reads of the visitor's theme cookie that assertThemeReadAllowed guards. */
164
+ export type ThemeReadSurface = "ctx.theme" | "getRequestContext().theme";
165
+
166
+ /**
167
+ * Guard for a read of the visitor's theme: the handler and middleware
168
+ * `ctx.theme` (handler-context.ts, middleware.ts) and `getRequestContext().theme`
169
+ * (request-context.ts). The theme is the visitor's theme cookie, so it is an
170
+ * identity read like cookies(): it refuses in the same scopes, in the same
171
+ * order (refusingCacheScope, then tripShellCaptureGuard in context.ts), with
172
+ * the same exemption (a loader body under cache(); at capture there is none).
173
+ * Before #971 each read was a plain cookie read, so a ppr shell, a cache()
174
+ * entry or a "use cache" entry stored the first visitor's theme and served it
175
+ * to every later visitor.
165
176
  *
166
- * Applies only to the READ surfaces (cookies(), headers()) whose values
167
- * become markup. Response directives (invalidateClientCache(),
168
- * keepClientCache()) stay callable: during capture they are header effects on
169
- * a discarded response, and on the live HIT path the full pipeline runs so their
170
- * headers flow to the client normally.
177
+ * A read the guards allow is recorded on the current loader execution, as a
178
+ * cookies() read method records it (#972, recordLoaderIdentityRead).
171
179
  *
172
- * The throw makes such a route PPR-ineligible by construction: the capture
173
- * render errors, nothing is stored, and every request keeps getting the
174
- * normal axis-1 render.
180
+ * `ctx` is the request context at read time, as cookies() reads it. The
181
+ * router's own payload read (payloadInitialTheme, rsc/full-payload.ts) goes
182
+ * through the unguarded `_readTheme()`.
175
183
  */
176
- function assertNotInsideShellCapture(ctx: unknown, fnName: string): void {
177
- if (
178
- ctx !== null &&
179
- typeof ctx === "object" &&
180
- (ctx as { _shellCaptureRun?: unknown })._shellCaptureRun === true
181
- ) {
182
- if (isInsideHandlerInvokedLoaderBody()) return;
183
- // Flag the capture context BEFORE throwing: inside an executing bake-lane
184
- // loader this throw is swallowed by wrapLoaderPromise into per-loader error
185
- // UI, which would bake silently into the shared shell. The capture checks
186
- // the flag after the render and refuses (shell-capture.ts). Also record
187
- // WHICH loader body (if any) made the read, so the refusal warning can
188
- // name the real source instead of hardcoding a lane — the read may come
189
- // from a bake-lane loader OR from handler/render code (issue #672).
190
- (ctx as { _shellCaptureGuardTripped?: string })._shellCaptureGuardTripped =
191
- fnName;
192
- (
193
- ctx as { _shellCaptureGuardTrippedLoaderId?: string }
194
- )._shellCaptureGuardTrippedLoaderId = getCurrentLoaderBodyId();
184
+ export function assertThemeReadAllowed(
185
+ ctx: unknown,
186
+ surface: ThemeReadSurface,
187
+ ): void {
188
+ const scope = refusingCacheScope();
189
+ if (scope === "use cache") {
190
+ throw new Error(
191
+ `${surface} cannot be read inside a "use cache" function. The theme ` +
192
+ `comes from the visitor's cookie and is not in the cache key, so the ` +
193
+ `first caller's theme would be served to later callers. Read it ` +
194
+ `before the cached function and pass it in as an argument.`,
195
+ );
196
+ }
197
+ if (scope === "cache()") {
198
+ throw new Error(
199
+ `${surface} cannot be read inside a cache() boundary. The theme comes ` +
200
+ `from the visitor's cookie, so the first visitor's theme would be ` +
201
+ `stored in the shared entry and served to everyone. ${THEME_READ_FIX}`,
202
+ );
203
+ }
204
+ if (tripShellCaptureGuard(ctx, surface, THEME_READ_FIX)) {
195
205
  throw new Error(
196
- `${fnName}() cannot be called while capturing a shared shell ` +
197
- `(ppr shell capture). The captured shell is served to every user ` +
198
- `of this URL, so request-scoped data read here would leak one user's ` +
199
- `${fnName === "cookies" ? "cookies" : "headers"} to others. Read it ` +
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,
206
+ `${surface} cannot be read while capturing a shared shell (ppr shell ` +
207
+ `capture). The captured shell is served to every visitor of this URL, ` +
208
+ `so the capturing visitor's theme would reach everyone. ${THEME_READ_FIX}`,
203
209
  );
204
210
  }
211
+ // An identity read like a cookies() read method: a loader cache() fill with
212
+ // no key() refuses an execution that made one (#972).
213
+ recordLoaderIdentityRead(surface, "read");
205
214
  }
206
215
 
207
216
  const HEADERS_MUTATION_METHODS = new Set(["set", "append", "delete"]);
217
+ // Reading one records the read (#972), like the cookies() read methods: a
218
+ // headers() view taken outside a loader and read inside one still counts.
219
+ const HEADERS_READ_PROPS = new Set<string | symbol>([
220
+ "get",
221
+ "has",
222
+ "entries",
223
+ "keys",
224
+ "values",
225
+ "forEach",
226
+ "getSetCookie",
227
+ Symbol.iterator,
228
+ ]);
208
229
 
209
230
  /**
210
231
  * Get the original request headers (read-only).
@@ -227,6 +248,7 @@ export function headers(): ReadonlyHeaders {
227
248
  assertNotInsideShellCapture(ctx, "headers");
228
249
  return new Proxy(ctx.request.headers, {
229
250
  get(target, prop, receiver) {
251
+ if (HEADERS_READ_PROPS.has(prop)) recordLoaderIdentityRead("headers()");
230
252
  if (typeof prop === "string" && HEADERS_MUTATION_METHODS.has(prop)) {
231
253
  return () => {
232
254
  throw new Error(
@@ -307,11 +329,13 @@ function createCookieStore(ctx: {
307
329
  }): CookieStore {
308
330
  return {
309
331
  get(name: string): Cookie | undefined {
332
+ recordLoaderIdentityRead("cookies()");
310
333
  const value = ctx.cookie(name);
311
334
  return value !== undefined ? { name, value } : undefined;
312
335
  },
313
336
 
314
337
  getAll(name?: string): Cookie[] {
338
+ recordLoaderIdentityRead("cookies()");
315
339
  const all = ctx.cookies();
316
340
  if (name !== undefined) {
317
341
  const value = all[name];
@@ -321,6 +345,7 @@ function createCookieStore(ctx: {
321
345
  },
322
346
 
323
347
  has(name: string): boolean {
348
+ recordLoaderIdentityRead("cookies()");
324
349
  return ctx.cookie(name) !== undefined;
325
350
  },
326
351