@rangojs/router 0.0.0-experimental.100 → 0.0.0-experimental.102

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.
@@ -332,6 +332,8 @@ export function scrollToHash(): boolean {
332
332
  * Scroll to top of page
333
333
  */
334
334
  export function scrollToTop(): void {
335
+ if (typeof window === "undefined") return;
336
+ if (typeof window.scrollTo !== "function") return;
335
337
  window.scrollTo(0, 0);
336
338
  }
337
339
 
@@ -374,20 +376,26 @@ export function handleNavigationEnd(options: {
374
376
  // Fall through to hash or top if no saved position
375
377
  }
376
378
 
377
- // Defer hash and scroll-to-top to after React paints the new content,
378
- // so the user doesn't see the current page jump before the new route appears.
379
- deferToNextPaint(() => {
380
- // Re-check: the deferred callback may fire after environment teardown
381
- if (typeof window === "undefined") return;
382
-
383
- // Try hash scrolling first
384
- if (scrollToHash()) {
385
- return;
386
- }
387
-
388
- // Default: scroll to top
389
- scrollToTop();
390
- });
379
+ // scrollToHash / scrollToTop run synchronously here.
380
+ // handleNavigationEnd is invoked from NavigationProvider's
381
+ // useLayoutEffect (post-commit, pre-paint), so a sync scrollTo is
382
+ // captured by the upcoming paint AND by startViewTransition's snapshot.
383
+ // Deferring via rAF here pushed the call past the snapshot capture,
384
+ // making forward navigations wrapped in a layout/route view transition
385
+ // skip scroll-to-top the live DOM scrolled but the captured snapshot
386
+ // was at the previous scroll position, so the user-facing page stayed
387
+ // visually clamped at the source page's scrollY (often the new tree's
388
+ // max scroll for tall→short navs). Y=0 / a hash element are robust
389
+ // against unmeasured layout, so sync scroll is correct here even
390
+ // before the new tree's scrollHeight settles.
391
+ //
392
+ // (The restore branch above keeps deferToNextPaint because savedY
393
+ // depends on the new tree's max scroll; sync scrollTo against an
394
+ // unmeasured DOM would clamp savedY to whatever the old/zero max was.)
395
+ if (scrollToHash()) {
396
+ return;
397
+ }
398
+ scrollToTop();
391
399
  }
392
400
 
393
401
  /**
@@ -186,7 +186,10 @@ export function href<T extends ValidPaths>(path: T, mount?: string): string {
186
186
  const normalizedMount = mount.endsWith("/") ? mount.slice(0, -1) : mount;
187
187
  return normalizedMount + path;
188
188
  }
189
- return path;
189
+ // ValidPaths is built from template literals so T does extend string at
190
+ // runtime, but the inference can fail past a certain route-union complexity
191
+ // and TypeScript reports T as not assignable to string.
192
+ return path as string;
190
193
  }
191
194
 
192
195
  /**
@@ -0,0 +1,202 @@
1
+ /**
2
+ * LoaderStore — shared subscription model for `useLoader` / `useFetchLoader`.
3
+ *
4
+ * Each loader id (loader.$$id) gets one entry that holds the latest committed
5
+ * snapshot plus a set of listeners. Snapshots are frozen and replaced atomically
6
+ * on mutation, so subscribers can compare snapshot identity and avoid
7
+ * unnecessary updates between real changes.
8
+ *
9
+ * Mutations that come in for an old request id (e.g. a slow response that
10
+ * resolves after a newer load() was issued, or after a navigation cleared the
11
+ * entry) are silently dropped. `reserveRequestId` is the only way to claim the
12
+ * "latest" slot; `clear` bumps it too so pre-navigation in-flight loads cannot
13
+ * commit into the new route's context.
14
+ *
15
+ * The store is intentionally module-level: each browser tab is its own JS
16
+ * realm, so there is no cross-request pollution. Server renders never mutate
17
+ * the store — the hook falls back to `OutletContext.loaderData`.
18
+ */
19
+
20
+ export interface LoaderEntry<T = unknown> {
21
+ readonly value: T | undefined;
22
+ readonly error: Error | null;
23
+ readonly isLoading: boolean;
24
+ /** Identifies the request that produced this snapshot. 0 means "no request". */
25
+ readonly requestId: number;
26
+ }
27
+
28
+ const EMPTY_SNAPSHOT: LoaderEntry = Object.freeze({
29
+ value: undefined,
30
+ error: null,
31
+ isLoading: false,
32
+ requestId: 0,
33
+ });
34
+
35
+ interface InternalEntry {
36
+ snapshot: LoaderEntry;
37
+ listeners: Set<() => void>;
38
+ /** Monotonically increasing. Bumped by reserveRequestId() and clear(). */
39
+ latestRequestId: number;
40
+ }
41
+
42
+ export class LoaderStore {
43
+ private readonly entries = new Map<string, InternalEntry>();
44
+
45
+ private getOrCreate(id: string): InternalEntry {
46
+ let e = this.entries.get(id);
47
+ if (!e) {
48
+ e = {
49
+ snapshot: EMPTY_SNAPSHOT,
50
+ listeners: new Set(),
51
+ latestRequestId: 0,
52
+ };
53
+ this.entries.set(id, e);
54
+ }
55
+ return e;
56
+ }
57
+
58
+ /**
59
+ * Subscribe to entry changes for `id`.
60
+ * Returns an unsubscribe function. The store keeps the entry around even
61
+ * after the last subscriber leaves so that an in-flight `load()` can still
62
+ * commit if the consumer remounts.
63
+ */
64
+ subscribe(id: string, cb: () => void): () => void {
65
+ const e = this.getOrCreate(id);
66
+ e.listeners.add(cb);
67
+ return () => {
68
+ e.listeners.delete(cb);
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Returns the current snapshot for `id`. Stable reference between mutations
74
+ * — subscribers rely on this to avoid spurious re-renders.
75
+ * Returns `EMPTY_SNAPSHOT` (a singleton) when the entry has never been
76
+ * mutated or has been cleared.
77
+ */
78
+ getSnapshot(id: string): LoaderEntry {
79
+ return this.entries.get(id)?.snapshot ?? EMPTY_SNAPSHOT;
80
+ }
81
+
82
+ /**
83
+ * Reserve a fresh request id for an upcoming `load()` call. The returned id
84
+ * is the new "latest"; any older in-flight requests will fail their gating
85
+ * check on `finishData` / `finishError` / `finishLoading` and be dropped.
86
+ *
87
+ * Callers should follow with `beginRequest(id, requestId)` to flip the
88
+ * loading flag on AND clear any leftover error from a previous attempt
89
+ * — the latter matters for `throwOnError: false` consumers, which would
90
+ * otherwise keep showing the stale error throughout the retry.
91
+ */
92
+ reserveRequestId(id: string): number {
93
+ const e = this.getOrCreate(id);
94
+ e.latestRequestId++;
95
+ return e.latestRequestId;
96
+ }
97
+
98
+ /**
99
+ * Mark the request as in-flight: `isLoading = true`, `error = null`.
100
+ * Combines the two operations so a retry doesn't render the previous
101
+ * error during the new request. Gated on `requestId === latestRequestId`
102
+ * for symmetry with the other mutators.
103
+ */
104
+ beginRequest(id: string, requestId: number): void {
105
+ const e = this.entries.get(id);
106
+ if (!e || requestId !== e.latestRequestId) return;
107
+ if (e.snapshot.isLoading && e.snapshot.error === null) return;
108
+ e.snapshot = Object.freeze({
109
+ value: e.snapshot.value,
110
+ error: null,
111
+ isLoading: true,
112
+ requestId,
113
+ });
114
+ this.notify(e);
115
+ }
116
+
117
+ /**
118
+ * Commit a successful result. No-op if `requestId` is not the latest
119
+ * (a newer `load()` was issued or `clear()` ran). Clearing `error` is
120
+ * intentional: a successful refetch should hide the previous failure.
121
+ */
122
+ finishData<T>(id: string, requestId: number, value: T): void {
123
+ const e = this.entries.get(id);
124
+ if (!e || requestId !== e.latestRequestId) return;
125
+ e.snapshot = Object.freeze({
126
+ value,
127
+ error: null,
128
+ isLoading: false,
129
+ requestId,
130
+ });
131
+ this.notify(e);
132
+ }
133
+
134
+ /**
135
+ * Commit an error. Preserves the last good `value` so consumers can keep
136
+ * showing previous data while displaying the error if they choose. No-op
137
+ * if `requestId` is not the latest.
138
+ */
139
+ finishError(id: string, requestId: number, error: Error): void {
140
+ const e = this.entries.get(id);
141
+ if (!e || requestId !== e.latestRequestId) return;
142
+ e.snapshot = Object.freeze({
143
+ value: e.snapshot.value,
144
+ error,
145
+ isLoading: false,
146
+ requestId,
147
+ });
148
+ this.notify(e);
149
+ }
150
+
151
+ /**
152
+ * Update loading flag. Gated on `requestId` to fix the race where an old
153
+ * load() finishes after a new one started — its `setLoading(false)` would
154
+ * otherwise hide the new request's spinner.
155
+ */
156
+ setLoading(id: string, requestId: number, isLoading: boolean): void {
157
+ const e = this.entries.get(id);
158
+ if (!e || requestId !== e.latestRequestId) return;
159
+ if (e.snapshot.isLoading === isLoading) return;
160
+ e.snapshot = Object.freeze({
161
+ ...e.snapshot,
162
+ isLoading,
163
+ });
164
+ this.notify(e);
165
+ }
166
+
167
+ /**
168
+ * Reset the entry. Bumps `latestRequestId` so any in-flight `load()` whose
169
+ * promise is still pending will fail its gate when it resolves and be
170
+ * dropped — prevents pre-navigation loads from clobbering the new route's
171
+ * context.
172
+ */
173
+ clear(id: string): void {
174
+ const e = this.entries.get(id);
175
+ if (!e) return;
176
+ e.latestRequestId++;
177
+ if (e.snapshot === EMPTY_SNAPSHOT) return;
178
+ e.snapshot = EMPTY_SNAPSHOT;
179
+ this.notify(e);
180
+ }
181
+
182
+ private notify(e: InternalEntry): void {
183
+ for (const cb of e.listeners) cb();
184
+ }
185
+
186
+ /**
187
+ * Test-only escape hatch. Drops every entry. Production code should never
188
+ * call this; the store is process-scoped and lives for the tab's lifetime.
189
+ * @internal
190
+ */
191
+ reset(): void {
192
+ this.entries.clear();
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Module-level singleton. Each browser tab gets its own; SSR never mutates it.
198
+ * The hook falls through to `OutletContext.loaderData` during the server render.
199
+ */
200
+ export const loaderStore: LoaderStore = new LoaderStore();
201
+
202
+ export const EMPTY_LOADER_SNAPSHOT: LoaderEntry = EMPTY_SNAPSHOT;
@@ -12,8 +12,31 @@ import {
12
12
  type ReactNode,
13
13
  } from "react";
14
14
  import { OutletContext, type OutletContextValue } from "./outlet-context.js";
15
+ import { loaderStore, type LoaderEntry } from "./loader-store.js";
15
16
  import type { LoaderDefinition, LoadOptions } from "./types.js";
16
17
 
18
+ /**
19
+ * Plain route-context refetch — a `load()` call with no options or a
20
+ * trivially-defaulted GET (no params, no body). Results from these are
21
+ * broadcast to every component reading the same loader id via the shared
22
+ * store, so a layout's refetch button updates page + parallel-slot reads
23
+ * automatically.
24
+ *
25
+ * Calls with explicit `params`, an explicit non-GET method, or a `body`
26
+ * stay local to the call site — that preserves the today-semantics of
27
+ * `useFetchLoader(SearchLoader).load({ params: { q } })` style code where
28
+ * each component owns its own fetched view.
29
+ */
30
+ function isPlainRefetch(options: LoadOptions | undefined): boolean {
31
+ if (!options) return true;
32
+ if (options.method && options.method !== "GET") return false;
33
+ if (options.params && Object.keys(options.params).length > 0) return false;
34
+ if ("body" in options && (options as { body?: unknown }).body !== undefined) {
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+
17
40
  /**
18
41
  * Extract a specific loader's data from a content ReactNode.
19
42
  *
@@ -132,12 +155,23 @@ function useLoaderInternal<T>(
132
155
  ): UseFetchLoaderResult<T> {
133
156
  const context = useContext(OutletContext);
134
157
 
135
- // Get data from context (SSR/navigation)
136
- const contextData = useMemo((): T | undefined => {
158
+ // Get data from context (SSR/navigation). `hasContextData` distinguishes
159
+ // "loader registered on the route, value happens to be undefined" from
160
+ // "loader is not in any parent's context at all". The shared store is
161
+ // only consulted when the loader really is in route context — that
162
+ // preserves per-component isolation for ad-hoc useFetchLoader callers
163
+ // who use the same fetchable loader without registering it.
164
+ const { contextData, hasContextData } = useMemo((): {
165
+ contextData: T | undefined;
166
+ hasContextData: boolean;
167
+ } => {
137
168
  let current: OutletContextValue | null | undefined = context;
138
169
  while (current) {
139
170
  if (current.loaderData && loader.$$id in current.loaderData) {
140
- return current.loaderData[loader.$$id] as T;
171
+ return {
172
+ contextData: current.loaderData[loader.$$id] as T,
173
+ hasContextData: true,
174
+ };
141
175
  }
142
176
  // Check content element — the route's OutletProvider is rendered as
143
177
  // <Outlet /> content (a child), so its loaderData isn't in the parent
@@ -147,32 +181,102 @@ function useLoaderInternal<T>(
147
181
  loader.$$id,
148
182
  );
149
183
  if (contentData !== NOT_FOUND) {
150
- return contentData as T;
184
+ return { contextData: contentData as T, hasContextData: true };
151
185
  }
152
186
  current = current.parent;
153
187
  }
154
- return undefined;
188
+ return { contextData: undefined, hasContextData: false };
155
189
  }, [context, loader.$$id]);
156
190
 
157
- // Local state for fetched data (from load() calls)
158
- const [fetchedData, setFetchedData] = useState<T | undefined>(undefined);
159
- const [isLoading, setIsLoading] = useState(false);
160
- const [error, setError] = useState<Error | null>(null);
161
- const requestIdRef = useRef(0);
162
-
163
- // Track context data changes to reset fetched data on navigation
191
+ // Shared subscription: every component reading the same loader id sees
192
+ // the same snapshot, so a plain refetch from one component propagates to
193
+ // the others. Mirrors the convention used by useParams / useLinkStatus —
194
+ // useState seeded from the store, useEffect subscribes for updates and
195
+ // calls setState inside startTransition so subscriber re-renders don't
196
+ // trip Suspense fallbacks during a refetch (matches the per-hook
197
+ // startTransition the old code wrapped setFetchedData in).
198
+ const loaderId = loader.$$id;
199
+ const [sharedState, setSharedState] = useState<{
200
+ loaderId: string;
201
+ snapshot: LoaderEntry;
202
+ }>(() => ({
203
+ loaderId,
204
+ snapshot: loaderStore.getSnapshot(loaderId),
205
+ }));
206
+ const sharedSnapshot =
207
+ sharedState.loaderId === loaderId
208
+ ? sharedState.snapshot
209
+ : loaderStore.getSnapshot(loaderId);
210
+ useEffect(() => {
211
+ // Sync any value the store committed between this hook's lazy
212
+ // initializer and effect-time (e.g. a sibling that mounted earlier
213
+ // already triggered a load()).
214
+ const initial = loaderStore.getSnapshot(loaderId);
215
+ if (initial !== sharedSnapshot) {
216
+ startTransition(() => {
217
+ setSharedState({ loaderId, snapshot: initial });
218
+ });
219
+ }
220
+ return loaderStore.subscribe(loaderId, () => {
221
+ const next = loaderStore.getSnapshot(loaderId);
222
+ startTransition(() => {
223
+ setSharedState({ loaderId, snapshot: next });
224
+ });
225
+ });
226
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional:
227
+ // sharedSnapshot is captured for the one-shot init sync; we don't want
228
+ // to re-subscribe on every snapshot change.
229
+ }, [loaderId]);
230
+
231
+ // Local state holds the result of:
232
+ // - parameterized / mutation `load()` calls (load({ params }), POST,
233
+ // etc.) — stay scoped so concurrent same-loader different-params
234
+ // fetches don't clobber each other through the shared store;
235
+ // - any `load()` made by hooks that are NOT in route context (i.e.
236
+ // useFetchLoader of an unregistered loader) — keeping those local
237
+ // prevents two unrelated components from accidentally sharing data
238
+ // through the global store just because they reference the same
239
+ // loader id.
240
+ const [localFetchedData, setLocalFetchedData] = useState<T | undefined>(
241
+ undefined,
242
+ );
243
+ const [localIsLoading, setLocalIsLoading] = useState(false);
244
+ const [localError, setLocalError] = useState<Error | null>(null);
245
+
246
+ // Local request id, mirrors the per-hook gating the previous
247
+ // implementation provided. Two quick parameterized loads from the same
248
+ // hook (e.g. load({ params: { q: "a" } }) then load({ params: { q: "b" } }))
249
+ // can resolve out of order — only the latest must commit.
250
+ const localRequestIdRef = useRef(0);
251
+
252
+ // Tracks the request id of the most recent SHARED load() this hook
253
+ // initiated. The render-throw rule below uses it to scope the throw
254
+ // to the originating hook only — sibling readers see the error in
255
+ // `error` but don't blow up their own boundaries.
256
+ const lastSharedRequestIdRef = useRef<number | null>(null);
257
+
258
+ // Reset on navigation. clear() bumps the entry's latest request id so
259
+ // any pre-navigation load() promise that resolves later fails its gate
260
+ // and is dropped — fixes the race where a stale fetch overwrites the
261
+ // new route's context.
164
262
  const prevContextDataRef = useRef(contextData);
165
263
  useEffect(() => {
166
264
  if (prevContextDataRef.current !== contextData) {
167
- // Navigation happened, clear fetched data so context takes precedence
168
- setFetchedData(undefined);
169
- setError(null);
265
+ setLocalFetchedData(undefined);
266
+ setLocalIsLoading(false);
267
+ setLocalError(null);
268
+ lastSharedRequestIdRef.current = null;
269
+ loaderStore.clear(loaderId);
170
270
  prevContextDataRef.current = contextData;
171
271
  }
172
- }, [contextData]);
272
+ }, [contextData, loaderId]);
173
273
 
174
- // Data priority: fetched data (if any) > context data
175
- const data = fetchedData ?? contextData;
274
+ // Read priority: a parameterized load() result overrides the shared
275
+ // snapshot; the shared snapshot overrides the server-seeded context.
276
+ const data =
277
+ localFetchedData ?? (sharedSnapshot.value as T | undefined) ?? contextData;
278
+ const isLoading = localIsLoading || sharedSnapshot.isLoading;
279
+ const error = localError ?? sharedSnapshot.error;
176
280
 
177
281
  const throwOnError = options?.throwOnError ?? true;
178
282
 
@@ -180,30 +284,47 @@ function useLoaderInternal<T>(
180
284
  // churn. loader.$$id can change if a reusable component receives a different
181
285
  // loader without remounting; data changes on every navigation. Refs keep the
182
286
  // callback stable while always reading the latest values.
183
- const loaderIdRef = useRef(loader.$$id);
184
- loaderIdRef.current = loader.$$id;
287
+ const loaderIdRef = useRef(loaderId);
288
+ loaderIdRef.current = loaderId;
185
289
  const dataRef = useRef(data);
186
290
  dataRef.current = data;
291
+ const hasContextDataRef = useRef(hasContextData);
292
+ hasContextDataRef.current = hasContextData;
187
293
 
188
294
  // Load function for fetching data via the ?_rsc_loader endpoint.
189
295
  // Supports GET (data fetching) and POST/PUT/PATCH/DELETE (mutations).
190
296
  const load = useCallback(
191
297
  async (loadOptions?: LoadOptions): Promise<T> => {
192
- const requestId = ++requestIdRef.current;
193
- const loaderId = loaderIdRef.current;
194
- // Verify the loader has $$id
195
- if (!loaderId) {
298
+ const id = loaderIdRef.current;
299
+ if (!id) {
196
300
  throw new Error(
197
301
  `Loader is missing $$id. Make sure the exposeLoaderId Vite plugin is enabled.`,
198
302
  );
199
303
  }
200
304
 
201
- setIsLoading(true);
202
- setError(null);
305
+ // Sharing the result is only correct when the loader is actually
306
+ // registered on the route — otherwise two unrelated components
307
+ // calling load() on the same fetchable loader would suddenly start
308
+ // overwriting each other's local view through the global store.
309
+ const shared = isPlainRefetch(loadOptions) && hasContextDataRef.current;
310
+ let sharedRequestId = -1;
311
+ let localRequestId = -1;
312
+ if (shared) {
313
+ sharedRequestId = loaderStore.reserveRequestId(id);
314
+ lastSharedRequestIdRef.current = sharedRequestId;
315
+ // beginRequest flips loading on AND clears any prior error so a
316
+ // throwOnError: false consumer doesn't keep showing the stale
317
+ // error during the retry. Gated on requestId === latest.
318
+ loaderStore.beginRequest(id, sharedRequestId);
319
+ } else {
320
+ localRequestId = ++localRequestIdRef.current;
321
+ setLocalIsLoading(true);
322
+ setLocalError(null);
323
+ }
203
324
 
204
325
  try {
205
326
  const url = new URL(window.location.href);
206
- url.searchParams.set("_rsc_loader", loaderId);
327
+ url.searchParams.set("_rsc_loader", id);
207
328
 
208
329
  const method = loadOptions?.method ?? "GET";
209
330
  const isBodyMethod = method !== "GET";
@@ -284,16 +405,26 @@ function useLoaderInternal<T>(
284
405
  }
285
406
 
286
407
  const result = payload.loaderResult;
287
- if (requestId === requestIdRef.current) {
408
+ if (shared) {
409
+ // finishData is gated on requestId; a stale response is dropped.
410
+ loaderStore.finishData(id, sharedRequestId, result);
411
+ } else if (localRequestId === localRequestIdRef.current) {
412
+ // Local-branch gate, mirrors the shared-branch requestId check:
413
+ // if a newer load() was issued from this hook before this one
414
+ // resolved, drop the stale result.
288
415
  startTransition(() => {
289
- setFetchedData(result);
416
+ setLocalFetchedData(result);
417
+ setLocalIsLoading(false);
290
418
  });
291
419
  }
292
420
  return result;
293
421
  } catch (e) {
294
422
  const err = e instanceof Error ? e : new Error(String(e));
295
- if (requestId === requestIdRef.current) {
296
- setError(err);
423
+ if (shared) {
424
+ loaderStore.finishError(id, sharedRequestId, err);
425
+ } else if (localRequestId === localRequestIdRef.current) {
426
+ setLocalError(err);
427
+ setLocalIsLoading(false);
297
428
  }
298
429
  if (throwOnError) {
299
430
  throw err;
@@ -302,18 +433,31 @@ function useLoaderInternal<T>(
302
433
  // successful value or undefined). Caller should check error state.
303
434
  return dataRef.current as T;
304
435
  } finally {
305
- if (requestId === requestIdRef.current) {
306
- setIsLoading(false);
436
+ if (shared) {
437
+ // setLoading is gated; only the latest request flips the flag off.
438
+ loaderStore.setLoading(id, sharedRequestId, false);
307
439
  }
308
440
  }
309
441
  },
310
442
  [throwOnError],
311
443
  );
312
444
 
313
- // Throw during render if there's an error and throwOnError is true
314
- // This allows ErrorBoundaries to catch async errors from load()
315
- if (error && throwOnError) {
316
- throw error;
445
+ // Throw during render if there's an error and throwOnError is true.
446
+ // - Local errors always belong to this hook, so always throw on opt-in.
447
+ // - Shared errors throw only when this hook initiated the failing
448
+ // request (entry.requestId matches lastSharedRequestIdRef). Sibling
449
+ // readers expose the error via `error` but do not throw, so a
450
+ // throwOnError: true reader never explodes because of someone else's
451
+ // throwOnError: false load() failure.
452
+ if (throwOnError) {
453
+ if (localError) throw localError;
454
+ if (
455
+ sharedSnapshot.error &&
456
+ lastSharedRequestIdRef.current !== null &&
457
+ sharedSnapshot.requestId === lastSharedRequestIdRef.current
458
+ ) {
459
+ throw sharedSnapshot.error;
460
+ }
317
461
  }
318
462
 
319
463
  return {
@@ -76,6 +76,14 @@ export interface DiscoveryState {
76
76
  resolvedBuildEnv?: Record<string, unknown>;
77
77
  /** Cleanup function for build-time env resources (e.g., miniflare). */
78
78
  buildEnvDispose?: (() => Promise<void> | void) | null;
79
+
80
+ /**
81
+ * Set when the most recent HMR re-discovery threw. Cleared on the next
82
+ * successful discovery. Surfaced via debug logs so we can detect "manifest
83
+ * frozen at last-good after error → user fix in non-route file → no
84
+ * rediscovery trigger" scenarios.
85
+ */
86
+ lastDiscoveryError?: { message: string; at: number } | null;
79
87
  }
80
88
 
81
89
  export function createDiscoveryState(
@@ -113,5 +121,6 @@ export function createDiscoveryState(
113
121
  devServer: null,
114
122
  selfWrittenGenFiles: new Map(),
115
123
  SELF_WRITE_WINDOW_MS: 5_000,
124
+ lastDiscoveryError: null,
116
125
  };
117
126
  }
@@ -133,6 +133,7 @@ export function createVersionPlugin(): Plugin {
133
133
  let currentVersion = buildVersion;
134
134
  let isDev = false;
135
135
  let server: any = null;
136
+ let resolvedCacheDir: string | undefined;
136
137
  const clientModuleSignatures = new Map<string, ClientModuleSignature>();
137
138
 
138
139
  let versionCounter = 0;
@@ -157,6 +158,12 @@ export function createVersionPlugin(): Plugin {
157
158
 
158
159
  configResolved(config) {
159
160
  isDev = config.command === "serve";
161
+ // Capture the resolved cacheDir so we can ignore optimizer-output
162
+ // writes inside it. Vite resolves cacheDir against the project root,
163
+ // so this is a stable absolute path for the lifetime of the server.
164
+ resolvedCacheDir = config.cacheDir
165
+ ? String(config.cacheDir).replace(/\\/g, "/")
166
+ : undefined;
160
167
  },
161
168
 
162
169
  configureServer(devServer) {
@@ -214,6 +221,14 @@ export function createVersionPlugin(): Plugin {
214
221
 
215
222
  if (!isRscModule) return;
216
223
 
224
+ // Skip Vite's own pre-bundled dep cache writes. The optimizer rewrites
225
+ // files inside the configured `cacheDir` on every discovery cycle
226
+ // (and when other dev servers under the same cwd populate their own
227
+ // isolated cache dirs). These are not user-source changes, so bumping
228
+ // the app version on them produces spurious version mismatches that
229
+ // surface as forced reloads on in-flight actions.
230
+ if (isViteDepCachePath(ctx.file, resolvedCacheDir)) return;
231
+
217
232
  // Skip re-bumping when the version virtual module itself is invalidated
218
233
  // (our own bumpVersion() invalidates it, which re-triggers hotUpdate).
219
234
  if (
@@ -264,3 +279,45 @@ export function createVersionPlugin(): Plugin {
264
279
  },
265
280
  };
266
281
  }
282
+
283
+ /**
284
+ * Match Vite's pre-bundled dep cache directories. These paths are rewritten
285
+ * by the dep optimizer (and by isolated test fixtures sharing the same cwd),
286
+ * not by user source changes, so they should not bump the app version (which
287
+ * would force a client reload mid-request).
288
+ *
289
+ * Two checks:
290
+ * 1. Anything inside the resolved `cacheDir` (precise — covers custom paths
291
+ * like the `RANGO_E2E_VITE_CACHE_DIR` overrides in the test fixtures).
292
+ * 2. Heuristic match for any `node_modules/.vite*` directory or a
293
+ * `.vite-isolated/` segment anywhere in the path. This catches the
294
+ * *other* dev servers in the same cwd whose cacheDir we cannot read
295
+ * (we only see config of the server we're attached to).
296
+ */
297
+ export function isViteDepCachePath(
298
+ filePath: string | undefined,
299
+ cacheDir?: string,
300
+ ): boolean {
301
+ if (!filePath) return false;
302
+ const normalized = filePath.replace(/\\/g, "/");
303
+
304
+ if (cacheDir) {
305
+ const normalizedCacheDir = cacheDir.replace(/\\/g, "/").replace(/\/+$/, "");
306
+ if (
307
+ normalized === normalizedCacheDir ||
308
+ normalized.startsWith(normalizedCacheDir + "/")
309
+ ) {
310
+ return true;
311
+ }
312
+ }
313
+
314
+ // Vite/optimizer convention: cache dirs always sit directly under
315
+ // `node_modules/` and start with `.vite` (e.g. `.vite`, `.vite-temp`,
316
+ // `.vite_rango_generate`, `.vite-e2e-test-app`). The `/.vite-isolated/`
317
+ // segment covers the test-fixture pattern that places the cache outside
318
+ // node_modules.
319
+ return (
320
+ /\/node_modules\/\.vite[^/]*\//.test(normalized) ||
321
+ normalized.includes("/.vite-isolated/")
322
+ );
323
+ }