@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.
- package/dist/bin/rango.js +16 -1
- package/dist/vite/index.js +81 -14
- package/package.json +1 -1
- package/skills/hooks/SKILL.md +78 -0
- package/skills/loader/SKILL.md +7 -0
- package/src/browser/history-state.ts +21 -0
- package/src/browser/navigation-bridge.ts +2 -6
- package/src/browser/navigation-transaction.ts +3 -7
- package/src/browser/react/location-state-shared.ts +82 -1
- package/src/browser/react/location-state.ts +39 -13
- package/src/browser/react/use-router.ts +14 -1
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/href-client.ts +4 -1
- package/src/loader-store.ts +202 -0
- package/src/use-loader.tsx +181 -37
- package/src/vite/discovery/state.ts +9 -0
- package/src/vite/plugins/version-plugin.ts +57 -0
- package/src/vite/router-discovery.ts +73 -14
|
@@ -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
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
/**
|
package/src/href-client.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/use-loader.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
//
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
//
|
|
175
|
-
|
|
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(
|
|
184
|
-
loaderIdRef.current =
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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",
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
296
|
-
|
|
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 (
|
|
306
|
-
|
|
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
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
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
|
+
}
|