@rangojs/router 0.0.0-experimental.cb54cbba → 0.0.0-experimental.debug-cache-2383ca26

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 (65) hide show
  1. package/AGENTS.md +4 -0
  2. package/dist/bin/rango.js +8 -3
  3. package/dist/vite/index.js +139 -200
  4. package/package.json +15 -14
  5. package/skills/caching/SKILL.md +37 -4
  6. package/skills/parallel/SKILL.md +126 -0
  7. package/src/browser/event-controller.ts +5 -0
  8. package/src/browser/navigation-bridge.ts +1 -3
  9. package/src/browser/navigation-client.ts +60 -27
  10. package/src/browser/navigation-transaction.ts +11 -9
  11. package/src/browser/partial-update.ts +50 -9
  12. package/src/browser/prefetch/cache.ts +57 -5
  13. package/src/browser/prefetch/fetch.ts +30 -21
  14. package/src/browser/prefetch/queue.ts +53 -13
  15. package/src/browser/react/Link.tsx +9 -1
  16. package/src/browser/react/NavigationProvider.tsx +27 -0
  17. package/src/browser/rsc-router.tsx +109 -57
  18. package/src/browser/scroll-restoration.ts +31 -34
  19. package/src/browser/segment-reconciler.ts +6 -1
  20. package/src/browser/types.ts +9 -0
  21. package/src/build/route-types/router-processing.ts +12 -2
  22. package/src/cache/cache-runtime.ts +15 -11
  23. package/src/cache/cache-scope.ts +43 -3
  24. package/src/cache/cf/cf-cache-store.ts +453 -11
  25. package/src/cache/cf/index.ts +5 -1
  26. package/src/cache/document-cache.ts +17 -7
  27. package/src/cache/index.ts +1 -0
  28. package/src/debug.ts +2 -2
  29. package/src/route-definition/dsl-helpers.ts +32 -7
  30. package/src/route-definition/redirect.ts +2 -2
  31. package/src/route-map-builder.ts +7 -1
  32. package/src/router/find-match.ts +4 -2
  33. package/src/router/intercept-resolution.ts +2 -0
  34. package/src/router/lazy-includes.ts +4 -1
  35. package/src/router/logging.ts +5 -2
  36. package/src/router/manifest.ts +9 -3
  37. package/src/router/match-middleware/background-revalidation.ts +30 -2
  38. package/src/router/match-middleware/cache-lookup.ts +66 -9
  39. package/src/router/match-middleware/cache-store.ts +53 -10
  40. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  41. package/src/router/match-middleware/segment-resolution.ts +8 -5
  42. package/src/router/match-result.ts +22 -6
  43. package/src/router/metrics.ts +6 -1
  44. package/src/router/middleware.ts +2 -1
  45. package/src/router/router-context.ts +6 -1
  46. package/src/router/segment-resolution/fresh.ts +122 -15
  47. package/src/router/segment-resolution/loader-cache.ts +1 -0
  48. package/src/router/segment-resolution/revalidation.ts +347 -290
  49. package/src/router/segment-wrappers.ts +2 -0
  50. package/src/router.ts +5 -1
  51. package/src/segment-system.tsx +140 -4
  52. package/src/server/context.ts +90 -13
  53. package/src/server/request-context.ts +10 -4
  54. package/src/ssr/index.tsx +1 -0
  55. package/src/types/handler-context.ts +103 -17
  56. package/src/types/route-entry.ts +7 -0
  57. package/src/types/segments.ts +2 -0
  58. package/src/urls/path-helper.ts +1 -1
  59. package/src/vite/discovery/state.ts +0 -2
  60. package/src/vite/plugin-types.ts +0 -83
  61. package/src/vite/plugins/expose-action-id.ts +1 -3
  62. package/src/vite/plugins/version-plugin.ts +13 -1
  63. package/src/vite/rango.ts +144 -209
  64. package/src/vite/router-discovery.ts +0 -8
  65. package/src/vite/utils/banner.ts +3 -3
@@ -10,6 +10,15 @@
10
10
 
11
11
  import { debugLog } from "./logging.js";
12
12
 
