@rangojs/router 0.0.0-experimental.92 → 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.92",
2043
+ version: "0.0.0-experimental.95",
2044
2044
  description: "Django-inspired RSC router with composable URL patterns",
2045
2045
  keywords: [
2046
2046
  "react",
@@ -3376,11 +3376,10 @@ function createVersionInjectorPlugin(rscEntryPath) {
3376
3376
  if (normalizedId !== normalizedEntry) {
3377
3377
  return null;
3378
3378
  }
3379
- const prepend = [];
3379
+ const prepend = [
3380
+ `import "virtual:rsc-router/routes-manifest";`
3381
+ ];
3380
3382
  let newCode = code;
3381
- if (!code.includes("virtual:rsc-router/routes-manifest")) {
3382
- prepend.push(`import "virtual:rsc-router/routes-manifest";`);
3383
- }
3384
3383
  const needsVersion = code.includes("createRSCHandler") && !code.includes("@rangojs/router:version") && /createRSCHandler\s*\(\s*\{/.test(code);
3385
3384
  if (needsVersion) {
3386
3385
  prepend.push(`import { VERSION } from "@rangojs/router:version";`);
@@ -3389,8 +3388,21 @@ function createVersionInjectorPlugin(rscEntryPath) {
3389
3388
  "createRSCHandler({\n version: VERSION,"
3390
3389
  );
3391
3390
  }
3392
- if (prepend.length === 0 && newCode === code) return null;
3393
- newCode = prepend.join("\n") + (prepend.length > 0 ? "\n" : "") + newCode;
3391
+ const lines = newCode.split("\n");
3392
+ let insertAt = 0;
3393
+ while (insertAt < lines.length) {
3394
+ const trimmed = lines[insertAt].trim();
3395
+ if (trimmed === "" || /^\/\/\/\s*<reference\b/.test(trimmed)) {
3396
+ insertAt++;
3397
+ } else {
3398
+ break;
3399
+ }
3400
+ }
3401
+ newCode = [
3402
+ ...lines.slice(0, insertAt),
3403
+ ...prepend,
3404
+ ...lines.slice(insertAt)
3405
+ ].join("\n");
3394
3406
  return {
3395
3407
  code: newCode,
3396
3408
  map: null
@@ -3784,6 +3796,12 @@ function markSelfGenWrite(state, filePath, content) {
3784
3796
  state.selfWrittenGenFiles.set(filePath, { at: Date.now(), hash });
3785
3797
  }
3786
3798
  function consumeSelfGenWrite(state, filePath) {
3799
+ return checkSelfGenWrite(state, filePath, true);
3800
+ }
3801
+ function peekSelfGenWrite(state, filePath) {
3802
+ return checkSelfGenWrite(state, filePath, false);
3803
+ }
3804
+ function checkSelfGenWrite(state, filePath, consume) {
3787
3805
  const info = state.selfWrittenGenFiles.get(filePath);
3788
3806
  if (!info) return false;
3789
3807
  if (Date.now() - info.at > state.SELF_WRITE_WINDOW_MS) {
@@ -3794,7 +3812,7 @@ function consumeSelfGenWrite(state, filePath) {
3794
3812
  const current = readFileSync3(filePath, "utf-8");
3795
3813
  const currentHash = createHash3("sha256").update(current).digest("hex");
3796
3814
  if (currentHash === info.hash) {
3797
- state.selfWrittenGenFiles.delete(filePath);
3815
+ if (consume) state.selfWrittenGenFiles.delete(filePath);
3798
3816
  return true;
3799
3817
  }
3800
3818
  return false;
@@ -5067,6 +5085,80 @@ function postprocessBundle(state) {
5067
5085
  }
5068
5086
  }
5069
5087
 
5088
+ // src/vite/discovery/gate-state.ts
5089
+ function createDiscoveryGate(s, debug11) {
5090
+ let gatePending = false;
5091
+ let gateResolver = () => {
5092
+ };
5093
+ let inProgress = false;
5094
+ let queued = false;
5095
+ let pendingEvents = false;
5096
+ const beginGate = () => {
5097
+ if (gatePending) return;
5098
+ s.discoveryDone = new Promise((resolve10) => {
5099
+ gateResolver = resolve10;
5100
+ });
5101
+ gatePending = true;
5102
+ };
5103
+ const resolveGate = () => {
5104
+ if (!gatePending) return;
5105
+ if (inProgress || queued || pendingEvents) {
5106
+ debug11?.(
5107
+ "hmr: resolveGate deferred \u2014 work in flight (inProgress=%s queued=%s pendingEvents=%s)",
5108
+ inProgress,
5109
+ queued,
5110
+ pendingEvents
5111
+ );
5112
+ return;
5113
+ }
5114
+ gatePending = false;
5115
+ debug11?.("hmr: discoveryDone resolved");
5116
+ gateResolver();
5117
+ };
5118
+ const noteRouteEvent = () => {
5119
+ pendingEvents = true;
5120
+ beginGate();
5121
+ };
5122
+ const runRefreshCycle = async (work) => {
5123
+ if (inProgress) {
5124
+ queued = true;
5125
+ debug11?.("hmr: rediscovery in flight \u2014 queued for a follow-up cycle");
5126
+ return;
5127
+ }
5128
+ pendingEvents = false;
5129
+ inProgress = true;
5130
+ try {
5131
+ await work();
5132
+ } finally {
5133
+ inProgress = false;
5134
+ if (queued) {
5135
+ queued = false;
5136
+ debug11?.("hmr: consuming queued rediscovery");
5137
+ runRefreshCycle(work).catch((err) => {
5138
+ debug11?.(
5139
+ "hmr: queued cycle rejected \u2014 releasing gate (%s)",
5140
+ err instanceof Error ? err.message : String(err)
5141
+ );
5142
+ resolveGate();
5143
+ });
5144
+ } else if (pendingEvents) {
5145
+ debug11?.(
5146
+ "hmr: holding gate for pending events (debounce not yet fired)"
5147
+ );
5148
+ } else {
5149
+ resolveGate();
5150
+ }
5151
+ }
5152
+ };
5153
+ return {
5154
+ beginGate,
5155
+ resolveGate,
5156
+ noteRouteEvent,
5157
+ runRefreshCycle,
5158
+ state: () => ({ gatePending, inProgress, queued, pendingEvents })
5159
+ };
5160
+ }
5161
+
5070
5162
  // src/vite/router-discovery.ts
5071
5163
  var debugDiscovery = createRangoDebugger(NS.discovery);
5072
5164
  var debugRoutes = createRangoDebugger(NS.routes);
@@ -5231,6 +5323,9 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
5231
5323
  const discoveryPromise = new Promise((resolve10) => {
5232
5324
  resolveDiscovery = resolve10;
5233
5325
  });
5326
+ const gate = createDiscoveryGate(s, debugDiscovery);
5327
+ const beginDiscoveryGate = gate.beginGate;
5328
+ const resolveDiscoveryGate = gate.resolveGate;
5234
5329
  const getDevServerOrigin = () => server.resolvedUrls?.local?.[0]?.replace(/\/$/, "") || `http://localhost:${server.config.server.port || 5173}`;
5235
5330
  let prerenderTempServer = null;
5236
5331
  let prerenderNodeRegistry = null;
@@ -5243,35 +5338,157 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
5243
5338
  releaseBuildEnv(s).catch(() => {
5244
5339
  });
5245
5340
  });
5341
+ async function importEntryAndRegistry(tempRscEnv) {
5342
+ const flagAlreadySet = !!globalThis.__rscRouterDiscoveryActive;
5343
+ if (!flagAlreadySet) {
5344
+ globalThis.__rscRouterDiscoveryActive = true;
5345
+ }
5346
+ try {
5347
+ debugDiscovery?.(
5348
+ "importEntryAndRegistry: importing entry (flag=%s)",
5349
+ globalThis.__rscRouterDiscoveryActive ?? false
5350
+ );
5351
+ await tempRscEnv.runner.import(s.resolvedEntryPath);
5352
+ debugDiscovery?.(
5353
+ "importEntryAndRegistry: entry import OK, fetching RouterRegistry"
5354
+ );
5355
+ const serverMod = await tempRscEnv.runner.import(
5356
+ "@rangojs/router/server"
5357
+ );
5358
+ prerenderNodeRegistry = serverMod.RouterRegistry;
5359
+ debugDiscovery?.(
5360
+ "importEntryAndRegistry: registry size=%d",
5361
+ prerenderNodeRegistry?.size ?? 0
5362
+ );
5363
+ } finally {
5364
+ if (!flagAlreadySet) {
5365
+ delete globalThis.__rscRouterDiscoveryActive;
5366
+ debugDiscovery?.(
5367
+ "importEntryAndRegistry: cleared __rscRouterDiscoveryActive"
5368
+ );
5369
+ }
5370
+ }
5371
+ }
5246
5372
  async function getOrCreateTempServer() {
5247
- if (prerenderNodeRegistry) {
5248
- return prerenderTempServer.environments?.rsc ?? null;
5373
+ if (prerenderTempServer) {
5374
+ const existingEnv = prerenderTempServer.environments?.rsc;
5375
+ if (existingEnv?.runner) {
5376
+ if (prerenderNodeRegistry) {
5377
+ debugDiscovery?.(
5378
+ "getOrCreateTempServer: cached temp runner reused"
5379
+ );
5380
+ return existingEnv;
5381
+ }
5382
+ debugDiscovery?.(
5383
+ "getOrCreateTempServer: server alive but registry missing \u2014 re-importing"
5384
+ );
5385
+ try {
5386
+ await importEntryAndRegistry(existingEnv);
5387
+ return existingEnv;
5388
+ } catch (err) {
5389
+ debugDiscovery?.(
5390
+ "getOrCreateTempServer: reuse import failed (%s) \u2014 closing orphan and creating fresh",
5391
+ err?.message ?? String(err)
5392
+ );
5393
+ await prerenderTempServer.close().catch(() => {
5394
+ });
5395
+ prerenderTempServer = null;
5396
+ prerenderNodeRegistry = null;
5397
+ }
5398
+ } else {
5399
+ debugDiscovery?.(
5400
+ "getOrCreateTempServer: existing server has no rsc.runner \u2014 closing and recreating"
5401
+ );
5402
+ await prerenderTempServer.close().catch(() => {
5403
+ });
5404
+ prerenderTempServer = null;
5405
+ prerenderNodeRegistry = null;
5406
+ }
5249
5407
  }
5408
+ debugDiscovery?.(
5409
+ "getOrCreateTempServer: creating new temp server, entry=%s",
5410
+ s.resolvedEntryPath ?? "(unset)"
5411
+ );
5250
5412
  try {
5251
5413
  prerenderTempServer = await createTempRscServer(s, {
5252
5414
  cacheDir: "node_modules/.vite_prerender"
5253
5415
  });
5254
5416
  const tempRscEnv = prerenderTempServer.environments?.rsc;
5255
5417
  if (tempRscEnv?.runner) {
5256
- await tempRscEnv.runner.import(s.resolvedEntryPath);
5257
- const serverMod = await tempRscEnv.runner.import(
5258
- "@rangojs/router/server"
5259
- );
5260
- prerenderNodeRegistry = serverMod.RouterRegistry;
5418
+ await importEntryAndRegistry(tempRscEnv);
5261
5419
  return tempRscEnv;
5262
5420
  }
5421
+ debugDiscovery?.(
5422
+ "getOrCreateTempServer: tempRscEnv.runner unavailable"
5423
+ );
5263
5424
  } catch (err) {
5425
+ debugDiscovery?.(
5426
+ "getOrCreateTempServer: FAILED message=%s",
5427
+ err.message
5428
+ );
5264
5429
  console.warn(
5265
5430
  `[rsc-router] Failed to create temp runner: ${err.message}`
5266
5431
  );
5267
5432
  }
5268
5433
  return null;
5269
5434
  }
5435
+ async function clearTempRegistries(tempRscEnv) {
5436
+ try {
5437
+ const serverMod = await tempRscEnv.runner.import(
5438
+ "@rangojs/router/server"
5439
+ );
5440
+ if (typeof serverMod?.RouterRegistry?.clear === "function") {
5441
+ serverMod.RouterRegistry.clear();
5442
+ }
5443
+ if (typeof serverMod?.HostRouterRegistry?.clear === "function") {
5444
+ serverMod.HostRouterRegistry.clear();
5445
+ }
5446
+ debugDiscovery?.(
5447
+ "clearTempRegistries: cleared RouterRegistry + HostRouterRegistry"
5448
+ );
5449
+ } catch (err) {
5450
+ debugDiscovery?.(
5451
+ "clearTempRegistries: import @rangojs/router/server failed (%s)",
5452
+ err?.message ?? String(err)
5453
+ );
5454
+ }
5455
+ }
5456
+ async function refreshTempRscEnv() {
5457
+ let tempRscEnv = await getOrCreateTempServer();
5458
+ if (!tempRscEnv) return null;
5459
+ const envGraph = tempRscEnv.moduleGraph;
5460
+ const serverGraph = prerenderTempServer?.moduleGraph;
5461
+ const target = envGraph?.invalidateAll ? envGraph : serverGraph?.invalidateAll ? serverGraph : null;
5462
+ if (!target) {
5463
+ debugDiscovery?.(
5464
+ "refreshTempRscEnv: invalidateAll unavailable on env+server graphs, falling back to close+recreate"
5465
+ );
5466
+ if (prerenderTempServer) {
5467
+ await prerenderTempServer.close().catch(() => {
5468
+ });
5469
+ prerenderTempServer = null;
5470
+ prerenderNodeRegistry = null;
5471
+ }
5472
+ return await getOrCreateTempServer();
5473
+ }
5474
+ debugDiscovery?.(
5475
+ "refreshTempRscEnv: invalidating module graph (%s)",
5476
+ envGraph?.invalidateAll ? "env" : "server"
5477
+ );
5478
+ target.invalidateAll();
5479
+ prerenderNodeRegistry = null;
5480
+ await clearTempRegistries(tempRscEnv);
5481
+ await importEntryAndRegistry(tempRscEnv);
5482
+ return tempRscEnv;
5483
+ }
5270
5484
  const discover = async () => {
5271
5485
  const discoverStart = performance.now();
5272
5486
  const rscEnv = server.environments?.rsc;
5273
5487
  if (!rscEnv?.runner) {
5274
- debugDiscovery?.("dev: no rsc runner (cloudflare path)");
5488
+ debugDiscovery?.(
5489
+ "dev: cloudflare path start, __rscRouterDiscoveryActive=%s",
5490
+ globalThis.__rscRouterDiscoveryActive ?? false
5491
+ );
5275
5492
  s.devServerOrigin = getDevServerOrigin();
5276
5493
  try {
5277
5494
  await timed(
@@ -5353,9 +5570,11 @@ ${err.stack}`
5353
5570
  resolveDiscovery();
5354
5571
  }
5355
5572
  };
5356
- s.discoveryDone = new Promise((resolve10) => {
5357
- setTimeout(() => discover().then(resolve10, resolve10), 0);
5358
- });
5573
+ beginDiscoveryGate();
5574
+ setTimeout(
5575
+ () => discover().then(resolveDiscoveryGate, resolveDiscoveryGate),
5576
+ 0
5577
+ );
5359
5578
  let mainRegistry = null;
5360
5579
  const propagateDiscoveryState = async (rscEnv) => {
5361
5580
  const serverMod = await rscEnv.runner.import("@rangojs/router/server");
@@ -5504,49 +5723,80 @@ ${err.stack}`
5504
5723
  return true;
5505
5724
  };
5506
5725
  let routeChangeTimer;
5507
- let runtimeRediscoveryInProgress = false;
5508
5726
  const refreshRuntimeDiscovery = async () => {
5509
5727
  const rscEnv = server.environments?.rsc;
5510
- if (!rscEnv?.runner || runtimeRediscoveryInProgress) return;
5511
- runtimeRediscoveryInProgress = true;
5512
- const hmrStart = performance.now();
5513
- try {
5514
- await timed(
5515
- debugDiscovery,
5516
- "hmr discoverRouters",
5517
- () => discoverRouters(s, rscEnv)
5518
- );
5519
- timedSync(
5520
- debugDiscovery,
5521
- "hmr writeRouteTypesFiles",
5522
- () => writeRouteTypesFiles(s)
5523
- );
5524
- await timed(
5525
- debugDiscovery,
5526
- "hmr propagateDiscoveryState",
5527
- () => propagateDiscoveryState(rscEnv)
5528
- );
5529
- } catch (err) {
5530
- console.warn(
5531
- `[rsc-router] Runtime re-discovery failed: ${err.message}`
5532
- );
5533
- } finally {
5534
- debugDiscovery?.(
5535
- "hmr re-discovery done (%sms)",
5536
- (performance.now() - hmrStart).toFixed(1)
5537
- );
5538
- runtimeRediscoveryInProgress = false;
5539
- }
5728
+ const hasMainRunner = !!rscEnv?.runner;
5729
+ if (!hasMainRunner && s.perRouterManifests.length === 0) return;
5730
+ await gate.runRefreshCycle(async () => {
5731
+ const hmrStart = performance.now();
5732
+ try {
5733
+ if (hasMainRunner) {
5734
+ await timed(
5735
+ debugDiscovery,
5736
+ "hmr discoverRouters",
5737
+ () => discoverRouters(s, rscEnv)
5738
+ );
5739
+ timedSync(
5740
+ debugDiscovery,
5741
+ "hmr writeRouteTypesFiles",
5742
+ () => writeRouteTypesFiles(s)
5743
+ );
5744
+ await timed(
5745
+ debugDiscovery,
5746
+ "hmr propagateDiscoveryState",
5747
+ () => propagateDiscoveryState(rscEnv)
5748
+ );
5749
+ } else {
5750
+ const tempRscEnv = await timed(
5751
+ debugDiscovery,
5752
+ "hmr refreshTempRscEnv (cloudflare)",
5753
+ () => refreshTempRscEnv()
5754
+ );
5755
+ if (!tempRscEnv) {
5756
+ throw new Error(
5757
+ "temp runner unavailable for cloudflare HMR rediscovery"
5758
+ );
5759
+ }
5760
+ await timed(
5761
+ debugDiscovery,
5762
+ "hmr discoverRouters (cloudflare)",
5763
+ () => discoverRouters(s, tempRscEnv)
5764
+ );
5765
+ timedSync(
5766
+ debugDiscovery,
5767
+ "hmr writeRouteTypesFiles",
5768
+ () => writeRouteTypesFiles(s)
5769
+ );
5770
+ }
5771
+ } catch (err) {
5772
+ console.warn(
5773
+ `[rsc-router] Runtime re-discovery failed: ${err.message}`
5774
+ );
5775
+ } finally {
5776
+ debugDiscovery?.(
5777
+ "hmr re-discovery done (%sms)",
5778
+ (performance.now() - hmrStart).toFixed(1)
5779
+ );
5780
+ }
5781
+ });
5540
5782
  };
5541
5783
  const scheduleRouteRegeneration = () => {
5542
5784
  clearTimeout(routeChangeTimer);
5543
5785
  routeChangeTimer = setTimeout(() => {
5544
5786
  routeChangeTimer = void 0;
5545
5787
  const regenStart = debugDiscovery ? performance.now() : 0;
5788
+ const rscEnv = server.environments?.rsc;
5789
+ const skipStaticWrite = !rscEnv?.runner && s.perRouterManifests.length > 0;
5546
5790
  try {
5547
- writeCombinedRouteTypesWithTracking(s);
5548
- if (s.perRouterManifests.length > 0) {
5549
- supplementGenFilesWithRuntimeRoutes(s);
5791
+ if (skipStaticWrite) {
5792
+ debugDiscovery?.(
5793
+ "watcher: skipping static write (cloudflare HMR \u2014 runtime rediscovery owns gen file)"
5794
+ );
5795
+ } else {
5796
+ writeCombinedRouteTypesWithTracking(s);
5797
+ if (s.perRouterManifests.length > 0) {
5798
+ supplementGenFilesWithRuntimeRoutes(s);
5799
+ }
5550
5800
  }
5551
5801
  } catch (err) {
5552
5802
  console.error(
@@ -5562,6 +5812,7 @@ ${err.stack}`
5562
5812
  console.warn(
5563
5813
  `[rsc-router] Runtime re-discovery error: ${err.message}`
5564
5814
  );
5815
+ resolveDiscoveryGate();
5565
5816
  });
5566
5817
  }
5567
5818
  }, 100);
@@ -5598,6 +5849,9 @@ ${err.stack}`
5598
5849
  }
5599
5850
  s.cachedRouterFiles = void 0;
5600
5851
  }
5852
+ if (s.perRouterManifests.length > 0) {
5853
+ gate.noteRouteEvent();
5854
+ }
5601
5855
  scheduleRouteRegeneration();
5602
5856
  } catch {
5603
5857
  }
@@ -5696,6 +5950,34 @@ ${details}`
5696
5950
  );
5697
5951
  }
5698
5952
  },
5953
+ // Suppress vite's HMR cascade for our own gen-file writes.
5954
+ //
5955
+ // After every cf HMR cycle, refreshTempRscEnv → writeRouteTypesFiles
5956
+ // writes the configured gen files (default `router.named-routes.gen.ts`,
5957
+ // but the source filenames and gen suffix are user-configurable). The
5958
+ // chokidar watcher then fires twice independently: our
5959
+ // `handleRouteFileChange` (already short-circuited by
5960
+ // `consumeSelfGenWrite` inside `maybeHandleGeneratedRouteFileMutation`),
5961
+ // AND vite's own HMR pipeline (which invalidates the gen file's
5962
+ // importers and triggers a second workerd full reload — visible to the
5963
+ // user as a duplicate "[RSCRouter] HMR: version changed" on the client).
5964
+ //
5965
+ // `peekSelfGenWrite` is the authoritative filter: its map only contains
5966
+ // paths that `markSelfGenWrite` has registered, so it natively works
5967
+ // for any configured gen-file name. It is non-consuming so the chokidar
5968
+ // handler that fires later can still consume the same entry. Returning
5969
+ // [] tells vite "no modules invalidated by this change" — safe because
5970
+ // `s.perRouterManifests` is already up-to-date (the write that just
5971
+ // happened is the consequence of our just-completed rediscovery).
5972
+ handleHotUpdate(ctx) {
5973
+ if (peekSelfGenWrite(s, ctx.file)) {
5974
+ debugDiscovery?.(
5975
+ "handleHotUpdate: suppressing self-write HMR cascade for %s",
5976
+ ctx.file
5977
+ );
5978
+ return [];
5979
+ }
5980
+ },
5699
5981
  // Virtual module: provides the pre-generated route manifest as a JS module
5700
5982
  // that calls setCachedManifest() at import time.
5701
5983
  resolveId(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.92",
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
  });