@rangojs/router 0.0.0-experimental.112 → 0.0.0-experimental.114

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 (48) hide show
  1. package/dist/bin/rango.js +74 -3
  2. package/dist/vite/index.js +133 -18
  3. package/package.json +1 -1
  4. package/skills/cache-guide/SKILL.md +35 -24
  5. package/skills/caching/SKILL.md +115 -7
  6. package/skills/document-cache/SKILL.md +78 -55
  7. package/skills/hooks/SKILL.md +40 -22
  8. package/skills/links/SKILL.md +10 -10
  9. package/skills/loader/SKILL.md +3 -3
  10. package/skills/rango/SKILL.md +16 -10
  11. package/skills/react-compiler/SKILL.md +168 -0
  12. package/skills/use-cache/SKILL.md +34 -5
  13. package/skills/view-transitions/SKILL.md +85 -3
  14. package/src/browser/react/location-state-shared.ts +93 -3
  15. package/src/browser/react/use-reverse.ts +19 -12
  16. package/src/build/route-types/per-module-writer.ts +4 -1
  17. package/src/build/route-types/router-processing.ts +14 -1
  18. package/src/build/route-types/source-scan.ts +118 -0
  19. package/src/cache/cache-scope.ts +28 -42
  20. package/src/cache/cf/cf-cache-store.ts +49 -6
  21. package/src/handle.ts +3 -5
  22. package/src/loader-store.ts +62 -25
  23. package/src/loader.rsc.ts +2 -5
  24. package/src/loader.ts +3 -10
  25. package/src/missing-id-error.ts +68 -0
  26. package/src/reverse.ts +16 -13
  27. package/src/route-definition/dsl-helpers.ts +5 -2
  28. package/src/route-definition/helpers-types.ts +31 -10
  29. package/src/router/loader-resolution.ts +16 -2
  30. package/src/router/match-middleware/cache-lookup.ts +44 -91
  31. package/src/router/match-middleware/cache-store.ts +3 -2
  32. package/src/router/router-options.ts +24 -0
  33. package/src/router/segment-resolution/fresh.ts +17 -4
  34. package/src/router/segment-resolution/revalidation.ts +17 -4
  35. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  36. package/src/router/types.ts +8 -0
  37. package/src/router.ts +2 -0
  38. package/src/segment-system.tsx +59 -10
  39. package/src/server/context.ts +26 -0
  40. package/src/server/cookie-store.ts +28 -4
  41. package/src/types/handler-context.ts +5 -2
  42. package/src/types/segments.ts +18 -1
  43. package/src/urls/path-helper-types.ts +9 -1
  44. package/src/use-loader.tsx +89 -42
  45. package/src/vite/plugins/expose-ids/export-analysis.ts +68 -12
  46. package/src/vite/plugins/expose-internal-ids.ts +12 -4
  47. package/src/vite/plugins/use-cache-transform.ts +12 -10
  48. package/src/vite/router-discovery.ts +14 -2
package/dist/bin/rango.js CHANGED
@@ -543,7 +543,7 @@ function writePerModuleRouteTypesForFile(filePath) {
543
543
  } else {
544
544
  routes = extractRoutesFromSource(source);
545
545
  }
