@rangojs/router 0.0.0-experimental.66 → 0.0.0-experimental.66cdebe3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +112 -17
  2. package/dist/vite/index.js +1462 -422
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +7 -5
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/handler-use/SKILL.md +364 -0
  7. package/skills/hooks/SKILL.md +54 -20
  8. package/skills/i18n/SKILL.md +276 -0
  9. package/skills/intercept/SKILL.md +45 -0
  10. package/skills/layout/SKILL.md +24 -0
  11. package/skills/links/SKILL.md +234 -16
  12. package/skills/loader/SKILL.md +70 -3
  13. package/skills/middleware/SKILL.md +34 -3
  14. package/skills/migrate-nextjs/SKILL.md +562 -0
  15. package/skills/migrate-react-router/SKILL.md +769 -0
  16. package/skills/parallel/SKILL.md +68 -0
  17. package/skills/rango/SKILL.md +26 -22
  18. package/skills/response-routes/SKILL.md +8 -0
  19. package/skills/route/SKILL.md +48 -0
  20. package/skills/server-actions/SKILL.md +739 -0
  21. package/skills/streams-and-websockets/SKILL.md +283 -0
  22. package/skills/typesafety/SKILL.md +9 -1
  23. package/skills/view-transitions/SKILL.md +212 -0
  24. package/src/browser/app-shell.ts +52 -0
  25. package/src/browser/event-controller.ts +44 -4
  26. package/src/browser/navigation-bridge.ts +151 -9
  27. package/src/browser/navigation-client.ts +64 -13
  28. package/src/browser/navigation-store.ts +25 -1
  29. package/src/browser/partial-update.ts +58 -12
  30. package/src/browser/prefetch/cache.ts +129 -21
  31. package/src/browser/prefetch/fetch.ts +148 -16
  32. package/src/browser/prefetch/queue.ts +36 -5
  33. package/src/browser/rango-state.ts +53 -13
  34. package/src/browser/react/Link.tsx +30 -2
  35. package/src/browser/react/NavigationProvider.tsx +95 -44
  36. package/src/browser/react/filter-segment-order.ts +51 -7
  37. package/src/browser/react/index.ts +3 -0
  38. package/src/browser/react/use-navigation.ts +22 -2
  39. package/src/browser/react/use-params.ts +17 -4
  40. package/src/browser/react/use-reverse.ts +99 -0
  41. package/src/browser/react/use-router.ts +8 -1
  42. package/src/browser/react/use-segments.ts +11 -8
  43. package/src/browser/rsc-router.tsx +34 -6
  44. package/src/browser/scroll-restoration.ts +69 -28
  45. package/src/browser/segment-reconciler.ts +36 -14
  46. package/src/browser/types.ts +19 -0
  47. package/src/build/route-trie.ts +52 -25
  48. package/src/cache/cf/cf-cache-store.ts +5 -7
  49. package/src/client.rsc.tsx +3 -0
  50. package/src/client.tsx +87 -175
  51. package/src/href-client.ts +4 -1
  52. package/src/index.rsc.ts +3 -0
  53. package/src/index.ts +44 -9
  54. package/src/outlet-context.ts +1 -1
  55. package/src/response-utils.ts +28 -0
  56. package/src/reverse.ts +62 -36
  57. package/src/route-definition/dsl-helpers.ts +175 -23
  58. package/src/route-definition/helpers-types.ts +63 -14
  59. package/src/route-definition/resolve-handler-use.ts +6 -0
  60. package/src/route-types.ts +7 -0
  61. package/src/router/handler-context.ts +21 -38
  62. package/src/router/lazy-includes.ts +6 -6
  63. package/src/router/loader-resolution.ts +3 -0
  64. package/src/router/manifest.ts +22 -13
  65. package/src/router/match-api.ts +4 -3
  66. package/src/router/match-handlers.ts +1 -0
  67. package/src/router/match-middleware/cache-lookup.ts +2 -1
  68. package/src/router/match-result.ts +101 -4
  69. package/src/router/middleware-types.ts +14 -25
  70. package/src/router/middleware.ts +54 -7
  71. package/src/router/pattern-matching.ts +101 -17
  72. package/src/router/revalidation.ts +15 -1
  73. package/src/router/segment-resolution/fresh.ts +13 -0
  74. package/src/router/segment-resolution/revalidation.ts +135 -101
  75. package/src/router/substitute-pattern-params.ts +56 -0
  76. package/src/router/trie-matching.ts +18 -13
  77. package/src/router/url-params.ts +49 -0
  78. package/src/router.ts +1 -2
  79. package/src/rsc/handler.ts +16 -8
  80. package/src/rsc/helpers.ts +69 -41
  81. package/src/rsc/progressive-enhancement.ts +4 -0
  82. package/src/rsc/response-route-handler.ts +14 -1
  83. package/src/rsc/rsc-rendering.ts +10 -0
  84. package/src/rsc/server-action.ts +4 -0
  85. package/src/rsc/types.ts +6 -0
  86. package/src/segment-content-promise.ts +67 -0
  87. package/src/segment-loader-promise.ts +122 -0
  88. package/src/segment-system.tsx +71 -70
  89. package/src/server/context.ts +26 -3
  90. package/src/server/request-context.ts +10 -42
  91. package/src/ssr/index.tsx +5 -1
  92. package/src/types/handler-context.ts +12 -39
  93. package/src/types/loader-types.ts +5 -6
  94. package/src/types/request-scope.ts +126 -0
  95. package/src/types/route-entry.ts +11 -0
  96. package/src/types/segments.ts +18 -1
  97. package/src/urls/include-helper.ts +24 -14
  98. package/src/urls/path-helper-types.ts +30 -4
  99. package/src/urls/response-types.ts +2 -10
  100. package/src/use-loader.tsx +4 -1
  101. package/src/vite/debug.ts +184 -0
  102. package/src/vite/discovery/discover-routers.ts +31 -3
  103. package/src/vite/discovery/gate-state.ts +171 -0
  104. package/src/vite/discovery/prerender-collection.ts +172 -84
  105. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  106. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  107. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  108. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  109. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  110. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  111. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  112. package/src/vite/plugins/expose-action-id.ts +52 -28
  113. package/src/vite/plugins/expose-id-utils.ts +12 -0
  114. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  115. package/src/vite/plugins/expose-internal-ids.ts +545 -304
  116. package/src/vite/plugins/performance-tracks.ts +17 -9
  117. package/src/vite/plugins/use-cache-transform.ts +56 -43
  118. package/src/vite/plugins/version-injector.ts +37 -11
  119. package/src/vite/rango.ts +49 -14
  120. package/src/vite/router-discovery.ts +558 -53
  121. package/src/vite/utils/banner.ts +1 -1
  122. package/src/vite/utils/package-resolution.ts +41 -1
  123. package/src/vite/utils/prerender-utils.ts +21 -6
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  isValidElement,
5
+ startTransition,
5
6
  useCallback,