13
+ /**
14
+ * Defers a callback to the next animation frame.
15
+ * Falls back to setTimeout(0) in environments without requestAnimationFrame.
16
+ */
17
+ const deferToNextPaint: (fn: () => void) => void =
18
+ typeof requestAnimationFrame === "function"
19
+ ? requestAnimationFrame
20
+ : (fn) => setTimeout(fn, 0);
21
+
13
22
  const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
14
23
 
15
24
  /**
@@ -264,51 +273,35 @@ export function restoreScrollPosition(options?: {
264
273
  return false;
265
274
  }
266
275
 
267
- // Check if page is tall enough to scroll to saved position
268
- const maxScrollY = document.documentElement.scrollHeight - window.innerHeight;
269
- const canScrollToPosition = savedY <= maxScrollY;
270
-
271
- if (canScrollToPosition) {
272
- window.scrollTo(0, savedY);
273
- debugLog("[Scroll] Restored position:", savedY, "for key:", key);
274
- return true;
275
- }
276
-
277
- // Scroll as far as we can for now
278
- window.scrollTo(0, maxScrollY);
279
- debugLog("[Scroll] Partial restore to:", maxScrollY, "target:", savedY);
280
-
281
- // Poll while streaming until we can scroll to target position
276
+ // If streaming, poll until streaming ends then scroll to saved position
282
277
  if (options?.retryIfStreaming && options?.isStreaming?.()) {
283
278
  const startTime = Date.now();
284
279
 
285
280
  pendingPollInterval = setInterval(() => {
286
- // Stop if we've exceeded the timeout
287
281
  if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
288
282
  debugLog("[Scroll] Polling timeout, giving up");
289
283
  cancelScrollRestorationPolling();
290
284
  return;
291
285
  }
292
286
 
293
- // Stop if streaming ended
294
287
  if (!options.isStreaming?.()) {
295
- debugLog("[Scroll] Streaming ended, stopping poll");
296
- cancelScrollRestorationPolling();
297
- return;
298
- }
299
-
300
- // Check if we can now scroll to the target position
301
- const currentMaxScrollY =
302
- document.documentElement.scrollHeight - window.innerHeight;
303
- if (savedY <= currentMaxScrollY) {
304
288
  window.scrollTo(0, savedY);
305
- debugLog("[Scroll] Poll restored position:", savedY);
289
+ debugLog("[Scroll] Restored after streaming:", savedY);
306
290
  cancelScrollRestorationPolling();
307
291
  }
308
292
  }, SCROLL_POLL_INTERVAL_MS);
293
+
294
+ return true;
309
295
  }
310
296
 
311
- return false;
297
+ // Not streaming — scroll after React commits and browser paints.
298
+ // startTransition defers the DOM commit, so scrolling synchronously
299
+ // would be overwritten when React replaces the content.
300
+ deferToNextPaint(() => {
301
+ window.scrollTo(0, savedY);
302
+ debugLog("[Scroll] Restored position:", savedY, "for key:", key);
303
+ });
304
+ return true;
312
305
  }
313
306
 
314
307
  /**
@@ -382,13 +375,17 @@ export function handleNavigationEnd(options: {
382
375
  // Fall through to hash or top if no saved position
383
376
  }
384
377
 
385
- // Try hash scrolling first
386
- if (scrollToHash()) {
387
- return;
388
- }
378
+ // Defer hash and scroll-to-top to after React paints the new content,
379
+ // so the user doesn't see the current page jump before the new route appears.
380
+ deferToNextPaint(() => {
381
+ // Try hash scrolling first
382
+ if (scrollToHash()) {
383
+ return;
384
+ }
389
385
 
390
- // Default: scroll to top
391
- scrollToTop();
386
+ // Default: scroll to top
387
+ scrollToTop();
388
+ });
392
389
  }
393
390
 
394
391
  /**
@@ -160,8 +160,13 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
160
160
 
161
161
  // For non-action actors: cached segments the server decided not to re-render.
162
162
  // - Preserve loading=false (suppressed boundary) to maintain tree structure
163
- // - Clear truthy loading (active skeleton) to prevent suspense on cached content
163
+ // - Preserve parallel segment loading so renderSegments can reconstruct
164
+ // parallel-owned loader markers from the cached slot metadata
165
+ // - Clear other truthy loading values to prevent suspense on cached content
164
166
  if (actor !== "action") {
167
+ if (fromCache.type === "parallel" && fromCache.loading !== undefined) {
168
+ return fromCache;
169
+ }
165
170
  if (fromCache.loading !== undefined && fromCache.loading !== false) {
166
171
  return { ...fromCache, loading: undefined };
167
172
  }
@@ -215,6 +215,15 @@ export interface SegmentState {
215
215
  export interface NavigationUpdate {
216
216
  root: ReactNode | Promise<ReactNode>;
217
217
  metadata: RscMetadata;
218
+ /** Scroll behavior to apply after React commits this update */
219
+ scroll?: {
220
+ /** For back/forward: restore saved position */
221
+ restore?: boolean;
222
+ /** Set to false to disable scrolling entirely */
223
+ enabled?: boolean;
224
+ /** Function to check if streaming is in progress */
225
+ isStreaming?: () => boolean;
226
+ };
218
227
  }
