@rangojs/router 0.0.0-experimental.452b518d → 0.0.0-experimental.47

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.
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.452b518d",
1748
+ version: "0.0.0-experimental.47",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.452b518d",
3
+ "version": "0.0.0-experimental.47",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -259,6 +259,17 @@ export function createPartialUpdater(
259
259
  existingSegments,
260
260
  );
261
261
 
262
+ // Fix: tx.commit() cached the source page's handleData because
263
+ // eventController hasn't been updated yet. Overwrite with the
264
+ // correct cached handleData to prevent cache corruption on
265
+ // subsequent navigations to this same URL.
266
+ if (mode.targetCacheHandleData) {
267
+ store.updateCacheHandleData(
268
+ store.getHistoryKey(),
269
+ mode.targetCacheHandleData,
270
+ );
271
+ }
272
+
262
273
  // Include cachedHandleData in metadata so NavigationProvider can restore
263
274
  // breadcrumbs and other handle data from cache.
264
275
  // Remove `handles` from metadata to prevent NavigationProvider from
@@ -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;
@@ -70,9 +70,11 @@
70
70
  * - No segments yielded from this middleware
71
71
  *
72
72
  * Loaders:
73
- * - NEVER cached by design
73
+ * - NEVER cached in the segment cache
74
74
  * - Always resolved fresh on every request
75
75
  * - Ensures data freshness even with cached UI components
76
+ * - Segment cache staleness does NOT propagate to loader revalidation;
77
+ * loaders use their own revalidation rules (actionId, user-defined)
76
78
  *
77
79
  *
78
80
  * REVALIDATION RULES