6
7
  useContext,
7
8
  useEffect,
@@ -284,7 +285,9 @@ function useLoaderInternal<T>(
284
285
 
285
286
  const result = payload.loaderResult;
286
287
  if (requestId === requestIdRef.current) {
287
- setFetchedData(result);
288
+ startTransition(() => {
289
+ setFetchedData(result);
290
+ });
288
291
  }
289
292
  return result;
290
293
  } catch (e) {
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Debug logging for the Rango Vite plugin.
3
+ *
4
+ * Thin wrapper over the `debug` package (the same one Vite uses for its
5
+ * own `vite:*` namespaces). Enable with either:
6
+ *
7
+ * DEBUG='rango:*' vite dev
8
+ * vite --debug rango:* # vite prepends `vite:`, we bridge it
9
+ *
10
+ * Returns `undefined` when no matching namespace is enabled, so call sites
11
+ * can guard expensive diagnostics with a simple truthiness check:
12
+ *
13
+ * const debug = createRangoDebugger(NS.routes);
14
+ * if (debug) debug("built manifest (%d routes) in %dms", n, ms);
15
+ *
16
+ * Back-compat: INTERNAL_RANGO_DEBUG=1 still enables all rango namespaces.
17
+ *
18
+ * Vite CLI note: `vite --debug <feat>` rewrites to `DEBUG=vite:<feat>` — it
19
+ * always prefixes with `vite:` and cannot enable bare `rango:*` namespaces.
20
+ * We work around this by registering a shadow `vite:rango:*` instance for
21
+ * each debugger, so either invocation works.
22
+ */
23
+
24
+ import debugFactory from "debug";
25
+
26
+ /**
27
+ * Canonical debug namespaces. Import as `NS.xxx` instead of string literals
28
+ * so typos become type errors and the full set lives in one place.
29
+ */
30
+ export const NS = {
31
+ config: "rango:config",
32
+ discovery: "rango:discovery",
33
+ routes: "rango:routes",
34
+ prerender: "rango:prerender",
35
+ build: "rango:build",
36
+ dev: "rango:dev",
37
+ transform: "rango:transform",
38
+ } as const;
39
+
40
+ // Back-compat: the legacy INTERNAL_RANGO_DEBUG env var enabled per-site
41
+ // console.logs in this plugin. Map it to `rango:*` so those call sites can
42
+ // be migrated to the `debug` pipeline without breaking existing setups.
43
+ // Uses debug.enable() rather than mutating process.env because the `debug`
44
+ // package already snapshotted DEBUG when it was imported above.
45
+ if (process.env.INTERNAL_RANGO_DEBUG) {
46
+ const existing = debugFactory.disable();
47
+ debugFactory.enable(existing ? `${existing},rango:*` : "rango:*");
48
+ }
49
+
50
+ export type Debugger = (formatter: string, ...args: unknown[]) => void;
51
+
52
+ export function createRangoDebugger(namespace: string): Debugger | undefined {
53
+ const primary = debugFactory(namespace);
54
+ // Shadow namespace so `vite --debug rango:*` (which expands to
55
+ // DEBUG=vite:rango:*) and `vite --debug` (DEBUG=vite:*) both pick us up.
56
+ const shadow = debugFactory(`vite:${namespace}`);
57
+ if (primary.enabled) return primary as Debugger;
58
+ if (shadow.enabled) return shadow as Debugger;
59
+ return undefined;
60
+ }
61
+
62
+ /**
63
+ * Measure an async block and log its duration via `debug`. No-ops (still
64
+ * runs `fn`) when the namespace is disabled, so production cost is a single
65
+ * `.enabled` check per call.
66
+ *
67
+ * await timed(debug, "discover routers", () => discoverRouters(state));
68
+ */
69
+ export async function timed<T>(
70
+ debug: Debugger | undefined,
71
+ label: string,
72
+ fn: () => T | Promise<T>,
73
+ ): Promise<T> {
74
+ if (!debug) return await fn();
75
+ const start = performance.now();
76
+ try {
77
+ return await fn();
78
+ } finally {
79
+ debug("%s (%sms)", label, (performance.now() - start).toFixed(1));
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Synchronous variant of `timed`. Use for sync call sites — wrapping them
85
+ * with the async `timed` would create a floating promise that discards any
86
+ * throw, bypassing the surrounding try/catch.
87
+ */
88
+ export function timedSync<T>(
89
+ debug: Debugger | undefined,
90
+ label: string,
91
+ fn: () => T,
92
+ ): T {
93
+ if (!debug) return fn();
94
+ const start = performance.now();
95
+ try {
96
+ return fn();
97
+ } finally {
98
+ debug("%s (%sms)", label, (performance.now() - start).toFixed(1));
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Aggregate counter for high-frequency call sites (typically Vite
104
+ * `transform` hooks that run on many files). Per-call logging would
105
+ * drown real signal; this collects totals and reports once on flush.
106
+ *
107
+ * const counter = createCounter(debug, "use-cache-transform");
108
+ * // inside transform():
109
+ * return counter?.time(id, () => doWork()) ?? doWork();
110
+ * // or manually:
111
+ * counter?.record(id, ms);
112
+ * // flush on buildEnd (counter resets, so multi-env builds each get
113
+ * // their own summary line):
114
+ * counter?.flush();
115
+ *
116
+ * Returns `undefined` when the namespace is disabled so call sites pay
117
+ * nothing when off.
118
+ */
119
+ export interface Counter {
120
+ record(file: string, ms: number): void;
121
+ /**
122
+ * Convenience: time a sync or async block and record it. Propagates
123
+ * throws; records regardless of outcome. Returns the function's result.
124
+ */
125
+ time<T>(file: string, fn: () => T): T;
126
+ time<T>(file: string, fn: () => Promise<T>): Promise<T>;
127
+ flush(): void;
128
+ }
129
+
130
+ export function createCounter(
131
+ debug: Debugger | undefined,
132
+ label: string,
133
+ ): Counter | undefined {
134
+ if (!debug) return undefined;
135
+ let n = 0;
136
+ let totalMs = 0;
137
+ let slowestMs = 0;
138
+ let slowestFile = "";
139
+ const record = (file: string, ms: number): void => {
140
+ n++;
141
+ totalMs += ms;
142
+ if (ms > slowestMs) {
143
+ slowestMs = ms;
144
+ slowestFile = file;
145
+ }
146
+ };
147
+ return {
148
+ record,
149
+ time<T>(file: string, fn: () => T | Promise<T>): T | Promise<T> {
150
+ const start = performance.now();
151
+ let out: T | Promise<T>;
152
+ try {
153
+ out = fn();
154
+ } catch (err) {
155
+ record(file, performance.now() - start);
156
+ throw err;
157
+ }
158
+ if (out && typeof (out as any).then === "function") {
159
+ return (out as Promise<T>).finally(() =>
160
+ record(file, performance.now() - start),
161
+ );
162
+ }
163
+ record(file, performance.now() - start);
164
+ return out;
165
+ },
166
+ flush(): void {
167
+ if (n === 0) return;
168
+ debug(
169
+ "%s: %d files, %sms total, slowest %sms %s",
170
+ label,
171
+ n,
172
+ totalMs.toFixed(1),
173
+ slowestMs.toFixed(1),
174
+ slowestFile,
175
+ );
176
+ // Reset so buildEnd firing once per environment (Vite 6+ multi-env)
177
+ // gives one log line per env rather than silently dropping later data.
178
+ n = 0;
179
+ totalMs = 0;
180
+ slowestMs = 0;
181
+ slowestFile = "";
182
+ },
183
+ };
184
+ }
@@ -20,6 +20,9 @@ import {
20
20
  expandPrerenderRoutes,
21
21
  renderStaticHandlers,
22
22
  } from "./prerender-collection.js";
23
+ import { createRangoDebugger, timed, NS } from "../debug.js";
24
+
25
+ const debug = createRangoDebugger(NS.discovery);
23
26
 
24
27
  /**
25
28
  * Import the user's entry via RSC runner, generate manifests for each
@@ -38,10 +41,16 @@ export async function discoverRouters(
38
41
  // Import the entry file via RSC environment.
39
42
  // For node preset: this is the router file (createRouter() registers in RouterRegistry).
40
43
  // For cloudflare preset: this is the worker entry (which imports the router).
41
- await rscEnv.runner.import(state.resolvedEntryPath);
44
+ await timed(debug, "inner: import entry", () =>
45
+ rscEnv.runner.import(state.resolvedEntryPath),
46
+ );
42
47
 
43
48
  // Import the router package to access the registry
44
- const serverMod = await rscEnv.runner.import("@rangojs/router/server");
49
+ const serverMod = await timed(
50
+ debug,
51
+ "inner: import @rangojs/router/server",
52
+ () => rscEnv.runner.import("@rangojs/router/server"),
53
+ );
45
54
  let registry: Map<string, any> = serverMod.RouterRegistry;
46
55
 
47
56
  if (!registry || registry.size === 0) {
@@ -100,9 +109,15 @@ export async function discoverRouters(
100
109
  }
101
110
 
102
111
  // Import build utilities for manifest generation
103
- const buildMod = await rscEnv.runner.import("@rangojs/router/build");
112
+ const buildMod = await timed(
113
+ debug,
114
+ "inner: import @rangojs/router/build",
115
+ () => rscEnv.runner.import("@rangojs/router/build"),
116
+ );
104
117
  const generateManifestFull = buildMod.generateManifestFull;
105
118
 
119
+ debug?.("inner: found %d router(s) in registry", registry.size);
120
+
106
121
  const nestedRouterConflict = findNestedRouterConflict(
107
122
  [...registry.values()]
108
123
  .map((router) => router.__sourceFile)
@@ -130,6 +145,7 @@ export async function discoverRouters(
130
145
  // Collect all manifests for trie building (avoid re-running generateManifest)
131
146
  const allManifests: Array<{ id: string; manifest: any }> = [];
132
147
 
148
+ const manifestGenStart = debug ? performance.now() : 0;
133
149
  for (const [id, router] of registry) {
134
150
  if (!router.urlpatterns || !generateManifestFull) {
135
151
  continue;
@@ -234,8 +250,15 @@ export async function discoverRouters(
234
250
  }
235
251
  }
236
252
 
253
+ debug?.(
254
+ "inner: generated manifests for %d router(s) (%sms)",
255
+ allManifests.length,
256
+ (performance.now() - manifestGenStart).toFixed(1),
257
+ );
258
+
237
259
  // Build route trie from merged manifest + ancestry
238
260
  let newMergedRouteTrie: any = null;
261
+ const trieStart = debug ? performance.now() : 0;
239
262
  if (Object.keys(newMergedRouteManifest).length > 0) {
240
263
  const buildRouteTrie = buildMod.buildRouteTrie;
241
264
  if (buildRouteTrie && mergedRouteAncestry) {
@@ -329,6 +352,11 @@ export async function discoverRouters(
329
352
  }
330
353
  }
331
354
 
355
+ debug?.(
356
+ "inner: trie build done (%sms)",
357
+ (performance.now() - trieStart).toFixed(1),
358
+ );
359
+
332
360
  // Commit all local state to the shared discovery state atomically.
333
361
  // This ensures a failed re-discovery (e.g. from a transient module
334
362
  // evaluation error) preserves the last known-good state.
@@ -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
+ }