@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.
Files changed (51) hide show
  1. package/README.md +65 -0
  2. package/dist/vite/index.js +1160 -443
  3. package/package.json +3 -1
  4. package/skills/hooks/SKILL.md +5 -0
  5. package/skills/links/SKILL.md +2 -0
  6. package/skills/loader/SKILL.md +35 -1
  7. package/skills/middleware/SKILL.md +2 -0
  8. package/skills/migrate-nextjs/SKILL.md +3 -1
  9. package/skills/migrate-react-router/SKILL.md +4 -0
  10. package/skills/parallel/SKILL.md +7 -0
  11. package/skills/rango/SKILL.md +1 -0
  12. package/skills/server-actions/SKILL.md +739 -0
  13. package/src/browser/event-controller.ts +44 -4
  14. package/src/browser/react/NavigationProvider.tsx +20 -7
  15. package/src/browser/react/filter-segment-order.ts +51 -7
  16. package/src/browser/react/use-segments.ts +11 -8
  17. package/src/browser/types.ts +6 -0
  18. package/src/route-definition/helpers-types.ts +6 -1
  19. package/src/router/match-api.ts +1 -0
  20. package/src/router/match-handlers.ts +1 -0
  21. package/src/router/match-result.ts +21 -2
  22. package/src/router/middleware.ts +22 -3
  23. package/src/router/pattern-matching.ts +30 -11
  24. package/src/router/revalidation.ts +15 -1
  25. package/src/router/segment-resolution/fresh.ts +8 -0
  26. package/src/router/segment-resolution/revalidation.ts +128 -100
  27. package/src/router/trie-matching.ts +8 -9
  28. package/src/rsc/progressive-enhancement.ts +2 -0
  29. package/src/rsc/rsc-rendering.ts +3 -0
  30. package/src/rsc/server-action.ts +2 -0
  31. package/src/rsc/types.ts +6 -0
  32. package/src/ssr/index.tsx +5 -1
  33. package/src/types/handler-context.ts +10 -5
  34. package/src/types/segments.ts +17 -0
  35. package/src/vite/debug.ts +184 -0
  36. package/src/vite/discovery/discover-routers.ts +31 -3
  37. package/src/vite/discovery/gate-state.ts +171 -0
  38. package/src/vite/discovery/prerender-collection.ts +48 -1
  39. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  40. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  41. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  42. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  43. package/src/vite/plugins/expose-action-id.ts +52 -28
  44. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  45. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  46. package/src/vite/plugins/performance-tracks.ts +17 -9
  47. package/src/vite/plugins/use-cache-transform.ts +56 -43
  48. package/src/vite/plugins/version-injector.ts +37 -11
  49. package/src/vite/rango.ts +19 -5
  50. package/src/vite/router-discovery.ts +498 -52
  51. 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 newSegmentOrder = filterSegmentOrder(matched ?? []);
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
- // Clean up data from segments no longer in the matched list
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
- if (!newSegmentOrder.includes(segmentId)) {
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 { eventController, store, matched, isPartial, historyKey } = opts;
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 (cached handlers),
77
- // we still need to update the segment order to clean up stale handle data.
78
- // This happens when navigating away from a route - the handlers for the new
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
- * Filter segment IDs to only include routes and layouts.
3
- * Excludes parallels (contain .@) and loaders (contain D followed by digit).
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
- return matched.filter((id) => {
7
- if (id.includes(".@")) return false;
8
- if (/D\d+\./.test(id)) return false;
9
- return true;
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
- segmentOrder: string[],
35
+ routeSegmentIds: string[],
33
36
  ): SegmentsState {
34
37
  return {
35
38
  path: parsePathname(location.pathname),
36
- segmentIds: segmentOrder,
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.segmentOrder,
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
- segmentOrder: string[];
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.segmentOrder === handleState.segmentOrder
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.segmentOrder,
125
+ handleState.routeSegmentIds,
123
126
  );
124
127
  segmentsCache.current = {
125
128
  location: location as URL,
126
- segmentOrder: handleState.segmentOrder,
129
+ routeSegmentIds: handleState.routeSegmentIds,
127
130
  state: segmentsState,
128
131
  };
129
132
  }
@@ -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 that returns boolean (hard) or { defaultShouldRevalidate } (soft)
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
  /**
@@ -550,6 +550,7 @@ export async function matchError<TEnv>(
550
550
  segments: [errorSegment],
551
551
  matched: matchedIds,
552
552
  diff: [errorSegment.id],
553
+ resolvedIds: [errorSegment.id],
553
554
  params: matched.params,
554
555
  };
555
556
  }
@@ -196,6 +196,7 @@ export function createMatchHandlers<TEnv = any>(
196
196
  segments: [],
197
197
  matched: [],
198
198
  diff: [],
199
+ resolvedIds: [],
199
200
  params: {},
200
201
  redirect: result.redirectUrl,
201
202
  };
@@ -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: dedupedSegments,
292
+ segments: cleanedSegments,
275
293
  matched: matchedIds,
276
- diff: dedupedSegments.map((s) => s.id),
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,
@@ -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
- finishMiddleware();
451
- throw error;
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
- const result = await middleware(ctx, guardedNext);
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 (empty-string = absent optional, which is allowed).
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 will have empty string value.
287
- * Use the pattern definition to determine if a param is optional.
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: Record<string, string> = {};
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: Record<string, string> = {};
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 (request.method === "POST") {
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,