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

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.91",
2043
+ version: "0.0.0-experimental.93",
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.91",
3
+ "version": "0.0.0-experimental.93",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -248,6 +248,37 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
248
248
  ]);
249
249
  ```
250
250
 
251
+ ### `revalidate()` return shapes
252
+
253
+ A `revalidate(fn)` callback can return one of four shapes. The chain
254
+ processes revalidators in order; each call's return controls how the
255
+ chain continues:
256
+
257
+ ```typescript
258
+ // 1) Hard decision — short-circuits the chain, used as the final answer.
259
+ revalidate(() => true);
260
+ revalidate(({ actionId }) => actionId?.includes("Cart") ?? false);
261
+
262
+ // 2) Soft decision — updates the running suggestion for downstream
263
+ // revalidators on the same segment, chain continues.
264
+ revalidate(({ defaultShouldRevalidate }) => ({
265
+ defaultShouldRevalidate: !defaultShouldRevalidate,
266
+ }));
267
+
268
+ // 3) Defer (no opinion) — leaves the running suggestion unchanged and
269
+ // continues to the next revalidator. Implicit return / null /
270
+ // undefined are all equivalent and consumer-friendly.
271
+ revalidate(({ actionId }) => {
272
+ if (actionId?.includes("Cart")) return true; // hard for this branch only
273
+ // implicit return — let downstream revalidators or the segment default decide
274
+ });
275
+ revalidate(() => undefined); // explicit defer
276
+ revalidate(() => null); // explicit defer
277
+ ```
278
+
279
+ If every revalidator on a segment defers, the segment-type default
280
+ (e.g. params-changed for routes, `false` for parallels) is used.
281
+
251
282
  ### Revalidation Contracts for Loader Dependencies
252
283
 
253
284
  If a loader reads `ctx.get()` data produced by an outer handler/layout, share
@@ -347,6 +347,13 @@ Revalidating only the parallel does not re-run outer handlers/layouts.
347
347
  If the slot reads `ctx.get()` data established above it, opt the outer
348
348
  segment into revalidation as well.
349
349
 
350
+ A `revalidate()` callback may return a hard `boolean`, a soft
351
+ `{ defaultShouldRevalidate }` object, or nothing (`void` / `null` /
352
+ `undefined`) to defer to the next revalidator. See
353
+ [loader/SKILL.md#revalidate-return-shapes](../loader/SKILL.md#revalidate-return-shapes)
354
+ for the full contract — it's the same across `loader()`, `path()`,
355
+ `layout()`, `parallel()`, and `intercept()`.
356
+
350
357
  ### Revalidation Contracts for Parallel Dependencies
351
358
 
352
359
  Prefer named revalidation contracts shared by both the upstream producer and
@@ -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
  /**
@@ -471,13 +471,16 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
471
471
  * **Return Types:**
472
472
  * - `boolean` - Hard decision: immediately returns this value (short-circuits)
473
473
  * - `{ defaultShouldRevalidate: boolean }` - Soft decision: updates suggestion for next revalidator
474
+ * - `void` / `null` / `undefined` - Defer to the current suggestion (no opinion); the
475
+ * loop continues to the next revalidator without changing the running default
474
476
  *
475
477
  * **Execution Flow:**
476
478
  * 1. Start with built-in `defaultShouldRevalidate` (true if params changed)
477
479
  * 2. Execute global revalidators first, then route-specific
478
480
  * 3. Hard decision (boolean): stop immediately and use that value
479
481
  * 4. Soft decision (object): update suggestion and continue to next revalidator
480
- * 5. If all return soft decisions: use the final suggestion
482
+ * 5. Defer (`void` / `null` / `undefined`): leave suggestion unchanged and continue
483
+ * 6. If no hard decision was returned: use the final running suggestion
481
484
  *
482
485
  * @param args.currentParams - Previous route params (generic by default, can be narrowed)
483
486
  * @param args.currentUrl - Previous URL
@@ -489,7 +492,8 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
489
492
  * @param args.formData - Form data from action (future support)
490
493
  * @param args.formMethod - HTTP method from action (future support)
491
494
  *
492
- * @returns Hard decision (boolean) or soft suggestion (object)
495
+ * @returns Hard decision (boolean), soft suggestion (object), or defer
496
+ * (`void` / `null` / `undefined`) to keep the running suggestion as-is.
493
497
  *
494
498
  * @example
495
499
  * ```typescript
@@ -514,8 +518,9 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
514
518
  * a segment (layout, route, parallel slot, or loader) should be re-rendered.
515
519
  *
516
520
  * Return `true` to re-render, `false` to skip (keep client's current version),
517
- * or `{ defaultShouldRevalidate: boolean }` to override the default for
518
- * downstream segments.
521
+ * `{ defaultShouldRevalidate: boolean }` to update the running suggestion for
522
+ * downstream revalidators, or nothing (`void` / `null` / `undefined`) to defer
523
+ * to the current suggestion without changing it.
519
524
  *
520
525
  * @example
521
526
  * ```ts
@@ -615,7 +620,7 @@ export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
615
620
  * action that may have mutated backend state.
616
621
  */
617
622
  stale?: boolean;
618
- }) => boolean | { defaultShouldRevalidate: boolean };
623
+ }) => boolean | { defaultShouldRevalidate: boolean } | null | void;
619
624
 
620
625
  // MiddlewareFn is imported from "../router/middleware.js" and re-exported
621
626