@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.
@@ -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
@@ -0,0 +1,171 @@
1
+ import type { Debugger } from "../debug.js";
2
+
3
+ /**
4
+ * Manifest-readiness gate + rediscovery scheduler.
5
+ *
6
+ * Owns the four pieces of state that cooperate to keep
7
+ * `s.discoveryDone` (the promise the manifest virtual module's `load()`
8
+ * hook awaits) consistent across HMR fan-out:
9
+ *
10
+ * - **gatePending**: a Promise has been issued and not yet resolved.
11
+ * Workerd's manifest virtual module load() is blocked on it.
12
+ * - **inProgress**: a refresh's work callback is currently executing.
13
+ * - **queued**: a refresh was attempted while one was already in
14
+ * flight; the active run consumes this in its `finally` and
15
+ * recurses.
16
+ * - **pendingEvents**: a route-file event has been received (gate
17
+ * already reset) but the corresponding refresh's work hasn't started
18
+ * yet — i.e. the debounce hasn't fired. Set in `noteRouteEvent`,
19
+ * cleared at the start of each refresh cycle. Refresh's finally MUST
20
+ * hold the gate if this is true even when `queued` is false,
21
+ * otherwise an event whose debounce fires AFTER the active refresh
22
+ * completes (the "tail-race" window) would observe a resolved gate.
23
+ *
24
+ * The HMR-event flow (cloudflare-stress repro):
25
+ *
26
+ * t=0 Touch 1 → noteRouteEvent → pendingEvents=true, beginGate
27
+ * (gate1 pending)
28
+ * → debounce 100ms
29
+ * t=100 runRefreshCycle(work) → clear pendingEvents, work starts
30
+ * t=750 Touch 2 → noteRouteEvent → pendingEvents=true (no-op gate)
31
+ * → debounce fires at t=850
32
+ * t=800 refresh A's finally → queued=false, pendingEvents=true
33
+ * → HOLD gate (don't resolve)
34
+ * t=850 runRefreshCycle (debounce) → clear pendingEvents, work starts
35
+ * t=1500 refresh B's finally → queued=false, pendingEvents=false
36
+ * → resolveGate (gate1 resolves)
37
+ *
38
+ * @internal Exported only for unit tests.
39
+ */
40
+ export interface DiscoveryGate {
41
+ /**
42
+ * Reset the gate to a fresh pending Promise via `s.discoveryDone`.
43
+ * No-op when a gate is already pending — file watchers can fire
44
+ * multiple events for one save, and replacing the resolver would
45
+ * orphan the original promise (workerd's manifest load() would hang).
46
+ */
47
+ beginGate(): void;
48
+ /**
49
+ * Resolve the current pending gate. No-op when no gate is pending.
50
+ * Called at the tail of the last refresh cycle in a burst.
51
+ */
52
+ resolveGate(): void;
53
+ /**
54
+ * Record that a route-file event has arrived. Sets `pendingEvents`
55
+ * and begins the gate. Idempotent for both flags.
56
+ */
57
+ noteRouteEvent(): void;
58
+ /**
59
+ * Run one refresh cycle, managing queue + pending state around it.
60
+ * If a cycle is already in flight, sets `queued=true` and returns.
61
+ * Otherwise clears `pendingEvents`, runs `work`, and in `finally`:
62
+ *
63
+ * - queued → recurse, gate stays pending
64
+ * - pendingEvents → hold gate (next debounced cycle resolves)
65
+ * - neither → resolveGate
66
+ */
67
+ runRefreshCycle(work: () => Promise<void>): Promise<void>;
68
+ /** Snapshot of internal state. Test-only. */
69
+ readonly state: () => Readonly<{
70
+ gatePending: boolean;
71
+ inProgress: boolean;
72
+ queued: boolean;
73
+ pendingEvents: boolean;
74
+ }>;
75
+ }
76
+
77
+ /** State container the gate writes `discoveryDone` into. */
78
+ export interface GateOwner {
79
+ discoveryDone: Promise<void> | null | undefined;
80
+ }
81
+
82
+ export function createDiscoveryGate(
83
+ s: GateOwner,
84
+ debug?: Debugger,
85
+ ): DiscoveryGate {
86
+ let gatePending = false;
87
+ let gateResolver: () => void = () => {};
88
+ let inProgress = false;
89
+ let queued = false;
90
+ let pendingEvents = false;
91
+
92
+ const beginGate = (): void => {
93
+ if (gatePending) return;
94
+ s.discoveryDone = new Promise<void>((resolve) => {
95
+ gateResolver = resolve;
96
+ });
97
+ gatePending = true;
98
+ };
99
+
100
+ const resolveGate = (): void => {
101
+ if (!gatePending) return;
102
+ // Defer resolution while a refresh cycle is in flight or queued, or
103
+ // while an unprocessed route-file event is pending its debounce.
104
+ // Without this guard, cold-start's `discover().then(resolveGate)`
105
+ // could fire while an HMR-triggered runRefreshCycle is mid-flight,
106
+ // prematurely unblocking workerd's manifest load() against the
107
+ // stale cold-start gen. The active cycle's `finally` calls
108
+ // resolveGate again at the tail and finishes the resolution then.
109
+ if (inProgress || queued || pendingEvents) {
110
+ debug?.(
111
+ "hmr: resolveGate deferred — work in flight (inProgress=%s queued=%s pendingEvents=%s)",
112
+ inProgress,
113
+ queued,
114
+ pendingEvents,
115
+ );
116
+ return;
117
+ }
118
+ gatePending = false;
119
+ debug?.("hmr: discoveryDone resolved");
120
+ gateResolver();
121
+ };
122
+
123
+ const noteRouteEvent = (): void => {
124
+ pendingEvents = true;
125
+ beginGate();
126
+ };
127
+
128
+ const runRefreshCycle = async (work: () => Promise<void>): Promise<void> => {
129
+ if (inProgress) {
130
+ queued = true;
131
+ debug?.("hmr: rediscovery in flight — queued for a follow-up cycle");
132
+ return;
133
+ }
134
+ // Snapshot the current pendingEvents into "we're about to process";
135
+ // events arriving from now on re-set it.
136
+ pendingEvents = false;
137
+ inProgress = true;
138
+ try {
139
+ await work();
140
+ } finally {
141
+ inProgress = false;
142
+ if (queued) {
143
+ queued = false;
144
+ debug?.("hmr: consuming queued rediscovery");
145
+ runRefreshCycle(work).catch((err: unknown) => {
146
+ debug?.(
147
+ "hmr: queued cycle rejected — releasing gate (%s)",
148
+ err instanceof Error ? err.message : String(err),
149
+ );
150
+ // Belt-and-suspenders: even if the queued cycle's own try/catch
151
+ // missed something, ensure workerd doesn't hang.
152
+ resolveGate();
153
+ });
154
+ } else if (pendingEvents) {
155
+ debug?.(
156
+ "hmr: holding gate for pending events (debounce not yet fired)",
157
+ );
158
+ } else {
159
+ resolveGate();
160
+ }
161
+ }
162
+ };
163
+
164
+ return {
165
+ beginGate,
166
+ resolveGate,
167
+ noteRouteEvent,
168
+ runRefreshCycle,
169
+ state: () => ({ gatePending, inProgress, queued, pendingEvents }),
170
+ };
171
+ }