@rangojs/router 0.0.0-experimental.93 → 0.0.0-experimental.95

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.
@@ -2040,7 +2040,7 @@ import { resolve } from "node:path";
2040
2040
  // package.json
2041
2041
  var package_default = {
2042
2042
  name: "@rangojs/router",
2043
- version: "0.0.0-experimental.93",
2043
+ version: "0.0.0-experimental.95",
2044
2044
  description: "Django-inspired RSC router with composable URL patterns",
2045
2045
  keywords: [
2046
2046
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.93",
3
+ "version": "0.0.0-experimental.95",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -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
  /**
@@ -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,
@@ -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,
@@ -537,8 +537,11 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
537
537
  );
538
538
 
539
539
  let component: ReactNode | undefined;
540
+ let handlerRan = false;
540
541
  if (shouldResolve) {
541
542
  component = await tryStaticSlot(parallelEntry, slot, parallelId);
543
+ // tryStaticSlot returning a value means the static cache supplied the
544
+ // component — handler did NOT run. handlerRan stays false.
542
545
  }
543
546
  if (component === undefined) {
544
547
  const hasLoadingFallback =
@@ -549,29 +552,37 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
549
552
  // Handler evicted (production static slot) but static lookup missed.
550
553
  // Nothing to render — use null so the client keeps its cached version.
551
554
  component = null;
552
- } else if (hasLoadingFallback) {
553
- const result =
554
- typeof handler === "function" ? handler(context) : handler;
555
- if (result instanceof Promise) {
556
- const tracked = deps.trackHandler(result, {
557
- segmentId: parallelId,
558
- segmentType: "parallel",
559
- });
560
- observeStreamedHandler(
561
- tracked,
562
- parallelId,
563
- "parallel",
564
- context.pathname,
565
- routeKey,
566
- params,
567
- );
568
- component = tracked as ReactNode;
555
+ } else {
556
+ // Slot-keyed pushes — slot owns its own bucket, parent layout owns
557
+ // its own. On slot-only revalidations the partial merge updates only
558
+ // the slot's bucket; the parent's bucket stays intact.
559
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
560
+ parallelId;
561
+ handlerRan = true;
562
+ if (hasLoadingFallback) {
563
+ const result =
564
+ typeof handler === "function" ? handler(context) : handler;
565
+ if (result instanceof Promise) {
566
+ const tracked = deps.trackHandler(result, {
567
+ segmentId: parallelId,
568
+ segmentType: "parallel",
569
+ });
570
+ observeStreamedHandler(
571
+ tracked,
572
+ parallelId,
573
+ "parallel",
574
+ context.pathname,
575
+ routeKey,
576
+ params,
577
+ );
578
+ component = tracked as ReactNode;
579
+ } else {
580
+ component = result as ReactNode;
581
+ }
569
582
  } else {
570
- component = result as ReactNode;
583
+ component =
584
+ typeof handler === "function" ? await handler(context) : handler;
571
585
  }
572
- } else {
573
- component =
574
- typeof handler === "function" ? await handler(context) : handler;
575
586
  }
576
587
  }
577
588
 
@@ -585,6 +596,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
585
596
  transition: parallelEntry.transition,
586
597
  params,
587
598
  slot,
599
+ _handlerRan: handlerRan,
588
600
  belongsToRoute,
589
601
  parallelName: `${parallelEntry.id}.${slot}`,
590
602
  ...(parallelEntry.mountPath
@@ -639,6 +651,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
639
651
  ): Promise<{ segment: ResolvedSegment; matchedId: string }> {
640
652
  const matchedId = entry.shortCode;
641
653
 
654
+ let handlerRan = false;
642
655
  const component = await revalidate(
643
656
  async () => {
644
657
  const hasSegment = clientSegmentIds.has(entry.shortCode);
@@ -715,6 +728,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
715
728
  return shouldRevalidate;
716
729
  },
717
730
  async () => {
731
+ handlerRan = true;
718
732
  const doneHandler = track(`handler:${entry.id}`, 2);
719
733
  (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
720
734
  entry.shortCode;
@@ -796,6 +810,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
796
810
  ? { layoutName: entry.id }
797
811
  : {}),
798
812
  ...(entry.mountPath ? { mountPath: entry.mountPath } : {}),
813
+ _handlerRan: handlerRan,
799
814
  };
800
815
 
801
816
  return { segment, matchedId };
@@ -1228,6 +1243,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1228
1243
  );
1229
1244
 
1230
1245
  let component: ReactNode | undefined;
1246
+ let handlerRan = false;
1231
1247
  if (shouldResolve) {
1232
1248
  component = await tryStaticSlot(parallelEntry, slot, parallelId);
1233
1249
  }
@@ -1239,29 +1255,35 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1239
1255
  } else if (handler === undefined) {
1240
1256
  // Handler evicted (production static slot) but static lookup missed.
1241
1257
  component = null;
1242
- } else if (hasLoadingFallback) {
1243
- const result =
1244
- typeof handler === "function" ? handler(context) : handler;
1245
- if (result instanceof Promise) {
1246
- const tracked = deps.trackHandler(result, {
1247
- segmentId: parallelId,
1248
- segmentType: "parallel",
1249
- });
1250
- observeStreamedHandler(
1251
- tracked,
1252
- parallelId,
1253
- "parallel",
1254
- context.pathname,
1255
- routeKey,
1256
- params,
1257
- );
1258
- component = tracked as ReactNode;
1258
+ } else {
1259
+ // Slot-keyed pushes — see resolveParallelSegmentsWithRevalidation.
1260
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
1261
+ parallelId;
1262
+ handlerRan = true;
1263
+ if (hasLoadingFallback) {
1264
+ const result =
1265
+ typeof handler === "function" ? handler(context) : handler;
1266
+ if (result instanceof Promise) {
1267
+ const tracked = deps.trackHandler(result, {
1268
+ segmentId: parallelId,
1269
+ segmentType: "parallel",
1270
+ });
1271
+ observeStreamedHandler(
1272
+ tracked,
1273
+ parallelId,
1274
+ "parallel",
1275
+ context.pathname,
1276
+ routeKey,
1277
+ params,
1278
+ );
1279
+ component = tracked as ReactNode;
1280
+ } else {
1281
+ component = result as ReactNode;
1282
+ }
1259
1283
  } else {
1260
- component = result as ReactNode;
1284
+ component =
1285
+ typeof handler === "function" ? await handler(context) : handler;
1261
1286
  }
1262
- } else {
1263
- component =
1264
- typeof handler === "function" ? await handler(context) : handler;
1265
1287
  }
1266
1288
  }
1267
1289
 
@@ -1275,6 +1297,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1275
1297
  transition: parallelEntry.transition,
1276
1298
  params,
1277
1299
  slot,
1300
+ _handlerRan: handlerRan,
1278
1301
  belongsToRoute,
1279
1302
  parallelName: `${parallelEntry.id}.${slot}`,
1280
1303
  ...(parallelEntry.mountPath
@@ -248,6 +248,7 @@ export async function handleProgressiveEnhancement<TEnv>(
248
248
  segments: match.segments,
249
249
  matched: match.matched,
250
250
  diff: match.diff,
251
+ resolvedIds: match.resolvedIds,
251
252
  params: match.params,
252
253
  isPartial: false,
253
254
  rootLayout: ctx.router.rootLayout,
@@ -354,6 +355,7 @@ async function renderPeErrorBoundary<TEnv>(
354
355
  segments: errorResult.segments,
355
356
  matched: errorResult.matched,
356
357
  diff: errorResult.diff,
358
+ resolvedIds: errorResult.resolvedIds,
357
359
  params: errorResult.params,
358
360
  isPartial: false,
359
361
  isError: true,
@@ -59,6 +59,7 @@ export async function handleRscRendering<TEnv>(
59
59
  segments: match.segments,
60
60
  matched: match.matched,
61
61
  diff: match.diff,
62
+ resolvedIds: match.resolvedIds,
62
63
  params: match.params,
63
64
  isPartial: false,
64
65
  rootLayout: ctx.router.rootLayout,
@@ -81,6 +82,7 @@ export async function handleRscRendering<TEnv>(
81
82
  segments: result.segments,
82
83
  matched: result.matched,
83
84
  diff: result.diff,
85
+ resolvedIds: result.resolvedIds,
84
86
  params: result.params,
85
87
  isPartial: true,
86
88
  slots: result.slots,
@@ -144,6 +146,7 @@ export async function handleRscRendering<TEnv>(
144
146
  segments: match.segments,
145
147
  matched: match.matched,
146
148
  diff: match.diff,
149
+ resolvedIds: match.resolvedIds,
147
150
  params: match.params,
148
151
  isPartial: false,
149
152
  rootLayout: ctx.router.rootLayout,
@@ -213,6 +213,7 @@ export async function executeServerAction<TEnv>(
213
213
  isPartial: true,
214
214
  matched: errorResult.matched,
215
215
  diff: errorResult.diff,
216
+ resolvedIds: errorResult.resolvedIds,
216
217
  params: errorResult.params,
217
218
  isError: true,
218
219
  handles: handleStore.stream(),
@@ -324,6 +325,7 @@ export async function revalidateAfterAction<TEnv>(
324
325
  isPartial: true,
325
326
  matched: matchResult.matched,
326
327
  diff: matchResult.diff,
328
+ resolvedIds: matchResult.resolvedIds,
327
329
  params: matchResult.params,
328
330
  slots: matchResult.slots,
329
331
  handles: handleStore.stream(),
package/src/rsc/types.ts CHANGED
@@ -26,6 +26,12 @@ export interface RscPayload {
26
26
  isError?: boolean;
27
27
  matched?: string[];
28
28
  diff?: string[];
29
+ /**
30
+ * All segment ids re-resolved on the server, including null-component
31
+ * ones excluded from `segments`/`diff`. Drives client-side handle-bucket
32
+ * cleanup. Superset of `diff`. See MatchResult.resolvedIds.
33
+ */
34
+ resolvedIds?: string[];
29
35
  /** Merged route params from the matched route */
30
36
  params?: Record<string, string>;
31
37
  slots?: Record<string, SlotState>;
package/src/ssr/index.tsx CHANGED
@@ -162,9 +162,13 @@ function createSsrEventController(opts: {
162
162
  }): EventController {
163
163
  const location = new URL(opts.pathname, "http://localhost");
164
164
  let params = opts.params ?? {};
165
+ const rawMatched = opts.matched ?? [];
165
166
  const handleState = {
166
167
  data: opts.handleData ?? {},
167
- segmentOrder: filterSegmentOrder(opts.matched ?? []),
168
+ segmentOrder: filterSegmentOrder(rawMatched),
169
+ routeSegmentIds: rawMatched.filter(
170
+ (id) => !id.includes(".@") && !/D\d+\./.test(id),
171
+ ),
168
172
  };
169
173
  const state: DerivedNavigationState = {
170
174
  state: "idle",
@@ -62,6 +62,14 @@ export interface ResolvedSegment {
62
62
  notFoundInfo?: NotFoundInfo; // For notFound segments: the not found information
63
63
  // Mount path from include() scope, used for MountContext.Provider wrapping
64
64
  mountPath?: string;
65
+ /**
66
+ * @internal Server-side marker: true when the segment's handler actually ran
67
+ * this request (not skipped via the revalidate cache path). Used by
68
+ * match-result.ts to populate `MatchResult.resolvedIds` for client-side
69
+ * handle-bucket cleanup. Stripped from the wire payload before serialization
70
+ * — never reaches the client.
71
+ */
72
+ _handlerRan?: boolean;
65
73
  }
66
74
 
67
75
  /**
@@ -116,6 +124,15 @@ export interface MatchResult {
116
124
  segments: ResolvedSegment[];
117
125
  matched: string[];
118
126
  diff: string[];
127
+ /**
128
+ * Every segment id whose handler actually ran on the server this request,
129
+ * including ones with `component === null` that get filtered out of
130
+ * `segments`/`diff` to avoid wasted bytes. Drives the client's handle-
131
+ * cleanup pass — a slot that re-resolves and pushes nothing must clear
132
+ * its previous handle bucket, but `diff` doesn't carry it because the
133
+ * segment payload doesn't either. A superset of `diff`.
134
+ */
135
+ resolvedIds: string[];
119
136
  /**
120
137
  * Merged route params from all matched segments
121
138
  * Available for use by the handler after route matching