@rangojs/router 0.0.0-experimental.b000c598 → 0.0.0-experimental.b30bbf02
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/README.md +65 -0
- package/dist/vite/index.js +1160 -443
- package/package.json +3 -1
- package/skills/hooks/SKILL.md +5 -0
- package/skills/links/SKILL.md +2 -0
- package/skills/loader/SKILL.md +35 -1
- package/skills/middleware/SKILL.md +2 -0
- package/skills/migrate-nextjs/SKILL.md +3 -1
- package/skills/migrate-react-router/SKILL.md +4 -0
- package/skills/parallel/SKILL.md +7 -0
- package/skills/rango/SKILL.md +1 -0
- package/skills/server-actions/SKILL.md +739 -0
- package/src/browser/event-controller.ts +44 -4
- package/src/browser/react/NavigationProvider.tsx +20 -7
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/types.ts +6 -0
- package/src/route-definition/helpers-types.ts +6 -1
- package/src/router/match-api.ts +1 -0
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-result.ts +21 -2
- package/src/router/middleware.ts +22 -3
- package/src/router/pattern-matching.ts +30 -11
- package/src/router/revalidation.ts +15 -1
- package/src/router/segment-resolution/fresh.ts +8 -0
- package/src/router/segment-resolution/revalidation.ts +128 -100
- package/src/router/trie-matching.ts +8 -9
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/rsc-rendering.ts +3 -0
- package/src/rsc/server-action.ts +2 -0
- package/src/rsc/types.ts +6 -0
- package/src/ssr/index.tsx +5 -1
- package/src/types/handler-context.ts +10 -5
- package/src/types/segments.ts +17 -0
- package/src/vite/debug.ts +184 -0
- package/src/vite/discovery/discover-routers.ts +31 -3
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +48 -1
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/plugins/cjs-to-esm.ts +5 -0
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +16 -4
- package/src/vite/plugins/expose-action-id.ts +52 -28
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +516 -486
- package/src/vite/plugins/performance-tracks.ts +17 -9
- package/src/vite/plugins/use-cache-transform.ts +56 -43
- package/src/vite/plugins/version-injector.ts +37 -11
- package/src/vite/rango.ts +19 -5
- package/src/vite/router-discovery.ts +498 -52
- package/src/vite/utils/package-resolution.ts +8 -0
|
@@ -113,11 +113,24 @@ export type ActionStateListener = (state: TrackedActionState) => void;
|
|
|
113
113
|
export type HandleListener = () => void;
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
* Internal handle state stored in controller
|
|
116
|
+
* Internal handle state stored in controller.
|
|
117
|
+
*
|
|
118
|
+
* Two segment lists are exposed because they serve different consumers:
|
|
119
|
+
*
|
|
120
|
+
* - `segmentOrder` drives handle collection (collectHandleData). Includes
|
|
121
|
+
* parallel slot ids and reorders them after their parent so later-wins
|
|
122
|
+
* collect functions (e.g. Meta) get the right precedence.
|
|
123
|
+
* - `routeSegmentIds` is the layouts-and-routes-only list documented by
|
|
124
|
+
* `useSegments().segmentIds`. Parallels and loader sub-ids are stripped;
|
|
125
|
+
* raw matched order is preserved.
|
|
126
|
+
*
|
|
127
|
+
* Both are derived from the same `matched` input on each setHandleData call
|
|
128
|
+
* so they stay in sync.
|
|
117
129
|
*/
|
|
118
130
|
export interface HandleState {
|
|
119
131
|
data: HandleData;
|
|
120
132
|
segmentOrder: string[];
|
|
133
|
+
routeSegmentIds: string[];
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
/**
|
|
@@ -202,6 +215,14 @@ export interface EventController {
|
|
|
202
215
|
data: HandleData,
|
|
203
216
|
matched?: string[],
|
|
204
217
|
isPartial?: boolean,
|
|
218
|
+
/**
|
|
219
|
+
* Segment ids that were re-resolved on the server this request (the
|
|
220
|
+
* partial response's `diff`). On a partial update, any existing bucket
|
|
221
|
+
* keyed under one of these ids that has no incoming entry is treated as
|
|
222
|
+
* stale and cleared. Without this, a parallel slot that revalidates but
|
|
223
|
+
* pushes nothing leaves its previous bucket in place forever.
|
|
224
|
+
*/
|
|
225
|
+
resolvedIds?: string[],
|
|
205
226
|
): void;
|
|
206
227
|
getHandleState(): HandleState;
|
|
207
228
|
|
|
@@ -300,6 +321,7 @@ export function createEventController(
|
|
|
300
321
|
// Handle data from RSC payload
|
|
301
322
|
let handleData: HandleData = {};
|
|
302
323
|
let handleSegmentOrder: string[] = [];
|
|
324
|
+
let routeSegmentIds: string[] = [];
|
|
303
325
|
|
|
304
326
|
// Merged route params from current match
|
|
305
327
|
let routeParams: Record<string, string> = {};
|
|
@@ -744,8 +766,15 @@ export function createEventController(
|
|
|
744
766
|
data: HandleData,
|
|
745
767
|
matched?: string[],
|
|
746
768
|
isPartial?: boolean,
|
|
769
|
+
resolvedIds?: string[],
|
|
747
770
|
): void {
|
|
748
|
-
const
|
|
771
|
+
const rawMatched = matched ?? [];
|
|
772
|
+
const newSegmentOrder = filterSegmentOrder(rawMatched);
|
|
773
|
+
// Separate list for useSegments(): "layouts and routes only" — strip
|
|
774
|
+
// parallels (".@") and loader sub-ids (D digit) without reordering.
|
|
775
|
+
const newRouteSegmentIds = rawMatched.filter(
|
|
776
|
+
(id) => !id.includes(".@") && !/D\d+\./.test(id),
|
|
777
|
+
);
|
|
749
778
|
|
|
750
779
|
if (isPartial && newSegmentOrder.length > 0) {
|
|
751
780
|
// Partial update: merge new data with existing
|
|
@@ -757,10 +786,19 @@ export function createEventController(
|
|
|
757
786
|
handleData[handleName][segmentId] = data[handleName][segmentId];
|
|
758
787
|
}
|
|
759
788
|
}
|
|
760
|
-
|
|
789
|
+
const resolvedIdSet =
|
|
790
|
+
resolvedIds && resolvedIds.length > 0 ? new Set(resolvedIds) : null;
|
|
791
|
+
// Cleanup pass:
|
|
792
|
+
// a) segment dropped from the match list — delete its bucket.
|
|
793
|
+
// b) segment was re-resolved this request but pushed nothing for
|
|
794
|
+
// this handle — its previous bucket is stale.
|
|
795
|
+
// (a) is the existing behavior; (b) requires resolvedIds.
|
|
761
796
|
for (const handleName of Object.keys(handleData)) {
|
|
762
797
|
for (const segmentId of Object.keys(handleData[handleName])) {
|
|
763
|
-
|
|
798
|
+
const droppedFromMatch = !newSegmentOrder.includes(segmentId);
|
|
799
|
+
const reresolvedWithoutPush =
|
|
800
|
+
resolvedIdSet?.has(segmentId) && !data[handleName]?.[segmentId];
|
|
801
|
+
if (droppedFromMatch || reresolvedWithoutPush) {
|
|
764
802
|
delete handleData[handleName][segmentId];
|
|
765
803
|
}
|
|
766
804
|
}
|
|
@@ -770,6 +808,7 @@ export function createEventController(
|
|
|
770
808
|
handleData = data;
|
|
771
809
|
}
|
|
772
810
|
handleSegmentOrder = newSegmentOrder;
|
|
811
|
+
routeSegmentIds = newRouteSegmentIds;
|
|
773
812
|
|
|
774
813
|
notifyHandles();
|
|
775
814
|
}
|
|
@@ -778,6 +817,7 @@ export function createEventController(
|
|
|
778
817
|
return {
|
|
779
818
|
data: handleData,
|
|
780
819
|
segmentOrder: handleSegmentOrder,
|
|
820
|
+
routeSegmentIds,
|
|
781
821
|
};
|
|
782
822
|
}
|
|
783
823
|
|
|
@@ -47,10 +47,22 @@ async function processHandles(
|
|
|
47
47
|
store: NavigationStore;
|
|
48
48
|
matched?: string[];
|
|
49
49
|
isPartial?: boolean;
|
|
50
|
+
/** Server's `resolvedIds`: every segment re-resolved this request,
|
|
51
|
+
* including null-component ones excluded from `diff`/`segments`.
|
|
52
|
+
* Drives cleanup of stale handle buckets when a re-resolved segment
|
|
53
|
+
* pushed nothing. */
|
|
54
|
+
resolvedIds?: string[];
|
|
50
55
|
historyKey: string;
|
|
51
56
|
},
|
|
52
57
|
): Promise<void> {
|
|
53
|
-
const {
|
|
58
|
+
const {
|
|
59
|
+
eventController,
|
|
60
|
+
store,
|
|
61
|
+
matched,
|
|
62
|
+
isPartial,
|
|
63
|
+
resolvedIds,
|
|
64
|
+
historyKey,
|
|
65
|
+
} = opts;
|
|
54
66
|
|
|
55
67
|
let yieldCount = 0;
|
|
56
68
|
for await (const handleData of handlesGenerator) {
|
|
@@ -65,7 +77,7 @@ async function processHandles(
|
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
yieldCount++;
|
|
68
|
-
eventController.setHandleData(handleData, matched, isPartial);
|
|
80
|
+
eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
// Check again before final updates
|
|
@@ -73,12 +85,11 @@ async function processHandles(
|
|
|
73
85
|
return;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
// For partial updates where the generator yielded nothing (
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// route might not push any breadcrumbs, but we still need to remove the old ones.
|
|
88
|
+
// For partial updates where the generator yielded nothing (every
|
|
89
|
+
// re-resolved handler pushed nothing), still call setHandleData so the
|
|
90
|
+
// cleanup pass can clear out stale buckets for those segments.
|
|
80
91
|
if (yieldCount === 0 && matched) {
|
|
81
|
-
eventController.setHandleData({}, matched, true);
|
|
92
|
+
eventController.setHandleData({}, matched, true, resolvedIds);
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
// After handles processing completes, update the cache's handleData.
|
|
@@ -394,6 +405,7 @@ export function NavigationProvider({
|
|
|
394
405
|
store,
|
|
395
406
|
matched: update.metadata.matched,
|
|
396
407
|
isPartial: update.metadata.isPartial,
|
|
408
|
+
resolvedIds: update.metadata.resolvedIds,
|
|
397
409
|
historyKey,
|
|
398
410
|
}).catch((err) =>
|
|
399
411
|
console.error("[NavigationProvider] Error consuming handles:", err),
|
|
@@ -412,6 +424,7 @@ export function NavigationProvider({
|
|
|
412
424
|
{}, // Empty data - all existing data not in matched will be cleaned up
|
|
413
425
|
update.metadata.matched,
|
|
414
426
|
true, // partial update - will clean up segments not in matched
|
|
427
|
+
update.metadata.resolvedIds,
|
|
415
428
|
);
|
|
416
429
|
}
|
|
417
430
|
});
|
|
@@ -1,11 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Build the handle-collection segment order from a raw `matched` list.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities:
|
|
5
|
+
*
|
|
6
|
+
* 1. Drop loader sub-ids ("D" followed by a digit, e.g. "M0L0D1.user") —
|
|
7
|
+
* loaders never push handles.
|
|
8
|
+
*
|
|
9
|
+
* 2. Place each parallel slot id (contains ".@") immediately after its
|
|
10
|
+
* parent layout/route id. Raw segment-resolution emission order does NOT
|
|
11
|
+
* guarantee this: route-mounted parallels are resolved/pushed BEFORE the
|
|
12
|
+
* route handler's segment is appended (see fresh.ts:resolveSegment for
|
|
13
|
+
* routes, and revalidation.ts ~915-919), so matched can read
|
|
14
|
+
* `[..., R0.@panel, R0]`. collectHandleData consumes segmentOrder verbatim
|
|
15
|
+
* with later-wins semantics, so without normalization the route handler's
|
|
16
|
+
* Meta would override the slot's more-specific Meta — backwards.
|
|
17
|
+
*
|
|
18
|
+
* Slot-id format is `<parentShortCode>.@<slotName>`; `parentShortCode` never
|
|
19
|
+
* contains ".@", so splitting at the first ".@" reliably yields the parent.
|
|
4
20
|
*/
|
|
5
21
|
export function filterSegmentOrder(matched: string[]): string[] {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
22
|
+
const slotsByParent = new Map<string, string[]>();
|
|
23
|
+
const nonSlots: string[] = [];
|
|
24
|
+
const nonSlotSet = new Set<string>();
|
|
25
|
+
|
|
26
|
+
for (const id of matched) {
|
|
27
|
+
if (/D\d+\./.test(id)) continue;
|
|
28
|
+
const slotIdx = id.indexOf(".@");
|
|
29
|
+
if (slotIdx >= 0) {
|
|
30
|
+
const parent = id.slice(0, slotIdx);
|
|
31
|
+
const list = slotsByParent.get(parent);
|
|
32
|
+
if (list) {
|
|
33
|
+
list.push(id);
|
|
34
|
+
} else {
|
|
35
|
+
slotsByParent.set(parent, [id]);
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
nonSlots.push(id);
|
|
39
|
+
nonSlotSet.add(id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result: string[] = [];
|
|
44
|
+
for (const id of nonSlots) {
|
|
45
|
+
result.push(id);
|
|
46
|
+
const slots = slotsByParent.get(id);
|
|
47
|
+
if (slots) result.push(...slots);
|
|
48
|
+
}
|
|
49
|
+
// Defensive: any slot whose parent is missing from the filtered list still
|
|
50
|
+
// gets included rather than silently dropped. Shouldn't happen in practice.
|
|
51
|
+
for (const [parent, slots] of slotsByParent) {
|
|
52
|
+
if (!nonSlotSet.has(parent)) result.push(...slots);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
11
55
|
}
|
|
@@ -25,15 +25,18 @@ function parsePathname(pathname: string): string[] {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Build segments state from event controller
|
|
28
|
+
* Build segments state from event controller. `segmentIds` is the
|
|
29
|
+
* route-only list (parallels and loaders stripped) — distinct from the
|
|
30
|
+
* controller's `segmentOrder` which drives handle collection and includes
|
|
31
|
+
* parallel slot ids.
|
|
29
32
|
*/
|
|
30
33
|
function buildSegmentsState(
|
|
31
34
|
location: URL,
|
|
32
|
-
|
|
35
|
+
routeSegmentIds: string[],
|
|
33
36
|
): SegmentsState {
|
|
34
37
|
return {
|
|
35
38
|
path: parsePathname(location.pathname),
|
|
36
|
-
segmentIds:
|
|
39
|
+
segmentIds: routeSegmentIds,
|
|
37
40
|
location,
|
|
38
41
|
};
|
|
39
42
|
}
|
|
@@ -74,7 +77,7 @@ export function useSegments<T>(
|
|
|
74
77
|
const handleState = ctx.eventController.getHandleState();
|
|
75
78
|
const segmentsState = buildSegmentsState(
|
|
76
79
|
location as URL,
|
|
77
|
-
handleState.
|
|
80
|
+
handleState.routeSegmentIds,
|
|
78
81
|
);
|
|
79
82
|
return selector ? selector(segmentsState) : segmentsState;
|
|
80
83
|
});
|
|
@@ -94,7 +97,7 @@ export function useSegments<T>(
|
|
|
94
97
|
// render-time setState calls.
|
|
95
98
|
const segmentsCache = useRef<{
|
|
96
99
|
location: URL;
|
|
97
|
-
|
|
100
|
+
routeSegmentIds: string[];
|
|
98
101
|
state: SegmentsState;
|
|
99
102
|
} | null>(null);
|
|
100
103
|
|
|
@@ -113,17 +116,17 @@ export function useSegments<T>(
|
|
|
113
116
|
if (
|
|
114
117
|
cache &&
|
|
115
118
|
cache.location === location &&
|
|
116
|
-
cache.
|
|
119
|
+
cache.routeSegmentIds === handleState.routeSegmentIds
|
|
117
120
|
) {
|
|
118
121
|
segmentsState = cache.state;
|
|
119
122
|
} else {
|
|
120
123
|
segmentsState = buildSegmentsState(
|
|
121
124
|
location as URL,
|
|
122
|
-
handleState.
|
|
125
|
+
handleState.routeSegmentIds,
|
|
123
126
|
);
|
|
124
127
|
segmentsCache.current = {
|
|
125
128
|
location: location as URL,
|
|
126
|
-
|
|
129
|
+
routeSegmentIds: handleState.routeSegmentIds,
|
|
127
130
|
state: segmentsState,
|
|
128
131
|
};
|
|
129
132
|
}
|
package/src/browser/types.ts
CHANGED
|
@@ -39,6 +39,12 @@ export interface RscMetadata {
|
|
|
39
39
|
isError?: boolean;
|
|
40
40
|
matched?: string[];
|
|
41
41
|
diff?: string[];
|
|
42
|
+
/**
|
|
43
|
+
* All segment ids re-resolved on the server, including null-component
|
|
44
|
+
* ones excluded from `segments`/`diff`. Drives client-side handle-bucket
|
|
45
|
+
* cleanup. Superset of `diff`. See MatchResult.resolvedIds.
|
|
46
|
+
*/
|
|
47
|
+
resolvedIds?: string[];
|
|
42
48
|
/** Merged route params from the matched route */
|
|
43
49
|
params?: Record<string, string>;
|
|
44
50
|
/**
|
|
@@ -259,7 +259,12 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
259
259
|
* ({ defaultShouldRevalidate: true })
|
|
260
260
|
* )
|
|
261
261
|
* ```
|
|
262
|
-
* @param fn - Function
|
|
262
|
+
* @param fn - Function returning either:
|
|
263
|
+
* - `boolean` (hard decision — short-circuits the chain),
|
|
264
|
+
* - `{ defaultShouldRevalidate: boolean }` (soft — updates the suggestion
|
|
265
|
+
* for downstream revalidators),
|
|
266
|
+
* - or nothing / `null` / `undefined` (defer — leaves the suggestion
|
|
267
|
+
* unchanged and continues to the next revalidator).
|
|
263
268
|
*/
|
|
264
269
|
revalidate: (fn: ShouldRevalidateFn<any, TEnv>) => RevalidateItem;
|
|
265
270
|
/**
|
package/src/router/match-api.ts
CHANGED
|
@@ -270,10 +270,29 @@ export function buildMatchResult<TEnv>(
|
|
|
270
270
|
const matchedIds =
|
|
271
271
|
removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
|
|
272
272
|
|
|
273
|
+
// resolvedIds: every segment whose handler actually ran this request.
|
|
274
|
+
// For full-match every segment is fresh; for partial-match we filter by
|
|
275
|
+
// the internal `_handlerRan` flag set in revalidation.ts. Drives the
|
|
276
|
+
// client's handle-bucket cleanup — a slot that re-resolved and pushed
|
|
277
|
+
// nothing must have its previous handle data cleared, but `diff` won't
|
|
278
|
+
// carry it because the segment payload skips null-component cached
|
|
279
|
+
// segments to save bytes.
|
|
280
|
+
const resolvedIds = ctx.isFullMatch
|
|
281
|
+
? allSegments.map((s) => s.id)
|
|
282
|
+
: allSegments.filter((s) => s._handlerRan).map((s) => s.id);
|
|
283
|
+
|
|
284
|
+
// Strip internal-only fields from the segments going on the wire.
|
|
285
|
+
const cleanedSegments = dedupedSegments.map((s) => {
|
|
286
|
+
if (s._handlerRan === undefined) return s;
|
|
287
|
+
const { _handlerRan: _drop, ...rest } = s;
|
|
288
|
+
return rest as ResolvedSegment;
|
|
289
|
+
});
|
|
290
|
+
|
|
273
291
|
return {
|
|
274
|
-
segments:
|
|
292
|
+
segments: cleanedSegments,
|
|
275
293
|
matched: matchedIds,
|
|
276
|
-
diff:
|
|
294
|
+
diff: cleanedSegments.map((s) => s.id),
|
|
295
|
+
resolvedIds,
|
|
277
296
|
params: ctx.matched.params,
|
|
278
297
|
routeName: ctx.routeKey,
|
|
279
298
|
slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
|
package/src/router/middleware.ts
CHANGED
|
@@ -447,8 +447,16 @@ export async function executeMiddleware<TEnv>(
|
|
|
447
447
|
try {
|
|
448
448
|
result = await entry.handler(ctx, wrappedNext);
|
|
449
449
|
} catch (error) {
|
|
450
|
-
|
|
451
|
-
|
|
450
|
+
// Thrown Response is short-circuit control flow, not an error.
|
|
451
|
+
// Fall through to the `if (result instanceof Response)` branch below
|
|
452
|
+
// so stub headers and request-context cookies merge as they do for
|
|
453
|
+
// an explicit `return new Response(...)`. Real errors propagate.
|
|
454
|
+
if (error instanceof Response) {
|
|
455
|
+
result = error;
|
|
456
|
+
} else {
|
|
457
|
+
finishMiddleware();
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
452
460
|
}
|
|
453
461
|
finishMiddleware();
|
|
454
462
|
|
|
@@ -641,7 +649,18 @@ export async function executeInterceptMiddleware<TEnv>(
|
|
|
641
649
|
return next();
|
|
642
650
|
};
|
|
643
651
|
|
|
644
|
-
|
|
652
|
+
let result: Response | void;
|
|
653
|
+
try {
|
|
654
|
+
result = await middleware(ctx, guardedNext);
|
|
655
|
+
} catch (error) {
|
|
656
|
+
// Thrown Response is short-circuit control flow, parity with the
|
|
657
|
+
// explicit-return path below. Real errors propagate.
|
|
658
|
+
if (error instanceof Response) {
|
|
659
|
+
result = error;
|
|
660
|
+
} else {
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
645
664
|
|
|
646
665
|
if (result instanceof Response) {
|
|
647
666
|
earlyResponse = result;
|
|
@@ -205,7 +205,9 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
205
205
|
/**
|
|
206
206
|
* Validate decoded params against a compiled pattern's constraints.
|
|
207
207
|
* Returns false if any constrained param has a non-empty value not in the
|
|
208
|
-
* allowed list (
|
|
208
|
+
* allowed list. Absent optionals (key missing or `undefined`) are allowed;
|
|
209
|
+
* `""` is also tolerated as "absent" so user-provided params or fixtures
|
|
210
|
+
* that pass empty strings explicitly behave the same way.
|
|
209
211
|
*/
|
|
210
212
|
function satisfiesConstraints(
|
|
211
213
|
params: Record<string, string>,
|
|
@@ -232,6 +234,27 @@ function escapeRegex(str: string): string {
|
|
|
232
234
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
233
235
|
}
|
|
234
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Build the named-params record from a regex match. Optional segments that
|
|
239
|
+
* didn't capture leave the corresponding group `undefined`; we skip those
|
|
240
|
+
* keys so `ctx.params.<name>` reads as `undefined` rather than `""`. This
|
|
241
|
+
* keeps the runtime aligned with the `ExtractParams` type and matches the
|
|
242
|
+
* trie matcher's contract (see `trie-matching.ts:validateAndBuild`).
|
|
243
|
+
*/
|
|
244
|
+
function buildParamsFromMatch(
|
|
245
|
+
match: RegExpExecArray,
|
|
246
|
+
paramNames: string[],
|
|
247
|
+
): Record<string, string> {
|
|
248
|
+
const params: Record<string, string> = {};
|
|
249
|
+
paramNames.forEach((name, index) => {
|
|
250
|
+
const captured = match[index + 1];
|
|
251
|
+
if (captured !== undefined) {
|
|
252
|
+
params[name] = safeDecodeURIComponent(captured);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return params;
|
|
256
|
+
}
|
|
257
|
+
|
|
235
258
|
/**
|
|
236
259
|
* Extract the static prefix from a route pattern.
|
|
237
260
|
* Returns everything before the first param/wildcard.
|
|
@@ -283,8 +306,10 @@ export function extractStaticPrefix(pattern: string): string {
|
|
|
283
306
|
/**
|
|
284
307
|
* Match a pathname against registered routes
|
|
285
308
|
*
|
|
286
|
-
* Note: Optional params that are absent in the path
|
|
287
|
-
*
|
|
309
|
+
* Note: Optional params that are absent in the path are omitted from the
|
|
310
|
+
* returned `params` (read as `undefined`), matching the trie matcher and
|
|
311
|
+
* the `ExtractParams<"/:locale?/...">` type. Use the pattern definition or
|
|
312
|
+
* `optionalParams` to determine which keys are optional.
|
|
288
313
|
*
|
|
289
314
|
* Trailing slash handling (priority order):
|
|
290
315
|
* 1. Per-route `trailingSlash` config from route()
|
|
@@ -451,10 +476,7 @@ export function findMatch<TEnv>(
|
|
|
451
476
|
// Try exact match first
|
|
452
477
|
const match = regex.exec(pathname);
|
|
453
478
|
if (match) {
|
|
454
|
-
const params
|
|
455
|
-
paramNames.forEach((name, index) => {
|
|
456
|
-
params[name] = safeDecodeURIComponent(match[index + 1] ?? "");
|
|
457
|
-
});
|
|
479
|
+
const params = buildParamsFromMatch(match, paramNames);
|
|
458
480
|
|
|
459
481
|
// Validate constraints against decoded values; a failure falls
|
|
460
482
|
// through to the next route so other patterns can still match.
|
|
@@ -512,10 +534,7 @@ export function findMatch<TEnv>(
|
|
|
512
534
|
// Try alternate pathname (opposite trailing slash)
|
|
513
535
|
const altMatch = regex.exec(alternatePathname);
|
|
514
536
|
if (altMatch) {
|
|
515
|
-
const params
|
|
516
|
-
paramNames.forEach((name, index) => {
|
|
517
|
-
params[name] = safeDecodeURIComponent(altMatch[index + 1] ?? "");
|
|
518
|
-
});
|
|
537
|
+
const params = buildParamsFromMatch(altMatch, paramNames);
|
|
519
538
|
|
|
520
539
|
if (!satisfiesConstraints(params, constraints)) {
|
|
521
540
|
continue;
|
|
@@ -59,6 +59,14 @@ interface EvaluateRevalidationOptions<TEnv> {
|
|
|
59
59
|
stale?: boolean;
|
|
60
60
|
/** Trace source hint for the revalidation trace */
|
|
61
61
|
traceSource?: RevalidationTraceEntry["source"];
|
|
62
|
+
/**
|
|
63
|
+
* Override the segment-type-derived default. When set, the value is used as
|
|
64
|
+
* the seed `defaultShouldRevalidate` passed to user revalidate fns and the
|
|
65
|
+
* reason flows into the trace. Callers use this when client-knowledge
|
|
66
|
+
* (e.g. parallel slot not in clientSegmentIds) should dictate the seed
|
|
67
|
+
* instead of the params/method-based heuristic.
|
|
68
|
+
*/
|
|
69
|
+
defaultOverride?: { value: boolean; reason: string };
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
/**
|
|
@@ -81,6 +89,7 @@ export async function evaluateRevalidation<TEnv>(
|
|
|
81
89
|
actionContext,
|
|
82
90
|
stale,
|
|
83
91
|
traceSource,
|
|
92
|
+
defaultOverride,
|
|
84
93
|
} = options;
|
|
85
94
|
const nextParams = segment.params || {};
|
|
86
95
|
const paramsChanged = !paramsEqual(nextParams, prevParams);
|
|
@@ -110,7 +119,12 @@ export async function evaluateRevalidation<TEnv>(
|
|
|
110
119
|
let defaultShouldRevalidate: boolean;
|
|
111
120
|
let defaultReason: string;
|
|
112
121
|
|
|
113
|
-
if (
|
|
122
|
+
if (defaultOverride) {
|
|
123
|
+
// Caller injected the seed (e.g. parallel slot not in clientSegmentIds).
|
|
124
|
+
// Skip the type-derived heuristic — caller knows better in this context.
|
|
125
|
+
defaultShouldRevalidate = defaultOverride.value;
|
|
126
|
+
defaultReason = defaultOverride.reason;
|
|
127
|
+
} else if (request.method === "POST") {
|
|
114
128
|
// Actions: revalidate segments that belong to the route, skip parent chain
|
|
115
129
|
if (segment.type === "route") {
|
|
116
130
|
// Route segment always revalidates on actions
|
|
@@ -515,6 +515,14 @@ export async function resolveParallelEntry<TEnv>(
|
|
|
515
515
|
if (handler === undefined) {
|
|
516
516
|
continue;
|
|
517
517
|
}
|
|
518
|
+
// Pin `_currentSegmentId` to the slot's own id so handle pushes from
|
|
519
|
+
// inside the slot handler get their own bucket in the HandleStore.
|
|
520
|
+
// Parent-keying would collapse them into the parent layout's bucket;
|
|
521
|
+
// the partial-update merge then replaces the parent's bucket on a
|
|
522
|
+
// slot-only revalidation and drops layout-pushed Meta/Breadcrumbs.
|
|
523
|
+
// filterSegmentOrder() retains slot ids so the client preserves them.
|
|
524
|
+
(context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
|
|
525
|
+
`${parentShortCode}.${slot}`;
|
|
518
526
|
const doneParallelHandler = track(
|
|
519
527
|
`handler:${parallelEntry.id}.${slot}`,
|
|
520
528
|
2,
|