@@ -518,7 +520,34 @@ export function withCacheLookup<TEnv>(
518
520
 
519
521
  // Look up revalidation rules for this segment
520
522
  const entryInfo = entryRevalidateMap?.get(segment.id);
523
+
524
+ // Even without explicit revalidation rules, route segments and their
525
+ // children must re-render when search params change — the handler reads
526
+ // ctx.searchParams so different ?page= values produce different content.
527
+ const searchChanged = ctx.prevUrl.search !== ctx.url.search;
528
+ const shouldDefaultRevalidate =
529
+ searchChanged &&
530
+ (segment.type === "route" ||
531
+ (segment.belongsToRoute &&
532
+ (segment.type === "layout" || segment.type === "parallel")));
533
+
521
534
  if (!entryInfo || entryInfo.revalidate.length === 0) {
535
+ if (shouldDefaultRevalidate) {
536
+ // Search params changed — must re-render even without custom rules
537
+ if (isTraceActive()) {
538
+ pushRevalidationTraceEntry({
539
+ segmentId: segment.id,
540
+ segmentType: segment.type,
541
+ belongsToRoute: segment.belongsToRoute ?? false,
542
+ source: "cache-hit",
543
+ defaultShouldRevalidate: true,
544
+ finalShouldRevalidate: true,
545
+ reason: "cached-search-changed",
546
+ });
547
+ }
548
+ yield segment;
549
+ continue;
550
+ }
522
551
  // No revalidation rules, use default behavior (skip if client has)
523
552
  if (isTraceActive()) {
524
553
  pushRevalidationTraceEntry({
@@ -615,7 +644,11 @@ export function withCacheLookup<TEnv>(
615
644
  ctx.url,
616
645
  ctx.routeKey,
617
646
  ctx.actionContext,
618
- cacheResult.shouldRevalidate || undefined,
647
+ // Loaders are never cached in the segment cache, so segment
648
+ // staleness (cacheResult.shouldRevalidate) must not propagate.
649
+ // But browser-sent staleness (ctx.stale) — indicating an action
650
+ // happened in this or another tab — must still reach loaders.
651
+ ctx.stale || undefined,
619
652
  ),
620
653
  );
621
654
 
@@ -624,20 +624,37 @@ export async function resolveLoadersOnly<TEnv>(
624
624
  deps: SegmentResolutionDeps<TEnv>,
625
625
  ): Promise<ResolvedSegment[]> {
626
626
  const loaderSegments: ResolvedSegment[] = [];
627
+ const seenIds = new Set<string>();
627
628
 
628
629
  async function collectEntryLoaders(
629
630
  entry: EntryData,
630
631
  belongsToRoute: boolean,
631
632
  shortCodeOverride?: string,
632
633
  ): Promise<void> {
633
- const segments = await resolveLoaders(
634
- entry,
635
- context,
636
- belongsToRoute,
637
- deps,
638
- shortCodeOverride,
639
- );
640
- loaderSegments.push(...segments);
634
+ // Skip if all loaders from this entry have already been resolved
635
+ // via a parent (e.g., cache boundary wrapping a layout with shared loaders).
636
+ const entryLoaders = entry.loader ?? [];
637
+ const sc = shortCodeOverride ?? entry.shortCode;
638
+ const allAlreadySeen =
639
+ entryLoaders.length > 0 &&
640
+ entryLoaders.every((le, i) =>
641
+ seenIds.has(`${sc}D${i}.${le.loader.$$id}`),
642
+ );
643
+ if (!allAlreadySeen) {
644
+ const segments = await resolveLoaders(
645
+ entry,
646
+ context,
647
+ belongsToRoute,
648
+ deps,
649
+ shortCodeOverride,
650
+ );
651
+ for (const seg of segments) {
652
+ if (!seenIds.has(seg.id)) {
653
+ seenIds.add(seg.id);
654
+ loaderSegments.push(seg);
655
+ }
656
+ }
657
+ }
641
658
 
642
659
  const seenParallelEntryIds = new Set<string>();
643
660
  for (const parallelEntry of getParallelEntries(entry.parallel)) {
@@ -262,29 +262,46 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
262
262
  ): Promise<{ segments: ResolvedSegment[]; matchedIds: string[] }> {
263
263
  const allLoaderSegments: ResolvedSegment[] = [];
264
264
  const allMatchedIds: string[] = [];
265
+ const seenIds = new Set<string>();
265
266
 
266
267
  async function collectEntryLoaders(
267
268
  entry: EntryData,
268
269
  belongsToRoute: boolean,
269
270
  shortCodeOverride?: string,
270
271
  ): Promise<void> {
271
- const { segments, matchedIds } = await resolveLoadersWithRevalidation(
272
- entry,
273
- context,
274
- belongsToRoute,
275
- clientSegmentIds,
276
- prevParams,
277
- request,
278
- prevUrl,
279
- nextUrl,
280
- routeKey,
281
- deps,
282
- actionContext,
283
- shortCodeOverride,
284
- stale,
285
- );
286
- allLoaderSegments.push(...segments);
287
- allMatchedIds.push(...matchedIds);
272
+ // Skip if all loaders from this entry have already been resolved
273
+ // via a parent (e.g., cache boundary wrapping a layout with shared loaders).
274
+ const loaderEntries = entry.loader ?? [];
275
+ const sc = shortCodeOverride ?? entry.shortCode;
276
+ const allAlreadySeen =
277
+ loaderEntries.length > 0 &&
278
+ loaderEntries.every((le, i) =>
279
+ seenIds.has(`${sc}D${i}.${le.loader.$$id}`),
280
+ );
281
+ if (!allAlreadySeen) {
282
+ const { segments, matchedIds } = await resolveLoadersWithRevalidation(
283
+ entry,
284
+ context,
285
+ belongsToRoute,
286
+ clientSegmentIds,
287
+ prevParams,
288
+ request,
289
+ prevUrl,
290
+ nextUrl,
291
+ routeKey,
292
+ deps,
293
+ actionContext,
294
+ shortCodeOverride,
295
+ stale,
296
+ );
297
+ for (const seg of segments) {
298
+ if (!seenIds.has(seg.id)) {
299
+ seenIds.add(seg.id);
300
+ allLoaderSegments.push(seg);
301
+ }
302
+ }
303
+ allMatchedIds.push(...matchedIds);
304
+ }
288
305
 
289
306
  const seenParallelEntryIds = new Set<string>();
290
307
  for (const parallelEntry of getParallelEntries(entry.parallel)) {
@@ -289,8 +289,12 @@ export type HandlerContext<
289
289
  /**
290
290
  * Access loader data or push handle data.
291
291
  *
292
+ * Available in route handlers, layout handlers, middleware, server actions,
293
+ * and server components rendered within the request context.
294
+ *
292
295
  * For loaders: Returns a promise that resolves to the loader data.
293
- * Loaders are executed in parallel and memoized per request.
296
+ * Loaders are executed in parallel and memoized per request — calling
297
+ * `ctx.use(SameLoader)` multiple times returns the same promise.
294
298
  *
295
299
  * For handles: Returns a push function to add data for this segment.
296
300
  * Handle data accumulates across all matched route segments.
@@ -519,30 +523,112 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
519
523
  * })
520
524
  * ```
521
525
  */
526
+ /**
527
+ * Revalidation function called during client-side navigation to decide whether
528
+ * a segment (layout, route, parallel slot, or loader) should be re-rendered.
529
+ *
530
+ * Return `true` to re-render, `false` to skip (keep client's current version),
531
+ * or `{ defaultShouldRevalidate: boolean }` to override the default for
532
+ * downstream segments.
533
+ *
534
+ * @example
535
+ * ```ts
536
+ * // Re-render only when a cart action happened or browser signals staleness
537
+ * revalidate(({ actionId, stale }) =>
538
+ * actionId?.includes("cart") || stale || false
539
+ * )
540
+ *
541
+ * // Always re-render when params change (default behavior made explicit)
542
+ * revalidate(({ defaultShouldRevalidate }) => defaultShouldRevalidate)
543
+ * ```
544
+ */
522
545
  export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
546
+ /** Route params from the page being navigated away from. */
523
547
  currentParams: TParams;
548
+ /** Full URL of the page being navigated away from. */
524
549
  currentUrl: URL;
550
+ /** Route params for the navigation target. */
525
551
  nextParams: TParams;
552
+ /** Full URL of the navigation target. */
526
553
  nextUrl: URL;
554
+ /**
555
+ * The router's default revalidation decision for this segment.
556
+ * `true` when params changed or the segment is new to the client.
557
+ * Return this when you want default behavior plus your own conditions.
558
+ */
527
559
  defaultShouldRevalidate: boolean;
560
+ /** Full handler context — access to `ctx.use()`, `ctx.env`, `ctx.params`, etc. */
528
561
  context: HandlerContext<TParams, TEnv>;
529
- // Segment metadata (which segment is being evaluated):
562
+
563
+ // ── Segment metadata (which segment is being evaluated) ──────────────
564
+
565
+ /** The type of segment being revalidated. */
530
566
  segmentType: "layout" | "route" | "parallel";
531
- layoutName?: string; // Layout name (e.g., "root", "shop", "auth") - only for layouts
532
- slotName?: string; // Slot name (e.g., "@sidebar", "@modal") - only for parallels
533
- // Action context (populated when revalidation triggered by server action):
534
- actionId?: string; // Action identifier (e.g., "src/actions.ts#addToCart")
535
- actionUrl?: URL; // URL where action was executed
536
- actionResult?: any; // Return value from action execution
537
- formData?: FormData; // FormData from action request
538
- method?: string; // Request method: 'GET' for navigation, 'POST' for actions
539
- routeName?: DefaultRouteName; // Route name of the navigation target (alias for toRouteName)
540
- // Named-route identity for both ends of a navigation transition.
541
- // Undefined for unnamed internal routes (those without a `name` option).
542
- fromRouteName?: DefaultRouteName; // Route name being navigated away from
543
- toRouteName?: DefaultRouteName; // Route name being navigated to
544
- // Stale cache revalidation (SWR pattern):
545
- stale?: boolean; // True if this is a stale cache revalidation request
567
+ /** Layout name (e.g., `"root"`, `"shop"`, `"auth"`). Only set for layout segments. */
568
+ layoutName?: string;
569
+ /** Slot name (e.g., `"@sidebar"`, `"@modal"`). Only set for parallel segments. */
570
+ slotName?: string;
571
+
572
+ // ── Action context (populated when revalidation is triggered by a server action) ──
573
+
574
+ /**
575
+ * Identifier of the server action that triggered revalidation.
576
+ * `undefined` during normal navigation (no action involved).
577
+ *
578
+ * Format: `"src/<path>#<exportName>"` the file path is the source path
579
+ * relative to the project root, followed by `#` and the exported function name.
580
+ *
581
+ * This is stable and can be used for path-based matching to revalidate
582
+ * when any action in a module or directory fires:
583
+ *
584
+ * @example
585
+ * ```ts
586
+ * // Match a specific action
587
+ * revalidate(({ actionId }) => actionId === "src/actions/cart.ts#addToCart")
588
+ *
589
+ * // Match any action in the cart module
590
+ * revalidate(({ actionId }) => actionId?.includes("cart") ?? false)
591
+ *
592
+ * // Match any action under src/apps/store/actions/
593
+ * revalidate(({ actionId }) => actionId?.startsWith("src/apps/store/actions/") ?? false)
594
+ * ```
595
+ */
596
+ actionId?: string;
597
+ /** URL where the action was executed (the page the user was on when they triggered the action). */
598
+ actionUrl?: URL;
599
+ /** Return value from the action execution. Can be used to conditionally revalidate based on the action's outcome. */
600
+ actionResult?: any;
601
+ /** FormData from the action request body. Only set for form-based actions (not inline `"use server"` actions). */
602
+ formData?: FormData;
603
+ /** HTTP method: `"GET"` for navigation, `"POST"` for server actions. */
604
+ method?: string;
605
+
606
+ // ── Route identity ───────────────────────────────────────────────────
607
+
608
+ /** Route name of the navigation target. Alias for `toRouteName`. */
609
+ routeName?: DefaultRouteName;
610
+ /**
611
+ * Route name being navigated away from.
612
+ * `undefined` for unnamed internal routes (those without a `name` option).
613
+ */
614
+ fromRouteName?: DefaultRouteName;
615
+ /**
616
+ * Route name being navigated to.
617
+ * `undefined` for unnamed internal routes (those without a `name` option).
618
+ */
619
+ toRouteName?: DefaultRouteName;
620
+
621
+ // ── Staleness signal ─────────────────────────────────────────────────
622
+
623
+ /**
624
+ * `true` when the browser signals that data may be stale — typically because
625
+ * a server action was executed in this or another tab (`_rsc_stale` header).
626
+ *
627
+ * This is NOT segment cache staleness (loaders are never segment-cached).
628
+ * Use this to decide whether loader data should be re-fetched after an
629
+ * action that may have mutated backend state.
630
+ */
631
+ stale?: boolean;
546
632
  }) => boolean | { defaultShouldRevalidate: boolean };
547
633
 
548
634
  // MiddlewareFn is imported from "../router/middleware.js" and re-exported