546
- const genPath = filePath.replace(/\.(tsx?)$/, ".gen.ts");
546
+ const genPath = filePath.replace(/\.(tsx?|jsx?)$/, ".gen.ts");
547
547
  if (routes.length === 0) {
548
548
  if (varNames.length > 0 && !existsSync2(genPath)) {
549
549
  writeFileSync(genPath, generatePerModuleTypesSource([]));
@@ -576,6 +576,75 @@ var init_per_module_writer = __esm({
576
576
  }
577
577
  });
578
578
 
579
+ // src/build/route-types/source-scan.ts
580
+ function isLineTerminator(ch) {
581
+ const c = ch.charCodeAt(0);
582
+ return c === 10 || c === 13 || c === 8232 || c === 8233;
583
+ }
584
+ function makeCodeClassifier(code) {
585
+ const n = code.length;
586
+ let i = 0;
587
+ let skipStart = -1;
588
+ let skipEnd = -1;
589
+ return (q) => {
590
+ if (q >= skipStart && q < skipEnd) return false;
591
+ while (i < n && i <= q) {
592
+ const c = code[i];
593
+ const d = i + 1 < n ? code[i + 1] : "";
594
+ let end = -1;
595
+ if (c === "/" && d === "/") {
596
+ let j = i + 2;
597
+ while (j < n && !isLineTerminator(code[j])) j++;
598
+ end = j;
599
+ } else if (c === "/" && d === "*") {
600
+ let j = i + 2;
601
+ while (j < n && !(code[j] === "*" && code[j + 1] === "/")) j++;
602
+ end = Math.min(n, j + 2);
603
+ } else if (c === '"' || c === "'" || c === "`") {
604
+ let j = i + 1;
605
+ while (j < n) {
606
+ if (code[j] === "\\") {
607
+ j += 2;
608
+ continue;
609
+ }
610
+ if (code[j] === c) {
611
+ j++;
612
+ break;
613
+ }
614
+ j++;
615
+ }
616
+ end = j;
617
+ }
618
+ if (end >= 0) {
619
+ if (q < end) {
620
+ skipStart = i;
621
+ skipEnd = end;
622
+ return false;
623
+ }
624
+ i = end;
625
+ } else {
626
+ i++;
627
+ }
628
+ }
629
+ return true;
630
+ };
631
+ }
632
+ function firstCodeMatchIndex(code, pattern) {
633
+ const inCode = makeCodeClassifier(code);
634
+ pattern.lastIndex = 0;
635
+ let m;
636
+ while ((m = pattern.exec(code)) !== null) {
637
+ if (inCode(m.index)) return m.index;
638
+ if (pattern.lastIndex <= m.index) pattern.lastIndex = m.index + 1;
639
+ }
640
+ return -1;
641
+ }
642
+ var init_source_scan = __esm({
643
+ "src/build/route-types/source-scan.ts"() {
644
+ "use strict";
645
+ }
646
+ });
647
+
579
648
  // src/build/route-types/router-processing.ts
580
649
  import {
581
650
  readFileSync as readFileSync3,
@@ -630,7 +699,7 @@ function findRouterFilesRecursive(dir, filter, results) {
630
699
  if (filter && !filter(fullPath)) continue;
631
700
  try {
632
701
  const source = readFileSync3(fullPath, "utf-8");
633
- if (ROUTER_CALL_PATTERN.test(source)) {
702
+ if (ROUTER_CALL_PATTERN.test(source) && firstCodeMatchIndex(source, ROUTER_CALL_PATTERN_G) >= 0) {
634
703
  routerFilesInDir.push(fullPath);
635
704
  }
636
705
  } catch {
@@ -971,15 +1040,17 @@ function writeCombinedRouteTypes(root, knownRouterFiles, opts) {
971
1040
  }
972
1041
  }
973
1042
  }
974
- var ROUTER_CALL_PATTERN;
1043
+ var ROUTER_CALL_PATTERN, ROUTER_CALL_PATTERN_G;
975
1044
  var init_router_processing = __esm({
976
1045
  "src/build/route-types/router-processing.ts"() {
977
1046
  "use strict";
978
1047
  init_codegen();
1048
+ init_source_scan();
979
1049
  init_include_resolution();
980
1050
  init_per_module_writer();
981
1051
  init_route_name();
982
1052
  ROUTER_CALL_PATTERN = /\bcreateRouter\s*[<(]/;
1053
+ ROUTER_CALL_PATTERN_G = /\bcreateRouter\s*[<(]/g;
983
1054
  }
984
1055
  });
985
1056
 
@@ -744,6 +744,83 @@ var STRICT_CREATE_CONFIGS = [
744
744
 
745
745
  // src/vite/plugins/expose-ids/export-analysis.ts
746
746
  import { parseAst } from "vite";
747
+
748
+ // src/build/route-types/source-scan.ts
749
+ function isLineTerminator(ch) {
750
+ const c = ch.charCodeAt(0);
751
+ return c === 10 || c === 13 || c === 8232 || c === 8233;
752
+ }
753
+ function makeCodeClassifier(code) {
754
+ const n = code.length;
755
+ let i = 0;
756
+ let skipStart = -1;
757
+ let skipEnd = -1;
758
+ return (q) => {
759
+ if (q >= skipStart && q < skipEnd) return false;
760
+ while (i < n && i <= q) {
761
+ const c = code[i];
762
+ const d = i + 1 < n ? code[i + 1] : "";
763
+ let end = -1;
764
+ if (c === "/" && d === "/") {
765
+ let j = i + 2;
766
+ while (j < n && !isLineTerminator(code[j])) j++;
767
+ end = j;
768
+ } else if (c === "/" && d === "*") {
769
+ let j = i + 2;
770
+ while (j < n && !(code[j] === "*" && code[j + 1] === "/")) j++;
771
+ end = Math.min(n, j + 2);
772
+ } else if (c === '"' || c === "'" || c === "`") {
773
+ let j = i + 1;
774
+ while (j < n) {
775
+ if (code[j] === "\\") {
776
+ j += 2;
777
+ continue;
778
+ }
779
+ if (code[j] === c) {
780
+ j++;
781
+ break;
782
+ }
783
+ j++;
784
+ }
785
+ end = j;
786
+ }
787
+ if (end >= 0) {
788
+ if (q < end) {
789
+ skipStart = i;
790
+ skipEnd = end;
791
+ return false;
792
+ }
793
+ i = end;
794
+ } else {
795
+ i++;
796
+ }
797
+ }
798
+ return true;
799
+ };
800
+ }
801
+ function firstCodeMatchIndex(code, pattern) {
802
+ const inCode = makeCodeClassifier(code);
803
+ pattern.lastIndex = 0;
804
+ let m;
805
+ while ((m = pattern.exec(code)) !== null) {
806
+ if (inCode(m.index)) return m.index;
807
+ if (pattern.lastIndex <= m.index) pattern.lastIndex = m.index + 1;
808
+ }
809
+ return -1;
810
+ }
811
+ function codeMatchIndices(code, pattern) {
812
+ const inCode = makeCodeClassifier(code);
813
+ const indices = [];
814
+ pattern.lastIndex = 0;
815
+ let m;
816
+ while ((m = pattern.exec(code)) !== null) {
817
+ if (inCode(m.index)) indices.push(m.index);
818
+ if (pattern.lastIndex <= m.index) pattern.lastIndex = m.index + 1;
819
+ }
820
+ return indices;
821
+ }
822
+
823
+ // src/vite/plugins/expose-ids/export-analysis.ts
747
824
  function isExportOnlyFile(code, bindings) {
748
825
  if (bindings.length === 0) return false;
749
826
  const knownLocals = /* @__PURE__ */ new Set();
@@ -772,12 +849,30 @@ function isExportOnlyFile(code, bindings) {
772
849
  }
773
850
  return true;
774
851
  }
775
- function countCreateCallsForNames(code, fnNames) {
776
- const pattern = new RegExp(
852
+ function createCallPattern(fnNames) {
853
+ return new RegExp(
777
854
  `\\b(?:${fnNames.map(escapeRegExp).join("|")})\\s*(?:<[^>]*>\\s*)?\\(`,
778
855
  "g"
779
856
  );
780
- return (code.match(pattern) || []).length;
857
+ }
858
+ function countCreateCallsForNames(code, fnNames) {
859
+ return codeMatchIndices(code, createCallPattern(fnNames)).length;
860
+ }
861
+ function offsetToLineColumn(code, index) {
862
+ let line = 1;
863
+ let lineStart = 0;
864
+ const end = Math.min(index, code.length);
865
+ for (let i = 0; i < end; i++) {
866
+ if (code[i] === "\n") {
867
+ line++;
868
+ lineStart = i + 1;
869
+ }
870
+ }
871
+ return { line, column: index - lineStart + 1 };
872
+ }
873
+ function findUnsupportedCreateCallSites(code, fnNames, supportedBindings) {
874
+ const supported = new Set(supportedBindings.map((b) => b.callExprStart));
875
+ return codeMatchIndices(code, createCallPattern(fnNames)).filter((index) => !supported.has(index)).map((index) => offsetToLineColumn(code, index));
781
876
  }
782
877
  function getImportedFnNames(code, importedName) {
783
878
  const importPattern = /import\s*\{([^}]*)\}\s*from\s*["']@rangojs\/router(?:\/[^"']*)?["']/g;
@@ -936,9 +1031,20 @@ function collectCreateExportBindings(code, fnNames, program) {
936
1031
  }
937
1032
  return bindings;
938
1033
  }
939
- function buildUnsupportedShapeWarning(filePath, fnName) {
940
- return [
941
- `[rango] Unsupported ${fnName} shape in "${filePath}".`,
1034
+ function buildUnsupportedShapeWarning(filePath, fnName, sites = []) {
1035
+ const lines = [`[rango] Unsupported ${fnName} shape in "${filePath}".`];
1036
+ if (sites.length === 1) {
1037
+ const s = sites[0];
1038
+ lines.push(
1039
+ `The ${fnName}(...) call at ${filePath}:${s.line}:${s.column} has no stable $$id injected \u2014 it is not in a supported shape.`
1040
+ );
1041
+ } else if (sites.length > 1) {
1042
+ lines.push(
1043
+ `These ${fnName}(...) calls have no stable $$id injected \u2014 they are not in a supported shape:`
1044
+ );
1045
+ for (const s of sites) lines.push(` - ${filePath}:${s.line}:${s.column}`);
1046
+ }
1047
+ lines.push(
942
1048
  `Supported shapes are:`,
943
1049
  ` - export const X = ${fnName}(...)`,
944
1050
  ` - const X = ${fnName}(...); export { X }`,
@@ -946,7 +1052,8 @@ function buildUnsupportedShapeWarning(filePath, fnName) {
946
1052
  `Potentially unsupported forms include:`,
947
1053
  ` - export let/var X = ${fnName}(...)`,
948
1054
  ` - inline ${fnName}(...) calls`
949
- ].join("\n");
1055
+ );
1056
+ return lines.join("\n");
950
1057
  }
951
1058
 
952
1059
  // src/vite/plugins/expose-ids/loader-transform.ts
@@ -1366,13 +1473,16 @@ ${lazyImports.join(",\n")}
1366
1473
  const hasCode = cfg.fnName === "createLoader" ? hasLoaderCode : cfg.fnName === "createHandle" ? hasHandleCode : hasLocationStateCode;
1367
1474
  if (!hasCode) continue;
1368
1475
  const fnNames = getFnNames(cfg.fnName);
1369
- const totalCalls = countCreateCallsForNames(code, fnNames);
1370
- const supportedBindings = getBindings(code, fnNames).length;
1371
- if (totalCalls <= supportedBindings) continue;
1476
+ const sites = findUnsupportedCreateCallSites(
1477
+ code,
1478
+ fnNames,
1479
+ getBindings(code, fnNames)
1480
+ );
1481
+ if (sites.length === 0) continue;
1372
1482
  const warnKey = `${id}::${cfg.fnName}`;
1373
1483
  if (unsupportedShapeWarnings.has(warnKey)) continue;
1374
1484
  unsupportedShapeWarnings.add(warnKey);
1375
- this.warn(buildUnsupportedShapeWarning(filePath, cfg.fnName));
1485
+ this.warn(buildUnsupportedShapeWarning(filePath, cfg.fnName, sites));
1376
1486
  }
1377
1487
  if (hasLoaderCode && isRscEnv) {
1378
1488
  const fnNames = getFnNames("createLoader");
@@ -1658,6 +1768,7 @@ import MagicString4 from "magic-string";
1658
1768
  var debug4 = createRangoDebugger(NS.transform);
1659
1769
  var CACHE_RUNTIME_IMPORT = "@rangojs/router/cache-runtime";
1660
1770
  var LAYOUT_TEMPLATE_PATTERN = /\/(layout|template)\.(tsx?|jsx?)$/;
1771
+ var USE_CACHE_DIRECTIVE_RE = /^use cache(:\s*[\w-]+)?$/;
1661
1772
  function useCacheTransform() {
1662
1773
  let projectRoot = "";
1663
1774
  let isBuild = false;
@@ -1786,7 +1897,7 @@ function transformFileLevelUseCache(code, ast, filePath, sourceId, isBuild, isLa
1786
1897
  function transformFunctionLevelUseCache(code, ast, filePath, sourceId, isBuild, transformHoistInlineDirective) {
1787
1898
  try {
1788
1899
  const { output, names } = transformHoistInlineDirective(code, ast, {
1789
- directive: /^use cache(:\s*[\w-]+)?$/,
1900
+ directive: USE_CACHE_DIRECTIVE_RE,
1790
1901
  runtime: (value, name, meta) => {
1791
1902
  const funcId = isBuild ? hashId(filePath, name) : `${filePath}#${name}`;
1792
1903
  const profileMatch = meta.directiveMatch[1];
@@ -1816,14 +1927,13 @@ function findFileLevelDirective(ast) {
1816
1927
  }
1817
1928
  return null;
1818
1929
  }
1819
- var VALID_DIRECTIVE_RE = /^use cache(:\s*[\w-]+)?$/;
1820
1930
  var NEAR_MISS_RE = /^use cache:\s*.+$/;
1821
1931
  function warnOnNearMissDirectives(ast, fileId, warn) {
1822
1932
  const visit = (node) => {
1823
1933
  if (!node || typeof node !== "object") return;
1824
1934
  if (node.type === "ExpressionStatement" && node.expression?.type === "Literal" && typeof node.expression.value === "string") {
1825
1935
  const value = node.expression.value;
1826
- if (value.startsWith("use cache") && NEAR_MISS_RE.test(value) && !VALID_DIRECTIVE_RE.test(value)) {
1936
+ if (value.startsWith("use cache") && NEAR_MISS_RE.test(value) && !USE_CACHE_DIRECTIVE_RE.test(value)) {
1827
1937
  const profilePart = value.slice("use cache:".length).trim();
1828
1938
  warn(
1829
1939
  `[rango:use-cache] "${value}" in ${fileId} has an invalid profile name "${profilePart}". Profile names must match [a-zA-Z0-9_-]+. This directive will be ignored.`
@@ -2019,7 +2129,7 @@ import { resolve } from "node:path";
2019
2129
  // package.json
2020
2130
  var package_default = {
2021
2131
  name: "@rangojs/router",
2022
- version: "0.0.0-experimental.112",
2132
+ version: "0.0.0-experimental.114",
2023
2133
  description: "Django-inspired RSC router with composable URL patterns",
2024
2134
  keywords: [
2025
2135
  "react",
@@ -2677,6 +2787,7 @@ function countPublicRouteEntries(source) {
2677
2787
  return count;
2678
2788
  }
2679
2789
  var ROUTER_CALL_PATTERN = /\bcreateRouter\s*[<(]/;
2790
+ var ROUTER_CALL_PATTERN_G = /\bcreateRouter\s*[<(]/g;
2680
2791
  function isRoutableSourceFile(name) {
2681
2792
  return (name.endsWith(".ts") || name.endsWith(".tsx") || name.endsWith(".js") || name.endsWith(".jsx")) && !name.includes(".gen.") && !name.includes(".test.") && !name.includes(".spec.");
2682
2793
  }
@@ -2704,7 +2815,7 @@ function findRouterFilesRecursive(dir, filter, results) {
2704
2815
  if (filter && !filter(fullPath)) continue;
2705
2816
  try {
2706
2817
  const source = readFileSync2(fullPath, "utf-8");
2707
- if (ROUTER_CALL_PATTERN.test(source)) {
2818
+ if (ROUTER_CALL_PATTERN.test(source) && firstCodeMatchIndex(source, ROUTER_CALL_PATTERN_G) >= 0) {
2708
2819
  routerFilesInDir.push(fullPath);
2709
2820
  }
2710
2821
  } catch {
@@ -5946,8 +6057,12 @@ ${err.stack}`
5946
6057
  const trimmed = source.trimStart();
5947
6058
  const isUseClient = trimmed.startsWith('"use client"') || trimmed.startsWith("'use client'");
5948
6059
  if (!inRecoveryMode && isUseClient) return;
5949
- const hasUrls = source.includes("urls(");
5950
- const hasCreateRouter = /\bcreateRouter\s*[<(]/.test(source);
6060
+ let hasUrls = source.includes("urls(");
6061
+ let hasCreateRouter = /\bcreateRouter\s*[<(]/.test(source);
6062
+ if (hasUrls) hasUrls = firstCodeMatchIndex(source, /urls\(/g) >= 0;
6063
+ if (hasCreateRouter) {
6064
+ hasCreateRouter = firstCodeMatchIndex(source, /\bcreateRouter\s*[<(]/g) >= 0;
6065
+ }
5951
6066
  if (!inRecoveryMode && !hasUrls && !hasCreateRouter) return;
5952
6067
  if (inRecoveryMode) {
5953
6068
  debugDiscovery?.(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.112",
3
+ "version": "0.0.0-experimental.114",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -6,8 +6,9 @@ argument-hint:
6
6
 
7
7
  # cache() vs "use cache" — When to Use Which
8
8
 
9
- Both mechanisms share the same backing store, cache profiles, and tag-based
10
- invalidation. They differ in scope, cache key, execution model, and runtime control.
9
+ Both mechanisms share the same backing store and cache profiles, and both accept
10
+ an optional `tags` field (not yet honored by the built-in stores — see "Two axes"
11
+ below). They differ in scope, cache key, execution model, and runtime control.
11
12
 
12
13
  ## Two axes — do not conflate
13
14
 
@@ -17,7 +18,10 @@ caching:
17
18
 
18
19
  1. **Stored-value freshness** — _is a cached value still good?_
19
20
  → `"use cache"` (fn/component), `cache()` (segment), loader `cache()` (loader data).
20
- Entries are coupled across the one store by **tags** (`revalidateTag`).
21
+ Entries expire by **TTL/SWR**. They accept an optional `tags` field, but the
22
+ built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`) do not yet index or
23
+ invalidate by tag, so tag-based invalidation (`revalidateTag`) is a
24
+ forward-looking API requiring a custom store with secondary indices.
21
25
  2. **Client-update selection** — _should this segment re-run and stream to the
22
26
  client on this navigation/action?_
23
27
  → `revalidate()`. Covered in `/loader` and `/route`, **not here**.
@@ -58,12 +62,16 @@ There are two guard models to keep separate. Both block response side effects
58
62
  else they allow:
59
63
 
60
64
  - **`cache()` boundary guard** (route-level) — fires while the handler runs on a
61
- miss. `ctx.get(nonCacheableVar)` throws (a tainted value would be baked into the
62
- cached segment) and response side effects (`ctx.header()`, `ctx.setCookie()`,
65
+ miss. `cookies()` and `headers()` throw (request-scoped data would be baked into
66
+ the shared cached shell), `ctx.get(nonCacheableVar)` throws (a tainted value
67
+ would be baked in), and response side effects (`ctx.header()`, `ctx.setCookie()`,
63
68
  `ctx.setStatus()`, `ctx.onResponse()`) throw. `ctx.set()` of a cacheable var is
64
- **allowed** — children are cached too and can read it.
65
- - **`"use cache"` exec-guard** (function-level) — stricter: inside the cached
66
- function `cookies()`, `headers()`, `ctx.set()`, and `ctx.header()` all throw.
69
+ **allowed** — children are cached too and can read it. **Loaders are exempt**
70
+ (they always run fresh) — read request data inside a loader.
71
+ - **`"use cache"` exec-guard** (function-level) the same request-scoped APIs
72
+ throw inside the cached function (`cookies()`, `headers()`, `ctx.set()`,
73
+ `ctx.header()`); additionally, tainted `ctx`/`env`/`req` args are excluded from
74
+ the cache key.
67
75
 
68
76
  ### Cross-deploy safety: version-segmented store keys
69
77
 
@@ -331,18 +339,21 @@ specifies `cache: false`, the value is non-cacheable.
331
339
 
332
340
  **Behavior inside a `cache()` boundary:**
333
341
 
334
- | Operation | Inside a `cache()` boundary |
335
- | --------------------------------- | -------------------------------------------------- |
336
- | `ctx.get(cacheableVar)` | Allowed |
337
- | `ctx.get(nonCacheableVar)` | Throws (would be baked in) |
338
- | `ctx.set(var, value)` (cacheable) | Allowed |
339
- | `ctx.header()` / cookie writes | Throws (response side effect would be lost on hit) |
340
-
341
- (Inside a `"use cache"` function the scoping differs: `ctx.set`, `ctx.header()`,
342
- `cookies()`, and `headers()` **throw** via the cache-exec guard. Inside
343
- `cache()`, response side effects throw via the cache-boundary guard, while the
344
- `ctx.get(nonCacheableVar)` taint check above is tied to the `cache()` boundary —
345
- see "Headers and Cookies" and the precise guarantee below.)
342
+ | Operation | Inside a `cache()` boundary |
343
+ | ----------------------------------------- | ------------------------------------------------------ |
344
+ | `cookies()` / `headers()` (read or write) | Throws (request-scoped, would poison the shared entry) |
345
+ | `ctx.get(cacheableVar)` | Allowed |
346
+ | `ctx.get(nonCacheableVar)` | Throws (would be baked in) |
347
+ | `ctx.set(var, value)` (cacheable) | Allowed |
348
+ | `ctx.header()` / cookie writes | Throws (response side effect would be lost on hit) |
349
+ | Any of the above **inside a loader** | Allowed (loaders always run fresh) |
350
+
351
+ (Both scopes block the same request-scoped APIs `cookies()`, `headers()`,
352
+ response side effects, and non-cacheable `ctx.get()` because each would leak
353
+ per-request data into a shared cache entry. The `cache()` boundary tracks the
354
+ scope via `isInsideCacheScope()`; `"use cache"` uses the exec guard and also
355
+ excludes tainted `ctx`/`env`/`req` args from the cache key. Loaders are exempt in
356
+ both — see "Headers and Cookies" and the precise guarantee below.)
346
357
 
347
358
  Write is dumb — `ctx.set()` stores the cache metadata but does not enforce.
348
359
  Enforcement happens at read time (`ctx.get()`), where ALS detects the cache
@@ -379,10 +390,10 @@ layout((ctx) => {
379
390
  So do **not** read this as "you can't cache user data" — that overstates it and
380
391
  breeds the false confidence that makes the derived leak _more_ likely. The guard
381
392
  is deliberately non-propagating (propagation would cost a wrapper per derivation
382
- on the hot path), and it is scoped to the `cache()` segment boundary `"use
383
- cache"` functions guard request data differently (tainted `ctx`/`env`/`req` args
384
- are excluded from the cache key, and `cookies()` / `headers()` throw inside them;
385
- see "Headers and Cookies"). The pattern that stays safe is also the natural one:
393
+ on the hot path), and it is scoped to the `cache()` segment boundary. `"use
394
+ cache"` functions block the same request-scoped reads (`cookies()` / `headers()`
395
+ throw inside them) and additionally exclude tainted `ctx`/`env`/`req` args from
396
+ the cache key. The pattern that stays safe is also the natural one:
386
397
  **read tainted context at the point of use, in the path that needs it (a loader or
387
398
  live segment) — never extract user data into a plain value and cache that.**
388
399
  Loaders are exempt because they run outside the cache scope and resolve fresh
@@ -8,6 +8,46 @@ argument-hint: [setup]
8
8
 
9
9
  @rangojs/router supports segment-level caching with stale-while-revalidate (SWR) for optimal performance.
10
10
 
11
+ > SWR support is store-specific. `CFCacheStore` revalidates segment, response,
12
+ > and `"use cache"` entries in the background. `MemorySegmentCacheStore`
13
+ > supports SWR for response and `"use cache"` item entries, but its
14
+ > route-segment entries expire at TTL with no background revalidation — use
15
+ > `CFCacheStore` for real segment SWR. See `/cache-guide`.
16
+
17
+ ## cache() is Partial Prerendering (PPR)
18
+
19
+ `cache()` caches **everything except loaders**. On a cache hit, the cached
20
+ segments (layouts, route components, parallels — including any resolved
21
+ Suspense) are served from the store, and **loaders re-run fresh on every
22
+ request**, streaming their results into the same response. Loaders are the
23
+ dynamic "holes" of an otherwise-cached tree.
24
+
25
+ This means a `cache()` boundary at the document root **is** whole-document
26
+ Partial Prerendering: the static shell is cached and served instantly while
27
+ per-request/per-user data stays live — in one streamed response, no extra round
28
+ trip. The browser cannot tell the shell came from cache.
29
+
30
+ ```typescript
31
+ cache({ ttl: 60, swr: 300 }, () => [
32
+ layout(<RootLayout />), // cached shell
33
+ path("/dashboard", Dashboard, { name: "dashboard" }, () => [
34
+ loader(StatsLoader), // DYNAMIC HOLE — re-runs every request
35
+ ]),
36
+ ]);
37
+ ```
38
+
39
+ The consumer rule: **want it cached? render it inline. want it dynamic? put it
40
+ in a loader and read it with `useLoader()` in a client component.** Anything
41
+ read with `cookies()`, `headers()`, or a non-cacheable variable belongs in a
42
+ loader (loaders always run fresh). Reading it directly in a cached handler
43
+ throws; awaiting it with `ctx.use()` and rendering the result in a cached
44
+ handler silently bakes per-request data into the shared shell (see "Cache purity
45
+ & tainted objects" below).
46
+
47
+ Pre-rendering (`/prerender`) is the build-time twin: it caches the same shell at
48
+ build time instead of on first request. Both feed the segment system
49
+ identically, and loaders always run fresh at request time.
50
+
11
51
  ## Route-Level Caching with cache()
12
52
 
13
53
  Use the `cache()` DSL function to cache routes:
@@ -116,7 +156,6 @@ import { MemorySegmentCacheStore } from "@rangojs/router/cache";
116
156
 
117
157
  const store = new MemorySegmentCacheStore({
118
158
  defaults: { ttl: 60, swr: 300 },
119
- maxSize: 1000, // Max entries
120
159
  });
121
160
  ```
122
161
 
@@ -173,13 +212,82 @@ const router = createRouter<AppBindings>({
173
212
  KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
174
213
  are only cached in L1.
175
214
 
176
- ## Context Variables Inside Cache Boundaries
215
+ ## Cache purity & tainted objects
216
+
217
+ A `cache()` boundary caches everything except loaders, so anything read inside a
218
+ cached handler is **frozen into the shared cache entry** and served to every
219
+ subsequent visitor. To stop one user's request-scoped data from leaking to
220
+ another, request-scoped APIs are guarded inside a cache scope:
221
+
222
+ | Inside a `cache()` boundary | Behavior |
223
+ | --------------------------------------------------------------- | --------------------------------------------------- |
224
+ | `cookies()` / `headers()` (read or write) | **throws** — request-scoped, would poison the entry |
225
+ | `ctx.header()` / `setCookie()` / `setStatus()` / `onResponse()` | **throws** — response side effects lost on a hit |
226
+ | `ctx.get(var)` where the var is `{ cache: false }` | **throws** on read |
227
+ | `ctx.set(var, value)` for a cacheable var | allowed (children are cached too) |
228
+ | Any of the above **inside a loader** | **allowed** — loaders always run fresh |
229
+
230
+ **Tainted objects.** Request-scoped objects (`ctx`, `env`, `request`) carry an
231
+ internal taint symbol so they are excluded from `"use cache"` cache keys, and
232
+ the cache scope is tracked via async-local state. Two flags back the guards:
233
+ `INSIDE_CACHE_EXEC` (set while a `"use cache"` function runs) and the `cache()`
234
+ DSL scope (`isInsideCacheScope()`). `isInsideCacheScope()` deliberately returns
235
+ `false` inside loaders — which is exactly why loaders are the dynamic holes:
236
+ they may read `cookies()`/`headers()` and re-run on every request.
237
+
238
+ The fix for "I need request data in a cached route": register a `loader()` and
239
+ **consume it with `useLoader()` in a client component**. The loader is the
240
+ dynamic hole — its data rides the fresh (never-cached) loader segment and is
241
+ rendered in the client component, so it never lands in the cached shell.
242
+
243
+ This is NOT the same as awaiting the loader in the handler. A cached handler
244
+ that does `await ctx.use(Loader)` and renders the result bakes that per-request
245
+ data straight into the shared cached segment — the loader running "fresh" does
246
+ not help, because its output was inlined into the cached parent, and `ctx.use()`
247
+ is **not** guarded. `ctx.use()` is a server-side escape hatch for non-rendered
248
+ uses (set a ctx var, make a routing decision); never render its result inside a
249
+ cached handler.
250
+
251
+ ```typescript
252
+ // WRONG — throws: cookies() read directly in a cached handler
253
+ cache({ ttl: 60 }, () => [
254
+ path("/me", () => <Profile id={cookies().get("uid")?.value} />),
255
+ ]);
256
+
257
+ // ALSO WRONG (unguarded, but leaks) — the awaited loader data is rendered into
258
+ // the cached handler, so the user's data is frozen into the shared shell.
259
+ cache({ ttl: 60 }, () => [
260
+ path(
261
+ "/me",
262
+ async (ctx) => {
263
+ const { user } = await ctx.use(MeLoader); // runs fresh…
264
+ return <Profile user={user} />; // …but inlined into the CACHED segment → leak
265
+ },
266
+ { name: "me" },
267
+ () => [loader(MeLoader)],
268
+ ),
269
+ ]);
270
+
271
+ // RIGHT — consume the loader in a CLIENT component via useLoader(). The cached
272
+ // route segment holds only the <Profile/> reference; the user data rides the
273
+ // fresh loader segment and renders client-side.
274
+
275
+ // profile.tsx (client component)
276
+ "use client";
277
+ import { useLoader } from "@rangojs/router/client";
278
+
279
+ export function Profile() {
280
+ const { user } = useLoader(MeLoader); // fresh per request; never cached
281
+ return <span>{user.name}</span>;
282
+ }
283
+
284
+ // urls — register the loader; MeLoader reads cookies() inside the loader (allowed)
285
+ cache({ ttl: 60 }, () => [
286
+ path("/me", () => <Profile />, { name: "me" }, () => [loader(MeLoader)]),
287
+ ]);
288
+ ```
177
289
 
178
- Context variables (`createVar`) are cacheable by default and can be read and
179
- written inside `cache()` scopes. Variables marked with `{ cache: false }` (at
180
- the var level or write level) throw when read inside a cache scope. Response
181
- side effects (`ctx.header()`, `ctx.cookie()`) always throw inside cache
182
- boundaries. See `/cache-guide` for the full cache safety table.
290
+ See `/cache-guide` for the full decision guide and the `cache()` vs `"use cache"` comparison.
183
291
 
184
292
  ## Nested Cache Boundaries
185
293