219
228
 
220
229
  /**
@@ -45,7 +45,9 @@ function isRoutableSourceFile(name: string): boolean {
45
45
  name.endsWith(".tsx") ||
46
46
  name.endsWith(".js") ||
47
47
  name.endsWith(".jsx")) &&
48
- !name.includes(".gen.")
48
+ !name.includes(".gen.") &&
49
+ !name.includes(".test.") &&
50
+ !name.includes(".spec.")
49
51
  );
50
52
  }
51
53
 
@@ -70,7 +72,15 @@ function findRouterFilesRecursive(
70
72
  for (const entry of entries) {
71
73
  const fullPath = join(dir, entry.name);
72
74
  if (entry.isDirectory()) {
73
- if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
75
+ if (
76
+ entry.name === "node_modules" ||
77
+ entry.name === "dist" ||
78
+ entry.name === "coverage" ||
79
+ entry.name === "__tests__" ||
80
+ entry.name === "__mocks__" ||
81
+ entry.name.startsWith(".")
82
+ )
83
+ continue;
74
84
  childDirs.push(fullPath);
75
85
  continue;
76
86
  }
@@ -214,11 +214,21 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
214
214
  bgStopCapture = c.stop;
215
215
  }
216
216
 
217
- // Stamp tainted args and RequestContext so request-scoped
218
- // reads (cookies, headers) and side effects (ctx.set, etc.)
219
- // throw inside background revalidation, same as the miss path.
220
- // Uses ref-counted stamp/unstamp so overlapping executions
221
- // sharing the same ctx don't clear each other's guards.
217
+ // Stamp tainted ARGS only not requestCtx. The args stamp guards
218
+ // direct ctx method calls (ctx.set, ctx.header, ctx.onResponse, etc.)
219
+ // which is sufficient for correctness.
220
+ //
221
+ // We intentionally skip stamping requestCtx here because:
222
+ // 1. runBackground starts the async task synchronously (before the
223
+ // first await), so stampCacheExec would pollute the shared
224
+ // requestCtx while the foreground pipeline is still running.
225
+ // This causes assertNotInsideCacheExec to fire when cache-store
226
+ // later calls requestCtx.onResponse().
227
+ // 2. requestCtx methods are closure-bound to the original ctx, so
228
+ // neither Object.create() nor a proxy can isolate the stamp.
229
+ // 3. The foreground miss path already stamps requestCtx and catches
230
+ // cookies()/headers() misuse on first execution. The background
231
+ // re-runs the same function with the same request.
222
232
  const bgTaintedArgs: unknown[] = [];
223
233
  for (const arg of args) {
224
234
  if (isTainted(arg)) {
@@ -226,9 +236,6 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
226
236
  bgTaintedArgs.push(arg);
227
237
  }
228
238
  }
229
- if (requestCtx) {
230
- stampCacheExec(requestCtx as object);
231
- }
232
239
 
233
240
  try {
234
241
  const freshResult = await fn.apply(this, args);
@@ -249,9 +256,6 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
249
256
  for (const arg of bgTaintedArgs) {
250
257
  unstampCacheExec(arg as object);
251
258
  }
252
- if (requestCtx) {
253
- unstampCacheExec(requestCtx as object);
254
- }
255
259
  // Restore original handle store
256
260
  if (originalHandleStore && requestCtx) {
257
261
  requestCtx._handleStore = originalHandleStore;
@@ -73,7 +73,7 @@ function getDefaultRouteCacheKey(
73
73
  isIntercept?: boolean,
74
74
  ): string {
75
75
  const ctx = getRequestContext();
76
- const isPartial = ctx?.url.searchParams.has("_rsc_partial") ?? false;
76
+ const isPartial = ctx?.originalUrl?.searchParams.has("_rsc_partial") ?? false;
77
77
  const searchParams = ctx?.url.searchParams;
78
78
  const host = ctx?.url.host ?? "localhost";
79
79
 
@@ -326,24 +326,58 @@ export class CacheScope {
326
326
  const key = await this.resolveKey(pathname, params, isIntercept);
327
327
 
328
328
  // Check if this is a partial request (navigation) vs document request
329
- const isPartial = requestCtx.url.searchParams.has("_rsc_partial");
329
+ const isPartial = requestCtx.originalUrl.searchParams.has("_rsc_partial");
330
+
331
+ if (INTERNAL_RANGO_DEBUG) {
332
+ debugCacheLog(
333
+ `[CacheScope] cacheRoute: scheduling waitUntil for ${key} (${nonLoaderSegments.length} segments, isPartial=${isPartial})`,
334
+ );
335
+ }
330
336
 
331
337
  requestCtx.waitUntil(async () => {
338
+ if (INTERNAL_RANGO_DEBUG) {
339
+ debugCacheLog(
340
+ `[CacheScope] waitUntil: awaiting handleStore.settled for ${key}`,
341
+ );
342
+ }
343
+
332
344
  await handleStore.settled;
333
345
 
346
+ if (INTERNAL_RANGO_DEBUG) {
347
+ debugCacheLog(
348
+ `[CacheScope] waitUntil: handleStore settled for ${key}`,
349
+ );
350
+ }
351
+
334
352
  // For document requests: only cache if ALL segments have components (complete render)
335
353
  // For partial requests: null components are expected (client already has them)
336
354
  if (!isPartial) {
337
355
  const hasAllComponents = nonLoaderSegments.every(
338
356
  (s) => s.component !== null,
339
357
  );
340
- if (!hasAllComponents) return;
358
+ if (!hasAllComponents) {
359
+ if (INTERNAL_RANGO_DEBUG) {
360
+ const nullSegments = nonLoaderSegments
361
+ .filter((s) => s.component === null)
362
+ .map((s) => s.id);
363
+ debugCacheLog(
364
+ `[CacheScope] waitUntil: SKIPPED (null components: ${nullSegments.join(", ")}) for ${key}`,
365
+ );
366
+ }
367
+ return;
368
+ }
341
369
  }
342
370
 
343
371
  // Collect handle data for non-loader segments only
344
372
  const handles = captureHandles(nonLoaderSegments, handleStore);
345
373
 
346
374
  try {
375
+ if (INTERNAL_RANGO_DEBUG) {
376
+ debugCacheLog(
377
+ `[CacheScope] waitUntil: serializing ${nonLoaderSegments.length} segments for ${key}`,
378
+ );
379
+ }
380
+
347
381
  // Serialize non-loader segments only
348
382
  const serializedSegments = await serializeSegments(nonLoaderSegments);
349
383
 
@@ -353,6 +387,12 @@ export class CacheScope {
353
387
  expiresAt: Date.now() + ttl * 1000,
354
388
  };
355
389
 
390
+ if (INTERNAL_RANGO_DEBUG) {
391
+ debugCacheLog(
392
+ `[CacheScope] waitUntil: calling store.set for ${key}`,
393
+ );
394
+ }
395
+
356
396
  await store.set(key, data, ttl, swr);
357
397
 
358
398
  if (INTERNAL_RANGO_DEBUG) {