@rangojs/router 0.0.0-experimental.debug-cache-fix → 0.0.0-experimental.dfdb0387

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 (115) hide show
  1. package/README.md +76 -18
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +702 -231
  4. package/package.json +2 -2
  5. package/skills/cache-guide/SKILL.md +32 -0
  6. package/skills/caching/SKILL.md +8 -0
  7. package/skills/links/SKILL.md +3 -1
  8. package/skills/loader/SKILL.md +53 -43
  9. package/skills/middleware/SKILL.md +2 -0
  10. package/skills/prerender/SKILL.md +110 -68
  11. package/skills/route/SKILL.md +31 -0
  12. package/skills/router-setup/SKILL.md +87 -2
  13. package/skills/typesafety/SKILL.md +10 -0
  14. package/src/__internal.ts +1 -1
  15. package/src/browser/app-version.ts +14 -0
  16. package/src/browser/navigation-bridge.ts +16 -3
  17. package/src/browser/navigation-client.ts +98 -46
  18. package/src/browser/navigation-store.ts +43 -8
  19. package/src/browser/partial-update.ts +32 -5
  20. package/src/browser/prefetch/cache.ts +16 -6
  21. package/src/browser/prefetch/fetch.ts +52 -6
  22. package/src/browser/prefetch/queue.ts +61 -29
  23. package/src/browser/prefetch/resource-ready.ts +77 -0
  24. package/src/browser/react/Link.tsx +67 -8
  25. package/src/browser/react/NavigationProvider.tsx +13 -4
  26. package/src/browser/react/context.ts +7 -2
  27. package/src/browser/react/use-handle.ts +9 -58
  28. package/src/browser/react/use-router.ts +21 -8
  29. package/src/browser/rsc-router.tsx +26 -3
  30. package/src/browser/scroll-restoration.ts +10 -8
  31. package/src/browser/segment-reconciler.ts +26 -0
  32. package/src/browser/server-action-bridge.ts +8 -6
  33. package/src/browser/types.ts +27 -5
  34. package/src/build/generate-manifest.ts +6 -6
  35. package/src/build/generate-route-types.ts +3 -0
  36. package/src/build/route-types/include-resolution.ts +8 -1
  37. package/src/build/route-types/router-processing.ts +211 -72
  38. package/src/build/route-types/scan-filter.ts +8 -1
  39. package/src/cache/cache-scope.ts +12 -14
  40. package/src/cache/taint.ts +55 -0
  41. package/src/client.tsx +2 -56
  42. package/src/context-var.ts +72 -2
  43. package/src/handle.ts +40 -0
  44. package/src/index.rsc.ts +3 -1
  45. package/src/index.ts +12 -0
  46. package/src/prerender/store.ts +5 -4
  47. package/src/prerender.ts +138 -77
  48. package/src/reverse.ts +22 -1
  49. package/src/route-definition/dsl-helpers.ts +42 -19
  50. package/src/route-definition/helpers-types.ts +10 -6
  51. package/src/route-definition/index.ts +3 -0
  52. package/src/route-definition/redirect.ts +9 -1
  53. package/src/route-definition/resolve-handler-use.ts +149 -0
  54. package/src/route-types.ts +11 -0
  55. package/src/router/content-negotiation.ts +100 -1
  56. package/src/router/handler-context.ts +79 -23
  57. package/src/router/intercept-resolution.ts +9 -4
  58. package/src/router/loader-resolution.ts +156 -21
  59. package/src/router/match-api.ts +124 -189
  60. package/src/router/match-middleware/cache-lookup.ts +26 -7
  61. package/src/router/match-middleware/segment-resolution.ts +53 -0
  62. package/src/router/match-result.ts +82 -4
  63. package/src/router/middleware-types.ts +6 -8
  64. package/src/router/middleware.ts +2 -5
  65. package/src/router/navigation-snapshot.ts +182 -0
  66. package/src/router/prerender-match.ts +110 -10
  67. package/src/router/preview-match.ts +30 -102
  68. package/src/router/request-classification.ts +310 -0
  69. package/src/router/route-snapshot.ts +245 -0
  70. package/src/router/router-interfaces.ts +36 -4
  71. package/src/router/router-options.ts +37 -11
  72. package/src/router/segment-resolution/fresh.ts +80 -9
  73. package/src/router/segment-resolution/helpers.ts +29 -24
  74. package/src/router/segment-resolution/revalidation.ts +91 -8
  75. package/src/router/types.ts +1 -0
  76. package/src/router.ts +54 -5
  77. package/src/rsc/handler.ts +472 -372
  78. package/src/rsc/loader-fetch.ts +23 -3
  79. package/src/rsc/manifest-init.ts +5 -1
  80. package/src/rsc/progressive-enhancement.ts +14 -2
  81. package/src/rsc/rsc-rendering.ts +10 -1
  82. package/src/rsc/server-action.ts +8 -0
  83. package/src/rsc/ssr-setup.ts +2 -2
  84. package/src/rsc/types.ts +9 -1
  85. package/src/server/context.ts +50 -1
  86. package/src/server/handle-store.ts +19 -0
  87. package/src/server/loader-registry.ts +9 -8
  88. package/src/server/request-context.ts +175 -15
  89. package/src/ssr/index.tsx +3 -0
  90. package/src/static-handler.ts +18 -6
  91. package/src/types/cache-types.ts +4 -4
  92. package/src/types/handler-context.ts +37 -19
  93. package/src/types/loader-types.ts +36 -9
  94. package/src/types/route-entry.ts +1 -1
  95. package/src/types/segments.ts +1 -0
  96. package/src/urls/path-helper-types.ts +9 -2
  97. package/src/urls/path-helper.ts +47 -12
  98. package/src/urls/pattern-types.ts +12 -0
  99. package/src/urls/response-types.ts +16 -6
  100. package/src/use-loader.tsx +77 -5
  101. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  102. package/src/vite/discovery/discover-routers.ts +5 -1
  103. package/src/vite/discovery/prerender-collection.ts +128 -74
  104. package/src/vite/discovery/state.ts +13 -4
  105. package/src/vite/index.ts +4 -0
  106. package/src/vite/plugin-types.ts +60 -5
  107. package/src/vite/plugins/expose-id-utils.ts +12 -0
  108. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  109. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  110. package/src/vite/plugins/performance-tracks.ts +88 -0
  111. package/src/vite/plugins/refresh-cmd.ts +88 -26
  112. package/src/vite/rango.ts +19 -2
  113. package/src/vite/router-discovery.ts +178 -37
  114. package/src/vite/utils/prerender-utils.ts +18 -0
  115. package/src/vite/utils/shared-utils.ts +3 -2
