@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.
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/src/browser/partial-update.ts +11 -0
- package/src/cache/cache-runtime.ts +15 -11
- package/src/router/match-middleware/cache-lookup.ts +35 -2
- package/src/router/segment-resolution/fresh.ts +25 -8
- package/src/router/segment-resolution/revalidation.ts +34 -17
- package/src/types/handler-context.ts +103 -17
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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
|
@@ -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
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
562
|
+
|
|
563
|
+
// ── Segment metadata (which segment is being evaluated) ──────────────
|
|
564
|
+
|
|
565
|
+
/** The type of segment being revalidated. */
|
|
530
566
|
segmentType: "layout" | "route" | "parallel";
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|