@@ -157,13 +157,26 @@ export function formatNestedRouterConflictError(
157
157
  // ---------------------------------------------------------------------------
158
158
 
159
159
  /**
160
- * Extract the url patterns variable from a router file using AST.
161
- * Detects two patterns:
160
+ * Result of extracting URL patterns from a router file.
161
+ * - "variable": a named variable reference (e.g., `.routes(patterns)` or `urls: patterns`)
162
+ * - "inline": an inline builder function (e.g., `.routes(({ path }) => [...])` or `urls: ({ path }) => [...]`)
163
+ */
164
+ export type UrlsExtractionResult =
165
+ | { kind: "variable"; name: string }
166
+ | { kind: "inline"; block: string };
167
+
168
+ /**
169
+ * Extract the url patterns from a router file using AST.
170
+ * Detects four patterns:
162
171
  * 1. createRouter(...).routes(variableName)
163
172
  * 2. createRouter({ urls: variableName, ... })
164
- * Returns the local variable name.
173
+ * 3. createRouter(...).routes(({ path, ... }) => [...])
174
+ * 4. createRouter({ urls: ({ path, ... }) => [...], ... })
175
+ * Returns either a variable name or an inline code block.
165
176
  */
166
- export function extractUrlsVariableFromRouter(code: string): string | null {
177
+ export function extractUrlsFromRouter(
178
+ code: string,
179
+ ): UrlsExtractionResult | null {
167
180
  const sourceFile = ts.createSourceFile(
168
181
  "router.tsx",
169
182
  code,
@@ -171,7 +184,7 @@ export function extractUrlsVariableFromRouter(code: string): string | null {
171
184
  true,
172
185
  ts.ScriptKind.TSX,
173
186
  );
174
- let result: string | null = null;
187
+ let result: UrlsExtractionResult | null = null;
175
188
 
176
189
  function isCreateRouterCall(node: ts.Node): boolean {
177
190
  if (!ts.isCallExpression(node)) return false;
@@ -179,44 +192,108 @@ export function extractUrlsVariableFromRouter(code: string): string | null {
179
192
  return ts.isIdentifier(callee) && callee.text === "createRouter";
180
193
  }
181
194
 
195
+ /** Check if a node is an arrow/function expression (inline builder). */
196
+ function isInlineBuilder(node: ts.Node): boolean {
197
+ return ts.isArrowFunction(node) || ts.isFunctionExpression(node);
198
+ }
199
+
200
+ /** Check if a .routes() call chains from createRouter(). */
201
+ function isRoutesOnCreateRouter(node: ts.CallExpression): boolean {
202
+ if (
203
+ !ts.isPropertyAccessExpression(node.expression) ||
204
+ node.expression.name.text !== "routes"
205
+ )
206
+ return false;
207
+ let inner: ts.Expression = node.expression.expression;
208
+ while (
209
+ ts.isCallExpression(inner) &&
210
+ ts.isPropertyAccessExpression(inner.expression)
211
+ ) {
212
+ inner = inner.expression.expression;
213
+ }
214
+ return isCreateRouterCall(inner);
215
+ }
216
+
182
217
  function visit(node: ts.Node) {
183
218
  if (result) return;
184
219
 
185
- // Pattern 1: createRouter(...).routes(variableName)
186
- // The AST shape is CallExpression(.routes) -> PropertyAccessExpression -> CallExpression(createRouter)
220
+ // Pattern 1 & 3: createRouter(...).routes(variableName | builder)
187
221
  if (
188
222
  ts.isCallExpression(node) &&
189
- ts.isPropertyAccessExpression(node.expression) &&
190
- node.expression.name.text === "routes" &&
191
223
  node.arguments.length >= 1 &&
192
- ts.isIdentifier(node.arguments[0])
224
+ isRoutesOnCreateRouter(node)
193
225
  ) {
194
- // Walk up the chain: createRouter().middleware(...).routes(x) etc.
195
- // The innermost call should be createRouter(...)
196
- let inner: ts.Expression = node.expression.expression;
197
- while (
198
- ts.isCallExpression(inner) &&
199
- ts.isPropertyAccessExpression(inner.expression)
200
- ) {
201
- inner = inner.expression.expression;
202
- }
203
- if (isCreateRouterCall(inner)) {
204
- result = (node.arguments[0] as ts.Identifier).text;
205
- return;
226
+ const arg = node.arguments[0];
227
+ if (ts.isIdentifier(arg)) {
228
+ result = { kind: "variable", name: arg.text };
229
+ } else if (isInlineBuilder(arg)) {
230
+ result = { kind: "inline", block: arg.getText(sourceFile) };
206
231
  }
232
+ return;
207
233
  }
208
234
 
209
- // Pattern 2: createRouter({ urls: variableName, ... })
235
+ // Pattern 2 & 4: createRouter({ urls: variableName | builder, ... })
210
236
  if (isCreateRouterCall(node)) {
211
237
  const callExpr = node as ts.CallExpression;
212
- for (const arg of callExpr.arguments) {
238
+ for (const callArg of callExpr.arguments) {
239
+ if (ts.isObjectLiteralExpression(callArg)) {
240
+ for (const prop of callArg.properties) {
241
+ if (
242
+ ts.isPropertyAssignment(prop) &&
243
+ ts.isIdentifier(prop.name) &&
244
+ prop.name.text === "urls"
245
+ ) {
246
+ if (ts.isIdentifier(prop.initializer)) {
247
+ result = { kind: "variable", name: prop.initializer.text };
248
+ } else if (isInlineBuilder(prop.initializer)) {
249
+ result = {
250
+ kind: "inline",
251
+ block: prop.initializer.getText(sourceFile),
252
+ };
253
+ }
254
+ return;
255
+ }
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ ts.forEachChild(node, visit);
262
+ }
263
+
264
+ visit(sourceFile);
265
+ return result;
266
+ }
267
+
268
+ /**
269
+ * Extract the `basename` string literal from createRouter({ basename: "..." }).
270
+ * Returns the basename value or undefined if not present.
271
+ */
272
+ export function extractBasenameFromRouter(code: string): string | undefined {
273
+ const sourceFile = ts.createSourceFile(
274
+ "router.tsx",
275
+ code,
276
+ ts.ScriptTarget.Latest,
277
+ true,
278
+ ts.ScriptKind.TSX,
279
+ );
280
+ let result: string | undefined;
281
+
282
+ function visit(node: ts.Node) {
283
+ if (result !== undefined) return;
284
+ if (
285
+ ts.isCallExpression(node) &&
286
+ ts.isIdentifier(node.expression) &&
287
+ node.expression.text === "createRouter"
288
+ ) {
289
+ for (const arg of node.arguments) {
213
290
  if (ts.isObjectLiteralExpression(arg)) {
214
291
  for (const prop of arg.properties) {
215
292
  if (
216
293
  ts.isPropertyAssignment(prop) &&
217
294
  ts.isIdentifier(prop.name) &&
218
- prop.name.text === "urls" &&
219
- ts.isIdentifier(prop.initializer)
295
+ prop.name.text === "basename" &&
296
+ ts.isStringLiteral(prop.initializer)
220
297
  ) {
221
298
  result = prop.initializer.text;
222
299
  return;
@@ -225,7 +302,6 @@ export function extractUrlsVariableFromRouter(code: string): string | null {
225
302
  }
226
303
  }
227
304
  }
228
-
229
305
  ts.forEachChild(node, visit);
230
306
  }
231
307
 
@@ -233,9 +309,40 @@ export function extractUrlsVariableFromRouter(code: string): string | null {
233
309
  return result;
234
310
  }
235
311
 
312
+ /** @deprecated Use extractUrlsFromRouter instead */
313
+ export function extractUrlsVariableFromRouter(code: string): string | null {
314
+ const result = extractUrlsFromRouter(code);
315
+ return result?.kind === "variable" ? result.name : null;
316
+ }
317
+
318
+ /** Apply a basename prefix to all route patterns in a result set. */
319
+ function applyBasenameToRoutes(
320
+ result: {
321
+ routes: Record<string, string>;
322
+ searchSchemas: Record<string, Record<string, string>>;
323
+ },
324
+ basename: string,
325
+ ): {
326
+ routes: Record<string, string>;
327
+ searchSchemas: Record<string, Record<string, string>>;
328
+ } {
329
+ const prefixed: Record<string, string> = {};
330
+ for (const [name, pattern] of Object.entries(result.routes)) {
331
+ if (pattern === "/") {
332
+ prefixed[name] = basename;
333
+ } else if (basename.endsWith("/") && pattern.startsWith("/")) {
334
+ prefixed[name] = basename + pattern.slice(1);
335
+ } else {
336
+ prefixed[name] = basename + pattern;
337
+ }
338
+ }
339
+ return { routes: prefixed, searchSchemas: result.searchSchemas };
340
+ }
341
+
236
342
  /**
237
343
  * Resolve routes and search schemas from a router source file by following the
238
- * variable passed to `.routes(...)` or `urls: ...` in createRouter options.
344
+ * variable passed to `.routes(...)` or `urls: ...` in createRouter options,
345
+ * or by parsing an inline builder function directly.
239
346
  */
240
347
  export function buildCombinedRouteMapForRouterFile(routerFilePath: string): {
241
348
  routes: Record<string, string>;
@@ -248,21 +355,54 @@ export function buildCombinedRouteMapForRouterFile(routerFilePath: string): {
248
355
  return { routes: {}, searchSchemas: {} };
249
356
  }
250
357
 
251
- const urlsVarName = extractUrlsVariableFromRouter(routerSource);
252
- if (!urlsVarName) {
358
+ const extraction = extractUrlsFromRouter(routerSource);
359
+ if (!extraction) {
253
360
  return { routes: {}, searchSchemas: {} };
254
361
  }
255
362
 
256
- const imported = resolveImportedVariable(routerSource, urlsVarName);
257
- if (imported) {
258
- const targetFile = resolveImportPath(imported.specifier, routerFilePath);
259
- if (!targetFile) {
260
- return { routes: {}, searchSchemas: {} };
363
+ // Detect basename from createRouter({ basename: "..." })
364
+ const rawBasename = extractBasenameFromRouter(routerSource);
365
+ const basename = rawBasename
366
+ ? ("/" + rawBasename.replace(/^\/+|\/+$/g, "")).replace(/^\/$/, "")
367
+ : undefined;
368
+
369
+ let result: {
370
+ routes: Record<string, string>;
371
+ searchSchemas: Record<string, Record<string, string>>;
372
+ };
373
+
374
+ // Inline builder: extract routes directly from the function body
375
+ if (extraction.kind === "inline") {
376
+ result = buildCombinedRouteMapWithSearch(
377
+ routerFilePath,
378
+ undefined,
379
+ undefined,
380
+ undefined,
381
+ extraction.block,
382
+ );
383
+ } else {
384
+ // Variable reference: follow imports or same-file declaration
385
+ const imported = resolveImportedVariable(routerSource, extraction.name);
386
+ if (imported) {
387
+ const targetFile = resolveImportPath(imported.specifier, routerFilePath);
388
+ if (!targetFile) {
389
+ return { routes: {}, searchSchemas: {} };
390
+ }
391
+ result = buildCombinedRouteMapWithSearch(
392
+ targetFile,
393
+ imported.exportedName,
394
+ );
395
+ } else {
396
+ result = buildCombinedRouteMapWithSearch(routerFilePath, extraction.name);
261
397
  }
262
- return buildCombinedRouteMapWithSearch(targetFile, imported.exportedName);
263
398
  }
264
399
 
265
- return buildCombinedRouteMapWithSearch(routerFilePath, urlsVarName);
400
+ // Apply basename prefix to all extracted route patterns
401
+ if (basename) {
402
+ result = applyBasenameToRoutes(result, basename);
403
+ }
404
+
405
+ return result;
266
406
  }
267
407
 
268
408
  // ---------------------------------------------------------------------------
@@ -285,12 +425,26 @@ export function detectUnresolvableIncludes(
285
425
  return [];
286
426
  }
287
427
 
288
- // Extract the urls variable from the router file
289
- const urlsVarName = extractUrlsVariableFromRouter(source);
290
- if (!urlsVarName) return [];
428
+ // Extract the urls source from the router file
429
+ const extraction = extractUrlsFromRouter(source);
430
+ if (!extraction) return [];
291
431
 
292
- // Resolve where the urls variable comes from
293
- const imported = resolveImportedVariable(source, urlsVarName);
432
+ const diagnostics: UnresolvableInclude[] = [];
433
+
434
+ if (extraction.kind === "inline") {
435
+ // Inline builder: parse directly
436
+ buildCombinedRouteMapWithSearch(
437
+ realPath,
438
+ undefined,
439
+ new Set(),
440
+ diagnostics,
441
+ extraction.block,
442
+ );
443
+ return diagnostics;
444
+ }
445
+
446
+ // Variable reference: resolve where it comes from
447
+ const imported = resolveImportedVariable(source, extraction.name);
294
448
  let targetFile: string;
295
449
  let exportedName: string | undefined;
296
450
 
@@ -312,10 +466,9 @@ export function detectUnresolvableIncludes(
312
466
  } else {
313
467
  // Same-file urls() definition
314
468
  targetFile = realPath;
315
- exportedName = urlsVarName;
469
+ exportedName = extraction.name;
316
470
  }
317
471
 
318
- const diagnostics: UnresolvableInclude[] = [];
319
472
  buildCombinedRouteMapWithSearch(
320
473
  targetFile,
321
474
  exportedName,
@@ -397,34 +550,20 @@ export function writeCombinedRouteTypes(
397
550
  }
398
551
 
399
552
  for (const routerFilePath of routerFilePaths) {
400
- let routerSource: string;
401
- try {
402
- routerSource = readFileSync(routerFilePath, "utf-8");
403
- } catch {
404
- continue;
405
- }
406
- // Extract the urls variable name from .routes(varName) or urls: varName
407
- const urlsVarName = extractUrlsVariableFromRouter(routerSource);
408
- if (!urlsVarName) continue;
409
-
410
- // Resolve the variable to its source module
411
- let result: {
412
- routes: Record<string, string>;
413
- searchSchemas: Record<string, Record<string, string>>;
414
- };
415
-
416
- const imported = resolveImportedVariable(routerSource, urlsVarName);
417
- if (imported) {
418
- // Variable is imported from another module
419
- const targetFile = resolveImportPath(imported.specifier, routerFilePath);
420
- if (!targetFile) continue;
421
- result = buildCombinedRouteMapWithSearch(
422
- targetFile,
423
- imported.exportedName,
424
- );
425
- } else {
426
- // Variable is defined in the same file
427
- result = buildCombinedRouteMapWithSearch(routerFilePath, urlsVarName);
553
+ const result = buildCombinedRouteMapForRouterFile(routerFilePath);
554
+ if (
555
+ Object.keys(result.routes).length === 0 &&
556
+ Object.keys(result.searchSchemas).length === 0
557
+ ) {
558
+ // Check if the file even has a createRouter call — if not, skip entirely.
559
+ // If it does, fall through to write an empty placeholder below.
560
+ let routerSource: string;
561
+ try {
562
+ routerSource = readFileSync(routerFilePath, "utf-8");
563
+ } catch {
564
+ continue;
565
+ }
566
+ if (!extractUrlsFromRouter(routerSource)) continue;
428
567
  }
429
568
 
430
569
  const routerBasename = pathBasename(routerFilePath).replace(
@@ -61,7 +61,14 @@ export function findTsFiles(dir: string, filter?: ScanFilter): string[] {
61
61
  for (const entry of entries) {
62
62
  const fullPath = join(dir, entry.name);
63
63
  if (entry.isDirectory()) {
64
- if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
64
+ if (
65
+ entry.name === "node_modules" ||
66
+ entry.name.startsWith(".") ||
67
+ entry.name === "dist" ||
68
+ entry.name === "build" ||
69
+ entry.name === "coverage"
70
+ )
71
+ continue;
65
72
  results.push(...findTsFiles(fullPath, filter));
66
73
  } else if (
67
74
  (entry.name.endsWith(".ts") ||
@@ -344,9 +344,7 @@ export class CacheScope {
344
344
  await handleStore.settled;
345
345
 
346
346
  if (INTERNAL_RANGO_DEBUG) {
347
- debugCacheLog(
348
- `[CacheScope] waitUntil: handleStore settled for ${key}`,
349
- );
347
+ debugCacheLog(`[CacheScope] waitUntil: handleStore settled for ${key}`);
350
348
  }
351
349
 
352
350
  // For document requests: only cache if layout segments have components
@@ -359,14 +357,16 @@ export class CacheScope {
359
357
  (s) => s.component === null && s.type === "layout",
360
358
  );
361
359
  if (hasIncompleteLayouts) {
362
- if (INTERNAL_RANGO_DEBUG) {
363
- const nullSegments = nonLoaderSegments
364
- .filter((s) => s.component === null && s.type === "layout")
365
- .map((s) => s.id);
366
- debugCacheLog(
367
- `[CacheScope] waitUntil: SKIPPED (incomplete layouts: ${nullSegments.join(", ")}) for ${key}`,
368
- );
369
- }
360
+ const nullSegments = nonLoaderSegments
361
+ .filter((s) => s.component === null && s.type === "layout")
362
+ .map((s) => s.id);
363
+ const error = new Error(
364
+ `[CacheScope] Cache write skipped: layout segments have null components ` +
365
+ `(${nullSegments.join(", ")}). This indicates an incomplete render — ` +
366
+ `layout handlers must return JSX for document requests to be cacheable.`,
367
+ );
368
+ error.name = "CacheScopeInvariantError";
369
+ console.error(error.message);
370
370
  return;
371
371
  }
372
372
  }
@@ -391,9 +391,7 @@ export class CacheScope {
391
391
  };
392
392
 
393
393
  if (INTERNAL_RANGO_DEBUG) {
394
- debugCacheLog(
395
- `[CacheScope] waitUntil: calling store.set for ${key}`,
396
- );
394
+ debugCacheLog(`[CacheScope] waitUntil: calling store.set for ${key}`);
397
395
  }
398
396
 
399
397
  await store.set(key, data, ttl, swr);
@@ -81,6 +81,61 @@ export function assertNotInsideCacheExec(
81
81
  }
82
82
  }
83
83
 
84
+ /**
85
+ * Symbol stamped on ctx when resolving handlers inside a cache() DSL boundary.
86
+ * Separate from INSIDE_CACHE_EXEC ("use cache") because cache() allows
87
+ * ctx.set() (children are also cached) but blocks response-level side effects
88
+ * (headers, cookies, status) which are lost on cache hit.
89
+ */
90
+ export const INSIDE_CACHE_SCOPE: unique symbol = Symbol.for(
91
+ "rango:inside-cache-scope",
92
+ ) as any;
93
+
94
+ /**
95
+ * Mark ctx as inside a cache() scope. Must be paired with unstampCacheScope.
96
+ */
97
+ export function stampCacheScope(obj: object): void {
98
+ const current = (obj as any)[INSIDE_CACHE_SCOPE] ?? 0;
99
+ (obj as any)[INSIDE_CACHE_SCOPE] = current + 1;
100
+ }
101
+
102
+ /**
103
+ * Remove cache() scope mark.
104
+ */
105
+ export function unstampCacheScope(obj: object): void {
106
+ const current = (obj as any)[INSIDE_CACHE_SCOPE] ?? 0;
107
+ if (current <= 1) {
108
+ delete (obj as any)[INSIDE_CACHE_SCOPE];
109
+ } else {
110
+ (obj as any)[INSIDE_CACHE_SCOPE] = current - 1;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Throw if ctx is inside a cache() DSL boundary.
116
+ * Call from response-level side effects (header, setCookie, setStatus, etc.)
117
+ * which are lost on cache hit because the handler body is skipped.
118
+ * ctx.set() is allowed inside cache() — children are also cached and can
119
+ * read the value.
120
+ */
121
+ export function assertNotInsideCacheScope(
122
+ ctx: unknown,
123
+ methodName: string,
124
+ ): void {
125
+ if (
126
+ ctx !== null &&
127
+ ctx !== undefined &&
128
+ typeof ctx === "object" &&
129
+ (INSIDE_CACHE_SCOPE as symbol) in (ctx as Record<symbol, unknown>)
130
+ ) {
131
+ throw new Error(
132
+ `ctx.${methodName}() cannot be called inside a cache() boundary. ` +
133
+ `On cache hit the handler is skipped, so this side effect would be lost. ` +
134
+ `Move ctx.${methodName}() to a middleware or layout outside the cache() scope.`,
135
+ );
136
+ }
137
+ }
138
+
84
139
  /**
85
140
  * Brand symbol for functions wrapped by registerCachedFunction().
86
141
  * Used at runtime to detect when a "use cache" function is misused
package/src/client.tsx CHANGED
@@ -13,7 +13,6 @@ import {
13
13
  type ClientErrorBoundaryFallbackProps,
14
14
  type ErrorInfo,
15
15
  type LoaderDefinition,
16
- type LoaderFn,
17
16
  type ResolvedSegment,
18
17
  } from "./types";
19
18
  import {
@@ -313,57 +312,6 @@ export {
313
312
  type UseLoaderOptions,
314
313
  } from "./use-loader.js";
315
314
 
316
- /**
317
- * Client-safe createLoader factory
318
- *
319
- * Creates a loader definition that can be used with useLoader().
320
- * This is the client-side version that only stores the $$id - the function
321
- * is ignored since loaders only execute on the server.
322
- *
323
- * The $$id is injected by the exposeLoaderId Vite plugin. In most cases,
324
- * you should import the loader directly from the server file rather than
325
- * creating a reference manually.
326
- *
327
- * @param fn - Loader function (ignored on client, kept for API compatibility)
328
- * @param _fetchable - Optional fetchable flag (ignored on client)
329
- * @param __injectedId - $$id injected by Vite plugin
330
- *
331
- * @example
332
- * ```tsx
333
- * "use client";
334
- * import { useLoader } from "rsc-router/client";
335
- * import { CartLoader } from "../loaders/cart"; // Import from server file
336
- *
337
- * export function CartIcon() {
338
- * const cart = useLoader(CartLoader);
339
- * return <span>Cart ({cart?.items.length ?? 0})</span>;
340
- * }
341
- * ```
342
- */
343
- // Overload 1: With function only (not fetchable)
344
- export function createLoader<T>(
345
- fn: LoaderFn<T, Record<string, string | undefined>, any>,
346
- ): LoaderDefinition<Awaited<T>, Record<string, string | undefined>>;
347
-
348
- // Overload 2: With function and fetchable flag
349
- export function createLoader<T>(
350
- fn: LoaderFn<T, Record<string, string | undefined>, any>,
351
- fetchable: true,
352
- ): LoaderDefinition<Awaited<T>, Record<string, string | undefined>>;
353
-
354
- // Implementation - function is ignored at runtime on client
355
- // The $$id is injected by Vite plugin as hidden third parameter
356
- export function createLoader(
357
- _fn: LoaderFn<any, Record<string, string | undefined>, any>,
358
- _fetchable?: true,
359
- __injectedId?: string,
360
- ): LoaderDefinition<any, Record<string, string | undefined>> {
361
- return {
362
- __brand: "loader",
363
- $$id: __injectedId || "",
364
- };
365
- }
366
-
367
315
  /**
368
316
  * Props for the ErrorBoundary component
369
317
  */
@@ -534,10 +482,8 @@ export {
534
482
  type ScrollRestorationProps,
535
483
  } from "./browser/react/ScrollRestoration.js";
536
484
 
537
- // Handle API - for accumulating data across route segments
538
- export { createHandle, isHandle, type Handle } from "./handle.js";
539
-
540
- // Handle data hook
485
+ // Handle data hook (client-side only createHandle/isHandle are server APIs from the root export)
486
+ export { type Handle } from "./handle.js";
541
487
  export { useHandle } from "./browser/react/use-handle.js";
542
488
 
543
489
  // Built-in handles