@reactra/resource 0.1.0-alpha.0
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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/cache.d.ts +58 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +167 -0
- package/dist/cache.js.map +1 -0
- package/dist/index.d.ts +350 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +956 -0
- package/dist/index.js.map +1 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
// @reactra/resource — useResource hook + ResourceCache + ErrorBoundary
|
|
2
|
+
//
|
|
3
|
+
// Owner: reactra-resource-spec.md (Spec 1.0 / Discussion v4 — v1 promotion)
|
|
4
|
+
//
|
|
5
|
+
// Surface — see Resource v1:
|
|
6
|
+
// useResource<Deps, T>(deps, fn, opts?): ResourceHandle<T> (§2)
|
|
7
|
+
// useMutation(fn, opts?) (§7 — Stage D)
|
|
8
|
+
// ResourceCache (singleton + interface), createLRUCache (§6 — Stage A1)
|
|
9
|
+
// configureResources({ cache?, cacheMaxEntries? }) (§9 — Stage A1)
|
|
10
|
+
// ResourceOptions, ResourceHandle<T>
|
|
11
|
+
// ErrorBoundary — class component that compiled `await(r) error(e) { }`
|
|
12
|
+
// blocks wrap around `<Suspense>` per Resource v1 §4 emission contract.
|
|
13
|
+
//
|
|
14
|
+
// Stages landed in this file: A1 — ResourceCache singleton + cache-hit branch
|
|
15
|
+
// (settled) + configureResources + RES010/011. A2 — in-flight dedup: useResource
|
|
16
|
+
// writes an in-flight tombstone (`value === undefined && error === undefined`)
|
|
17
|
+
// to the cache before firing `fn`; concurrent mounts on the same
|
|
18
|
+
// `(resourceName, depsKey)` adopt the shared promise + controller instead of
|
|
19
|
+
// firing their own; on rejection the tombstone is `cache.delete`d (matching
|
|
20
|
+
// A1's "do not cache rejections" rule). B — stale-while-revalidate: on a
|
|
21
|
+
// settled cache hit past `cache.ttlMs`, return the stale value this render
|
|
22
|
+
// (zero flicker, no suspense) AND fire a background refetch that writes an
|
|
23
|
+
// SWR tombstone (stale value + in-flight promise + fresh writtenAt) so
|
|
24
|
+
// concurrent mounts get a non-suspending hit and don't fire another refresh;
|
|
25
|
+
// on refresh failure the entry is rewritten with `writtenAt: now` to back off
|
|
26
|
+
// for `ttlMs`. SWR is opt-in via `cache: { ttlMs, staleWhileRevalidate: true }`.
|
|
27
|
+
// C — retry + backoff + jitter (RES012): `opts.retry: N | { count, baseMs?,
|
|
28
|
+
// jitter? }` wraps `fn` in `__fireWithRetry` (exponential backoff
|
|
29
|
+
// `baseMs * 2^attempt` with optional ±20% jitter). AbortError never retries;
|
|
30
|
+
// on exhaustion RES012 logs and the last error surfaces to setState +
|
|
31
|
+
// cache.delete tombstone (A2 path) unchanged. SWR refresh inherits retries.
|
|
32
|
+
// D — `useMutation(fn, opts?)` for non-idempotent side effects + cross-mount
|
|
33
|
+
// `cache.invalidate` notification: useResource now subscribes to its cache key
|
|
34
|
+
// via `useSyncExternalStore` (StrictMode-safe sub/unsub for free); re-renders
|
|
35
|
+
// driven by `setNotifyTick` inside the subscribe callback. `ResourceEntry`
|
|
36
|
+
// gains `cacheCoupled` (set true on adoption + after a successful write-back);
|
|
37
|
+
// render-body `cacheBust = cacheCoupled && cached === undefined` triggers
|
|
38
|
+
// re-fire on cross-mount invalidate. `useMutation` exposes
|
|
39
|
+
// `mutate`/`reset`/`data`/`error`/`isPending` + RES013 (overlap without
|
|
40
|
+
// `concurrent: true`) + RES014 (invalidate name unknown to cache).
|
|
41
|
+
// E — `warmResource(name, deps, fn, signal)` exported helper that
|
|
42
|
+
// `StoreRegistry.warm`-emitted companions call per-resource to pre-fill the
|
|
43
|
+
// cache ahead of navigation. Best-effort: silent on failure (RES015 not
|
|
44
|
+
// minted). Writes an in-flight tombstone for dedup + propagates caller signal
|
|
45
|
+
// to fn via a two-layer AbortController. In-flight adoption in useResource
|
|
46
|
+
// now swallows AbortError so a cancelled warm doesn't flash error UI.
|
|
47
|
+
// Streaming/SSR (F → v2/Phase 4) pending.
|
|
48
|
+
import React, { useCallback, useRef, useState, useSyncExternalStore } from "react";
|
|
49
|
+
import { createLRUCache } from "./cache.js";
|
|
50
|
+
export { createLRUCache } from "./cache.js";
|
|
51
|
+
/**
|
|
52
|
+
* True outside a CONFIRMED production build — gates dev-only diagnostics.
|
|
53
|
+
*
|
|
54
|
+
* Reference the `process.env.NODE_ENV` LITERAL (not `globalThis.process`):
|
|
55
|
+
* bundlers (Vite/webpack) statically replace it at build time, and a browser
|
|
56
|
+
* bundle has no runtime `process`. The prior `globalThis.process` form returned
|
|
57
|
+
* `true` whenever `process` was absent — i.e. in BOTH browser dev and browser
|
|
58
|
+
* prod — so RES012 still logged in a production browser build (the very thing the
|
|
59
|
+
* gate was added to stop). The try/catch covers a bundler that leaves the literal
|
|
60
|
+
* un-replaced (no `process` global): treat unknown as dev so the diagnostic isn't
|
|
61
|
+
* silently swallowed. Node/bun read it at runtime.
|
|
62
|
+
*/
|
|
63
|
+
const __isDevEnv = () => {
|
|
64
|
+
try {
|
|
65
|
+
return process.env.NODE_ENV !== "production";
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// ResourceCache singleton + configureResources (Resource v1 §6/§9, Stage A1)
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
let __currentCache = createLRUCache(50);
|
|
75
|
+
let __firstMountFired = false;
|
|
76
|
+
/** Internal accessor — the active singleton. Not part of the public API. */
|
|
77
|
+
export const __getResourceCache = () => __currentCache;
|
|
78
|
+
/** Test-only: reset the singleton + first-mount guard between test cases. */
|
|
79
|
+
export const __resetResourceConfigForTest = () => {
|
|
80
|
+
__currentCache = createLRUCache(50);
|
|
81
|
+
__firstMountFired = false;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Configure the resource layer at app boot (Resource v1 §9). Pass a custom
|
|
85
|
+
* `cache` implementation (must satisfy `ResourceCache`) or just override the
|
|
86
|
+
* built-in LRU's max-entries with `cacheMaxEntries`. **Must run before the
|
|
87
|
+
* first `useResource` mount** — calling it after throws RES010 (the cache
|
|
88
|
+
* implementation is already in use; the swap is rejected).
|
|
89
|
+
*
|
|
90
|
+
* Calling it more than once before the first mount overwrites silently (last
|
|
91
|
+
* call wins — typically only configures once at app boot). Calling it never
|
|
92
|
+
* is fine — the built-in LRU runs with `maxEntries: 50` and no TTL.
|
|
93
|
+
*/
|
|
94
|
+
export const configureResources = (opts = {}) => {
|
|
95
|
+
if (__firstMountFired) {
|
|
96
|
+
throw new Error(`[reactra:resource] RES010: configureResources called after the first useResource mount. ` +
|
|
97
|
+
`The cache implementation is already in use; the swap is rejected. ` +
|
|
98
|
+
`Call configureResources at app boot, before configureRouter and the first render. ` +
|
|
99
|
+
`(Resource v1 §9.)`);
|
|
100
|
+
}
|
|
101
|
+
if (opts.cache !== undefined) {
|
|
102
|
+
__currentCache = opts.cache;
|
|
103
|
+
}
|
|
104
|
+
else if (opts.cacheMaxEntries !== undefined) {
|
|
105
|
+
__currentCache = createLRUCache(opts.cacheMaxEntries);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
// The policy helpers below read only T/S-independent fields (resourceName/cache/
|
|
109
|
+
// retry), never `select`. `ResourceOptions<any, any>` lets any instantiation pass:
|
|
110
|
+
// a bare `ResourceOptions<unknown, unknown>` would reject `ResourceOptions<T, S>`
|
|
111
|
+
// because `select: (data: T) => S` is CONTRAVARIANT in T (a fn taking T is not a fn
|
|
112
|
+
// taking unknown). `any` here is justified — these helpers never touch `select`.
|
|
113
|
+
/** Cache is enabled for this call iff a resourceName is present AND `opts.cache` is truthy. */
|
|
114
|
+
const cacheEnabled = (opts) => opts.resourceName !== undefined &&
|
|
115
|
+
(opts.cache === true || (typeof opts.cache === "object" && opts.cache !== null));
|
|
116
|
+
/** Extract `opts.cache` as an object form, or null if `true`/`false`/absent. */
|
|
117
|
+
const cacheOptsObject = (opts) => typeof opts.cache === "object" && opts.cache !== null ? opts.cache : null;
|
|
118
|
+
/**
|
|
119
|
+
* Stage B — TTL-based staleness check. Returns `true` iff `cache.ttlMs` is
|
|
120
|
+
* set AND `Date.now() - entry.writtenAt` exceeds it. No TTL = never stale
|
|
121
|
+
* (entries live until eviction). Exported under `__` so the test suite can
|
|
122
|
+
* exercise the predicate without standing up a render.
|
|
123
|
+
*/
|
|
124
|
+
export const __isCacheEntryStale = (entry, opts, now = Date.now()) => {
|
|
125
|
+
const co = cacheOptsObject(opts);
|
|
126
|
+
if (co?.ttlMs === undefined)
|
|
127
|
+
return false;
|
|
128
|
+
return now - entry.writtenAt > co.ttlMs;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Stage B — staleWhileRevalidate opt-in check. Returns `true` iff
|
|
132
|
+
* `opts.cache` is an object form AND `staleWhileRevalidate: true`. Bare
|
|
133
|
+
* `cache: true` is treated as "cache but no SWR" — explicit opt-in required.
|
|
134
|
+
*/
|
|
135
|
+
export const __shouldRevalidate = (opts) => cacheOptsObject(opts)?.staleWhileRevalidate === true;
|
|
136
|
+
const RETRY_BASE_MS_DEFAULT = 250;
|
|
137
|
+
const RETRY_JITTER_DEFAULT = true;
|
|
138
|
+
/**
|
|
139
|
+
* Stage C — normalize `opts.retry` to a `__RetryConfig` or `null`. `retry: N`
|
|
140
|
+
* (number) becomes `{ count: N, baseMs: 250, jitter: true }`. `retry: { count }`
|
|
141
|
+
* fills in the same defaults for unset fields. `retry: 0` or absent returns
|
|
142
|
+
* null (consumer fires `fn` once with zero retry overhead).
|
|
143
|
+
*/
|
|
144
|
+
export const __normalizeRetry = (opts) => {
|
|
145
|
+
const r = opts.retry;
|
|
146
|
+
if (r === undefined)
|
|
147
|
+
return null;
|
|
148
|
+
if (typeof r === "number") {
|
|
149
|
+
if (r <= 0)
|
|
150
|
+
return null;
|
|
151
|
+
return { count: r, baseMs: RETRY_BASE_MS_DEFAULT, jitter: RETRY_JITTER_DEFAULT };
|
|
152
|
+
}
|
|
153
|
+
if (r.count <= 0)
|
|
154
|
+
return null;
|
|
155
|
+
return {
|
|
156
|
+
count: r.count,
|
|
157
|
+
baseMs: r.baseMs ?? RETRY_BASE_MS_DEFAULT,
|
|
158
|
+
jitter: r.jitter ?? RETRY_JITTER_DEFAULT,
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Stage C — exponential backoff with optional ±20% jitter. Delay for attempt
|
|
163
|
+
* `n` (0-indexed) is `baseMs * 2^n`, optionally multiplied by a uniform
|
|
164
|
+
* `[0.8, 1.2)` jitter factor. `random` is injectable so tests can pin both
|
|
165
|
+
* extremes (`() => 0` → 0.8x; `() => 1` → 1.2x).
|
|
166
|
+
*/
|
|
167
|
+
export const __computeRetryDelay = (attempt, config, random = Math.random) => {
|
|
168
|
+
const expMs = config.baseMs * 2 ** attempt;
|
|
169
|
+
if (!config.jitter)
|
|
170
|
+
return expMs;
|
|
171
|
+
// [0.8, 1.2): symmetric ±20% around the exponential delay.
|
|
172
|
+
return expMs * (0.8 + random() * 0.4);
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* Stage C — abortable `setTimeout` as a Promise. Resolves after `ms`; rejects
|
|
176
|
+
* with a DOMException("Aborted", "AbortError") if `signal` aborts before the
|
|
177
|
+
* timer fires (the timer is cleared). If the signal is already aborted at
|
|
178
|
+
* call time, rejects synchronously on the next microtask.
|
|
179
|
+
*/
|
|
180
|
+
export const __delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
181
|
+
if (signal.aborted) {
|
|
182
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const t = setTimeout(() => {
|
|
186
|
+
signal.removeEventListener("abort", onAbort);
|
|
187
|
+
resolve();
|
|
188
|
+
}, ms);
|
|
189
|
+
const onAbort = () => {
|
|
190
|
+
clearTimeout(t);
|
|
191
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
192
|
+
};
|
|
193
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
194
|
+
});
|
|
195
|
+
/**
|
|
196
|
+
* Stage C — fire `fn(deps, { signal })` up to `config.count + 1` times (one
|
|
197
|
+
* initial + `count` retries), with exponential backoff + jitter between
|
|
198
|
+
* attempts. On `AbortError` from `fn` OR signal abort during the backoff
|
|
199
|
+
* delay, the loop short-circuits and the abort propagates. On exhaustion,
|
|
200
|
+
* logs RES012 (dev-gated — prod stays silent since the error already surfaces
|
|
201
|
+
* via `r.error` + ErrorBoundary + the rejected promise) and re-throws the last
|
|
202
|
+
* error UNCONDITIONALLY. The hook's existing `.then` chain handles
|
|
203
|
+
* `setState({ error })` + cache.delete tombstone removal unchanged.
|
|
204
|
+
*/
|
|
205
|
+
export const __fireWithRetry = async (deps, signal, config, fn, random = Math.random) => {
|
|
206
|
+
let lastErr;
|
|
207
|
+
// `attempt` 0 is the initial call; 1..count are the retries.
|
|
208
|
+
for (let attempt = 0; attempt <= config.count; attempt++) {
|
|
209
|
+
if (signal.aborted) {
|
|
210
|
+
throw new DOMException("Aborted", "AbortError");
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
return await fn(deps, { signal });
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
lastErr = err;
|
|
217
|
+
// Aborts never retry — propagate immediately. This covers both the
|
|
218
|
+
// consumer-driven abort (deps change, refetch) and any in-fn explicit
|
|
219
|
+
// cancellation.
|
|
220
|
+
if (err instanceof DOMException && err.name === "AbortError")
|
|
221
|
+
throw err;
|
|
222
|
+
if (attempt >= config.count)
|
|
223
|
+
break;
|
|
224
|
+
// RES012-precursor — silent per-attempt retry. The surfaced log lives
|
|
225
|
+
// at exhaustion (below) so a transient flake doesn't spam the console.
|
|
226
|
+
const delayMs = __computeRetryDelay(attempt, config, random);
|
|
227
|
+
await __delay(delayMs, signal);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Exhausted. Log RES012 once (dev-gated — the error already surfaces via
|
|
231
|
+
// `r.error` + ErrorBoundary + the rejected promise, so prod console stays
|
|
232
|
+
// silent) and re-throw the last error so the existing `.then` chain surfaces
|
|
233
|
+
// it to setState + cache.delete the in-flight tombstone (A2's on-rejection
|
|
234
|
+
// path). The throw is UNCONDITIONAL — only the log is gated.
|
|
235
|
+
if (__isDevEnv()) {
|
|
236
|
+
console.error(`[reactra:resource] RES012: retry exhausted after ${config.count + 1} attempts — last error follows`, lastErr);
|
|
237
|
+
}
|
|
238
|
+
throw lastErr;
|
|
239
|
+
};
|
|
240
|
+
// Stable reference id for object-typed deps. Per Resource v0 §3 deps
|
|
241
|
+
// equality is `Object.is`, so two fresh object literals are different
|
|
242
|
+
// references and must trigger a refetch. We assign each object a unique
|
|
243
|
+
// id via a WeakMap so depsKey changes whenever the reference changes.
|
|
244
|
+
let __refCounter = 0;
|
|
245
|
+
const __refIds = new WeakMap();
|
|
246
|
+
const refId = (obj) => {
|
|
247
|
+
let id = __refIds.get(obj);
|
|
248
|
+
if (id === undefined) {
|
|
249
|
+
id = `ref:${++__refCounter}`;
|
|
250
|
+
__refIds.set(obj, id);
|
|
251
|
+
}
|
|
252
|
+
return id;
|
|
253
|
+
};
|
|
254
|
+
const scalarKey = (x) => {
|
|
255
|
+
if (x === null)
|
|
256
|
+
return "null";
|
|
257
|
+
if (x === undefined)
|
|
258
|
+
return "undef";
|
|
259
|
+
if (typeof x === "object")
|
|
260
|
+
return refId(x);
|
|
261
|
+
if (typeof x === "function")
|
|
262
|
+
return refId(x);
|
|
263
|
+
return `${typeof x}:${String(x)}`;
|
|
264
|
+
};
|
|
265
|
+
/**
|
|
266
|
+
* Stable string key for a `deps` value. Arrays are unwrapped element-wise so
|
|
267
|
+
* `[1, 2]` keys identically to another `[1, 2]`; scalar refs are compared by
|
|
268
|
+
* `Object.is`-equivalent identity. Exported for tests.
|
|
269
|
+
*/
|
|
270
|
+
export const depsToKey = (deps) => {
|
|
271
|
+
if (Array.isArray(deps))
|
|
272
|
+
return `[${deps.map(scalarKey).join(",")}]`;
|
|
273
|
+
return scalarKey(deps);
|
|
274
|
+
};
|
|
275
|
+
// Per-call record cached in a ref. Survives StrictMode's fake-unmount
|
|
276
|
+
// because refs live outside React's render-output reconciliation — the
|
|
277
|
+
// same depsKey on the fake-remount finds this entry intact and reuses
|
|
278
|
+
// its (unaborted) promise. See the comment block on `useResource` for
|
|
279
|
+
// the full rationale.
|
|
280
|
+
// Stage D — `useSyncExternalStore` snapshot stays a constant so the
|
|
281
|
+
// subscription-driven re-renders go through the explicit `setNotifyTick`
|
|
282
|
+
// call inside the subscribe callback (single source of truth). Hoisted to
|
|
283
|
+
// module scope so the reference is identity-stable across renders —
|
|
284
|
+
// uSES treats a changing snapshot fn closure as a change signal.
|
|
285
|
+
const USES_NOOP_SNAPSHOT = () => null;
|
|
286
|
+
/**
|
|
287
|
+
* The Reactra resource hook. Compiler-emitted from `resource r(deps) => fn(deps)`.
|
|
288
|
+
*
|
|
289
|
+
* Behaviour per Resource v0 §2 + §3:
|
|
290
|
+
* - `deps === null` → skip the call. `data/error: undefined`, `isPending: false`,
|
|
291
|
+
* `promise: Promise.resolve(undefined)`.
|
|
292
|
+
* - `deps === undefined` → same as `null` but emits a RES004 warning. The
|
|
293
|
+
* explicit skip sentinel is `null`.
|
|
294
|
+
* - Otherwise `fn(deps, { signal })` runs immediately. `data` updates on
|
|
295
|
+
* resolution; `error` updates on rejection (preserving last good `data`).
|
|
296
|
+
* - Deps change (shallow `Object.is`) aborts the in-flight call and re-fires.
|
|
297
|
+
* - `refetch()` aborts the current call and re-fires with the same deps.
|
|
298
|
+
* - Late resolutions are dropped via a generation counter.
|
|
299
|
+
*
|
|
300
|
+
* The returned `promise` is always non-null and is fed to `React.use()` inside
|
|
301
|
+
* compiled `await(r) { }` blocks so React 19's Suspense can drive the boundary.
|
|
302
|
+
*
|
|
303
|
+
* ## StrictMode-safety (the reason this hook is ref-cached, not useMemo'd)
|
|
304
|
+
*
|
|
305
|
+
* Earlier drafts used `useMemo` for the promise and a `useEffect(() => () =>
|
|
306
|
+
* abort())` cleanup. That breaks under React.StrictMode in dev. StrictMode
|
|
307
|
+
* double-invokes render → mount → cleanup → mount on first mount to surface
|
|
308
|
+
* impurity. The first mount's `useEffect` cleanup aborts the controller while
|
|
309
|
+
* the SAME promise object is still referenced by the rendered tree; on the
|
|
310
|
+
* fake-remount, `React.use(promise)` sees a rejected (AbortError) promise and
|
|
311
|
+
* the surrounding ErrorBoundary fires for what should be a normal load.
|
|
312
|
+
* Removing the cleanup-side abort alone doesn't fix it — the top-of-useMemo
|
|
313
|
+
* abort + StrictMode's deliberate memo-discard still leave a rejected promise
|
|
314
|
+
* visible to React.use either way (useMemo is a cache *hint*, not a guarantee).
|
|
315
|
+
*
|
|
316
|
+
* The fix: cache the entry in a `useRef`. Refs are mutable cells that survive
|
|
317
|
+
* StrictMode's fake-unmount unchanged because they live outside React's
|
|
318
|
+
* render-output reconciliation. On the fake-remount, the same `depsKey` finds
|
|
319
|
+
* the existing entry and reuses its unaborted promise. Abort happens ONLY at
|
|
320
|
+
* the top of the cache-miss branch (genuine deps change or first call), never
|
|
321
|
+
* from an effect cleanup. Late resolutions are still dropped by the
|
|
322
|
+
* `genRef === myGen` guard inside the .then handlers, which covers the cases
|
|
323
|
+
* the cleanup-abort used to: deps-change, refetch, and the rarer "still
|
|
324
|
+
* in-flight at real unmount" case (setState on an unmounted component is a
|
|
325
|
+
* silent no-op in React 18+; no error, no warning, just a dropped state
|
|
326
|
+
* update). The minor cost is that a fetch in flight at real unmount keeps
|
|
327
|
+
* running until the network call completes — but its resolution is dropped,
|
|
328
|
+
* so no setState-on-unmounted and no observable bug. Worth it for StrictMode.
|
|
329
|
+
*
|
|
330
|
+
* Mutating refs during render is the standard React 19 pattern for cache-like
|
|
331
|
+
* state; React docs explicitly bless it when (a) the operation is idempotent
|
|
332
|
+
* for a given input (same depsKey → same entry) and (b) the mutation doesn't
|
|
333
|
+
* affect the rendered output during *this* render. Both hold here.
|
|
334
|
+
*/
|
|
335
|
+
export const useResource = (deps, fn, opts = {}) => {
|
|
336
|
+
// First-mount latch (Resource v1 §9): configureResources called after this
|
|
337
|
+
// throws RES010 — the cache impl is in use and the swap is rejected.
|
|
338
|
+
__firstMountFired = true;
|
|
339
|
+
// Bumped by refetch() to force a cache-miss even when deps didn't change.
|
|
340
|
+
// Kept separate from genRef so React can observe it via state.
|
|
341
|
+
const [refetchTick, setRefetchTick] = useState(0);
|
|
342
|
+
// The resolved snapshot. `resolvedKey` is the depsKey of whichever promise
|
|
343
|
+
// resolved into this state — used to derive isPending below.
|
|
344
|
+
// State holds the SELECTED value `S` (= raw `T` when no `select`). Storing the
|
|
345
|
+
// slice — not the raw data — is what enables the re-render bail-out: an
|
|
346
|
+
// unselected-field change recomputes the same slice, the guarded setState
|
|
347
|
+
// returns the same state, and React skips the re-render.
|
|
348
|
+
const [state, setState] = useState({ data: undefined, error: undefined, resolvedKey: null });
|
|
349
|
+
// Project raw → selected. Identity when no `select` (so no-select resources keep
|
|
350
|
+
// today's exact behaviour byte-for-byte).
|
|
351
|
+
const applySel = (raw) => raw === undefined ? undefined : opts.select ? opts.select(raw) : raw;
|
|
352
|
+
// Commit a successful raw value as the selected slice. WITH `select`, guard on
|
|
353
|
+
// `Object.is` so an unchanged slice bails the re-render (the feature). WITHOUT
|
|
354
|
+
// `select`, keep today's always-allocate write (no behaviour change).
|
|
355
|
+
const commitData = (raw) => {
|
|
356
|
+
if (opts.select) {
|
|
357
|
+
const sel = applySel(raw);
|
|
358
|
+
setState((s) => s.resolvedKey === depsKey && Object.is(s.data, sel) && s.error === undefined
|
|
359
|
+
? s
|
|
360
|
+
: { data: sel, error: undefined, resolvedKey: depsKey });
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
setState({ data: raw, error: undefined, resolvedKey: depsKey });
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
// Ref-cached entry. Survives StrictMode fake-unmount unchanged.
|
|
367
|
+
const entryRef = useRef(null);
|
|
368
|
+
// Monotonic generation. Bumped only on a new factory invocation (NOT on
|
|
369
|
+
// unmount cleanup — there is no cleanup). Used by the .then handlers to
|
|
370
|
+
// drop late resolutions from aborted calls so they don't overwrite state
|
|
371
|
+
// from the current call.
|
|
372
|
+
const genRef = useRef(0);
|
|
373
|
+
const depsKey = `${depsToKey(deps)}:${refetchTick}`;
|
|
374
|
+
// ResourceCache key (Resource v1 §6): purely deps-derived, sans refetchTick —
|
|
375
|
+
// entries cross refetch boundaries (refetch invalidates the cache entry, see
|
|
376
|
+
// refetch callback). Distinct from the local `depsKey` which DOES include the
|
|
377
|
+
// tick so the local entryRef misses on refetch.
|
|
378
|
+
const cacheKey = depsToKey(deps);
|
|
379
|
+
const useCache = cacheEnabled(opts) && deps !== null && deps !== undefined;
|
|
380
|
+
// Stage D — subscribe to cache notifications for our key so a cross-mount
|
|
381
|
+
// `useMutation.invalidate(name)` or external `cache.delete/clear` triggers
|
|
382
|
+
// a re-render here. We use `useSyncExternalStore` purely for StrictMode-
|
|
383
|
+
// safe subscribe/unsubscribe lifecycle (uSES guarantees a single live
|
|
384
|
+
// subscription per mount across StrictMode's fake-unmount/remount, which
|
|
385
|
+
// we cannot get from a render-body subscribe without a `useEffect`). Its
|
|
386
|
+
// returned snapshot is intentionally a constant (`null`) — re-renders are
|
|
387
|
+
// driven by `notifyTick` inside the subscribe callback, NOT by uSES's
|
|
388
|
+
// snapshot diffing, so we don't get spurious renders from uSES itself.
|
|
389
|
+
const [, setNotifyTick] = useState(0);
|
|
390
|
+
const subscribeResource = useCache ? opts.resourceName : undefined;
|
|
391
|
+
const subscribeKey = useCache ? cacheKey : "";
|
|
392
|
+
const subscribeFn = useCallback((uSESChange) => {
|
|
393
|
+
if (subscribeResource === undefined)
|
|
394
|
+
return () => { };
|
|
395
|
+
return __currentCache.subscribe(subscribeResource, subscribeKey, () => {
|
|
396
|
+
// Drive an actual re-render — uSES's snapshot is a no-op constant.
|
|
397
|
+
setNotifyTick((t) => t + 1);
|
|
398
|
+
uSESChange();
|
|
399
|
+
});
|
|
400
|
+
}, [subscribeResource, subscribeKey]);
|
|
401
|
+
useSyncExternalStore(subscribeFn, USES_NOOP_SNAPSHOT, USES_NOOP_SNAPSHOT);
|
|
402
|
+
// `renderCached` holds a cache-hit entry for THIS render so the returned
|
|
403
|
+
// handle can read from it synchronously (zero Suspense flash, no flicker
|
|
404
|
+
// through `data: undefined` on first render).
|
|
405
|
+
let renderCached;
|
|
406
|
+
// Pre-read the cache once so both the cache-miss branch and the Stage D
|
|
407
|
+
// cacheBust check see the same snapshot.
|
|
408
|
+
const rawCached = useCache && opts.resourceName !== undefined
|
|
409
|
+
? __currentCache.get(opts.resourceName, cacheKey)
|
|
410
|
+
: undefined;
|
|
411
|
+
// Hard-expiry (`cache(ttl: D)` — a ttl WITHOUT `staleWhileRevalidate`): an entry
|
|
412
|
+
// past its `ttlMs` is treated as a MISS so the next read refetches instead of
|
|
413
|
+
// serving stale forever. `swr` is the opposite (serve stale + revalidate), so it
|
|
414
|
+
// is excluded here and keeps its settled hit. No ttl, or swr → unchanged.
|
|
415
|
+
const cached = rawCached !== undefined &&
|
|
416
|
+
cacheOptsObject(opts)?.ttlMs !== undefined &&
|
|
417
|
+
!__shouldRevalidate(opts) &&
|
|
418
|
+
__isCacheEntryStale(rawCached, opts)
|
|
419
|
+
? undefined
|
|
420
|
+
: rawCached;
|
|
421
|
+
// Stage D — cross-mount cache-bust. If our local entry was cache-coupled
|
|
422
|
+
// (adopted from the cache OR write-backed after a fresh fire) and the cache
|
|
423
|
+
// entry has since been evicted (invalidate / delete / clear), we re-fire
|
|
424
|
+
// even though `depsKey` didn't change. Without this, a `useMutation`
|
|
425
|
+
// invalidate would only refresh the firing mount; sibling mounts holding
|
|
426
|
+
// adopted entries would keep showing stale data until their own deps changed.
|
|
427
|
+
const cacheBust = entryRef.current !== null &&
|
|
428
|
+
entryRef.current.cacheCoupled &&
|
|
429
|
+
useCache &&
|
|
430
|
+
opts.resourceName !== undefined &&
|
|
431
|
+
cached === undefined;
|
|
432
|
+
// Cache-miss branch: first call, or deps genuinely changed, or Stage D
|
|
433
|
+
// cache-bust. This is the ONLY abort site — no useEffect cleanup, no
|
|
434
|
+
// top-of-useMemo abort. The same depsKey on a subsequent render (including
|
|
435
|
+
// StrictMode's fake-remount) takes the cache-hit path and returns the
|
|
436
|
+
// existing promise.
|
|
437
|
+
let entry = entryRef.current;
|
|
438
|
+
if (entry === null || entry.depsKey !== depsKey || cacheBust) {
|
|
439
|
+
// Cancel the prior in-flight (if any). On first call this is a no-op.
|
|
440
|
+
entry?.controller?.abort();
|
|
441
|
+
const myGen = ++genRef.current;
|
|
442
|
+
if (deps === undefined) {
|
|
443
|
+
console.warn("[reactra:resource] RES004: deps is undefined — use null to explicitly skip");
|
|
444
|
+
}
|
|
445
|
+
// Resource v1 §3 + §6 — Stage A1 cache-hit branch. Before firing `fn`,
|
|
446
|
+
// consult `ResourceCache` for `(resourceName, cacheKey)` (pre-read above
|
|
447
|
+
// so the cacheBust check sees the same snapshot). On hit, take the cached
|
|
448
|
+
// entry's already-resolved promise (no Suspense flash) and sync the local
|
|
449
|
+
// state via microtask so future renders are stable.
|
|
450
|
+
if (cached !== undefined) {
|
|
451
|
+
// Stage A2 — distinguish a SETTLED hit (resolved value, or settled-error
|
|
452
|
+
// we may cache in a future stage) from an IN-FLIGHT hit (both undefined —
|
|
453
|
+
// a prior mount is still fetching). Settled keeps the A1 zero-flicker
|
|
454
|
+
// synth; in-flight adopts the shared promise + controller so this mount
|
|
455
|
+
// dedups onto the same fetch instead of firing its own.
|
|
456
|
+
const cachedSettled = cached.value !== undefined || cached.error !== undefined;
|
|
457
|
+
entry = {
|
|
458
|
+
depsKey,
|
|
459
|
+
gen: myGen,
|
|
460
|
+
controller: null, // cache owns the controller (two-layer ownership, §3)
|
|
461
|
+
promise: cached.promise,
|
|
462
|
+
cacheCoupled: true, // adopted from cache → re-fire on invalidate
|
|
463
|
+
};
|
|
464
|
+
if (cachedSettled) {
|
|
465
|
+
renderCached = cached;
|
|
466
|
+
const cachedValue = cached.value;
|
|
467
|
+
const cachedError = cached.error;
|
|
468
|
+
const cachedSel = applySel(cachedValue);
|
|
469
|
+
queueMicrotask(() => {
|
|
470
|
+
if (myGen !== genRef.current)
|
|
471
|
+
return;
|
|
472
|
+
setState((s) => s.resolvedKey === depsKey && Object.is(s.data, cachedSel) && s.error === cachedError
|
|
473
|
+
? s
|
|
474
|
+
: { data: cachedSel, error: cachedError, resolvedKey: depsKey });
|
|
475
|
+
});
|
|
476
|
+
// Stage B — stale-while-revalidate. If `cache.ttlMs` is set and the
|
|
477
|
+
// cached entry is past it, AND `cache.staleWhileRevalidate: true`,
|
|
478
|
+
// return the stale value this render (renderCached + microtask
|
|
479
|
+
// setState above) AND fire a background refetch. The refetch
|
|
480
|
+
// writes an SWR tombstone holding the STALE value + the in-flight
|
|
481
|
+
// promise + a FRESH writtenAt so a concurrent mount (a) gets a
|
|
482
|
+
// non-suspending settled hit, and (b) doesn't fire ANOTHER refresh
|
|
483
|
+
// (the fresh stamp signals "refresh in flight"). On success, the
|
|
484
|
+
// tombstone is superseded with the resolved value (same controller →
|
|
485
|
+
// idempotent supersede, no abort). On failure, the entry is REWRITTEN
|
|
486
|
+
// with `writtenAt: now` so the next attempt waits a full `ttlMs`
|
|
487
|
+
// (back-off; the consumer keeps showing the valid stale value).
|
|
488
|
+
// SWR failures do NOT setState({error}) — the consumer asked for
|
|
489
|
+
// stale-over-error semantics. C (retry) and onError live in a later stage.
|
|
490
|
+
if (__isCacheEntryStale(cached, opts) &&
|
|
491
|
+
__shouldRevalidate(opts) &&
|
|
492
|
+
opts.resourceName !== undefined &&
|
|
493
|
+
deps !== null &&
|
|
494
|
+
deps !== undefined) {
|
|
495
|
+
const swrController = new AbortController();
|
|
496
|
+
const swrResourceName = opts.resourceName;
|
|
497
|
+
const swrCacheKey = cacheKey;
|
|
498
|
+
const swrStaleValue = cached.value;
|
|
499
|
+
// Stage C — SWR refresh also benefits from retries. Same normalization
|
|
500
|
+
// path; null = bare call, zero overhead.
|
|
501
|
+
const swrRetryConfig = __normalizeRetry(opts);
|
|
502
|
+
const swrRawPromise = swrRetryConfig === null
|
|
503
|
+
? fn(deps, { signal: swrController.signal })
|
|
504
|
+
: __fireWithRetry(deps, swrController.signal, swrRetryConfig, fn);
|
|
505
|
+
// Tombstone: stale value + in-flight promise + fresh writtenAt.
|
|
506
|
+
try {
|
|
507
|
+
__currentCache.set(swrResourceName, swrCacheKey, {
|
|
508
|
+
value: swrStaleValue,
|
|
509
|
+
error: undefined,
|
|
510
|
+
promise: swrRawPromise,
|
|
511
|
+
controller: swrController,
|
|
512
|
+
writtenAt: Date.now(),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
console.warn(`[reactra:resource] RES011: cache write failed for "${swrResourceName}" — falling back to no-cache for this entry`, err);
|
|
517
|
+
}
|
|
518
|
+
void swrRawPromise.then((value) => {
|
|
519
|
+
if (myGen === genRef.current) {
|
|
520
|
+
commitData(value);
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
__currentCache.set(swrResourceName, swrCacheKey, {
|
|
524
|
+
value: value,
|
|
525
|
+
error: undefined,
|
|
526
|
+
promise: Promise.resolve(value),
|
|
527
|
+
controller: swrController,
|
|
528
|
+
writtenAt: Date.now(),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
console.warn(`[reactra:resource] RES011: cache write failed for "${swrResourceName}" — falling back to no-cache for this entry`, err);
|
|
533
|
+
}
|
|
534
|
+
}, (err) => {
|
|
535
|
+
// SWR back-off: rewrite the stale entry with a fresh writtenAt so
|
|
536
|
+
// the next render past ttlMs retries. The consumer is NOT
|
|
537
|
+
// notified of the refresh error — stale-over-error is the SWR
|
|
538
|
+
// contract; the stale data keeps showing.
|
|
539
|
+
try {
|
|
540
|
+
__currentCache.set(swrResourceName, swrCacheKey, {
|
|
541
|
+
value: swrStaleValue,
|
|
542
|
+
error: undefined,
|
|
543
|
+
promise: Promise.resolve(swrStaleValue),
|
|
544
|
+
controller: swrController,
|
|
545
|
+
writtenAt: Date.now(),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
/* swallow — cache impl rejected the rewrite */
|
|
550
|
+
}
|
|
551
|
+
// Dev-only diagnostic so a flaky API isn't completely silent.
|
|
552
|
+
if (__isDevEnv()) {
|
|
553
|
+
console.warn(`[reactra:resource] SWR refresh failed for "${swrResourceName}" — keeping stale data`, err);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
// In-flight adoption: attach a follow-on to the shared promise so THIS
|
|
560
|
+
// mount learns when it lands. `setState` is gated on `myGen` so a
|
|
561
|
+
// deps-change supersede silently drops the stale handler. The firing
|
|
562
|
+
// mount's own `.then` updates ITS state separately; both can race
|
|
563
|
+
// freely (idempotent `setState` shape).
|
|
564
|
+
void cached.promise.then((value) => {
|
|
565
|
+
if (myGen !== genRef.current)
|
|
566
|
+
return;
|
|
567
|
+
commitData(value);
|
|
568
|
+
}, (err) => {
|
|
569
|
+
if (myGen !== genRef.current)
|
|
570
|
+
return;
|
|
571
|
+
// Stage E — an AbortError from an adopted in-flight (typically a
|
|
572
|
+
// cancelled `warmResource`, Resource v1 §8) is NOT surfaced to
|
|
573
|
+
// the consumer. Stage D's `cacheBust = cacheCoupled && cached ===
|
|
574
|
+
// undefined` re-fires fresh on the next render (warmResource's
|
|
575
|
+
// failure path delete's the tombstone). Surfacing the abort would
|
|
576
|
+
// flash an error in the UI for what is internally a benign
|
|
577
|
+
// prefetch-cancelled-by-real-navigation race.
|
|
578
|
+
if (err instanceof DOMException && err.name === "AbortError")
|
|
579
|
+
return;
|
|
580
|
+
setState((s) => ({ data: s.data, error: err, resolvedKey: depsKey }));
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
else if (deps === null || deps === undefined) {
|
|
585
|
+
entry = {
|
|
586
|
+
depsKey,
|
|
587
|
+
gen: myGen,
|
|
588
|
+
controller: null,
|
|
589
|
+
promise: Promise.resolve(undefined),
|
|
590
|
+
cacheCoupled: false, // no cache participation when skipping
|
|
591
|
+
};
|
|
592
|
+
// Clear any prior data/error on non-null→null transition (Resource v0
|
|
593
|
+
// §3 row 4). Microtask so we don't setState during render.
|
|
594
|
+
queueMicrotask(() => {
|
|
595
|
+
if (myGen !== genRef.current)
|
|
596
|
+
return;
|
|
597
|
+
setState((s) => s.data === undefined && s.error === undefined && s.resolvedKey === depsKey
|
|
598
|
+
? s
|
|
599
|
+
: { data: undefined, error: undefined, resolvedKey: depsKey });
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
const controller = new AbortController();
|
|
604
|
+
// Capture name/key at fire time so the on-success cache write uses the
|
|
605
|
+
// values that fired this fetch, not the values from a later render.
|
|
606
|
+
const resourceName = opts.resourceName;
|
|
607
|
+
const writingCacheKey = cacheKey;
|
|
608
|
+
const writingToCache = useCache && resourceName !== undefined;
|
|
609
|
+
// Stage C — wrap `fn` in the retry loop when `opts.retry` is configured.
|
|
610
|
+
// `__normalizeRetry` returns null for `retry: 0`/absent, in which case
|
|
611
|
+
// the bare `fn` call is used (zero overhead, matching A1 behaviour).
|
|
612
|
+
const retryConfig = __normalizeRetry(opts);
|
|
613
|
+
const rawPromise = retryConfig === null
|
|
614
|
+
? fn(deps, { signal: controller.signal })
|
|
615
|
+
: __fireWithRetry(deps, controller.signal, retryConfig, fn);
|
|
616
|
+
// Stage A2 — claim the cache key with an IN-FLIGHT tombstone before the
|
|
617
|
+
// `.then` chain. Concurrent mounts that render before this fetch lands
|
|
618
|
+
// will see `value === undefined && error === undefined` and adopt this
|
|
619
|
+
// promise + controller (cache-hit / in-flight branch above) instead of
|
|
620
|
+
// firing their own `fn`. Same-controller supersede is idempotent, so the
|
|
621
|
+
// on-success cache.set below does NOT abort this fetch.
|
|
622
|
+
if (writingToCache) {
|
|
623
|
+
try {
|
|
624
|
+
__currentCache.set(resourceName, writingCacheKey, {
|
|
625
|
+
value: undefined,
|
|
626
|
+
error: undefined,
|
|
627
|
+
promise: rawPromise,
|
|
628
|
+
controller,
|
|
629
|
+
writtenAt: Date.now(),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
// RES011 graceful degrade — no dedup for this fetch, but the fire
|
|
634
|
+
// continues. (May log again on the success path's write; acceptable
|
|
635
|
+
// for A2 — a once-per-key suppression is a future refinement.)
|
|
636
|
+
console.warn(`[reactra:resource] RES011: cache write failed for "${resourceName}" — falling back to no-cache for this entry`, err);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const promise = rawPromise.then((value) => {
|
|
640
|
+
if (myGen === genRef.current) {
|
|
641
|
+
commitData(value);
|
|
642
|
+
}
|
|
643
|
+
// Stage A1: cache only successful resolutions (errors retry on next
|
|
644
|
+
// mount, matching v0). Wrap quota/serialization failures in RES011.
|
|
645
|
+
if (writingToCache) {
|
|
646
|
+
try {
|
|
647
|
+
__currentCache.set(resourceName, writingCacheKey, {
|
|
648
|
+
value,
|
|
649
|
+
error: undefined,
|
|
650
|
+
promise: Promise.resolve(value),
|
|
651
|
+
controller,
|
|
652
|
+
writtenAt: Date.now(),
|
|
653
|
+
});
|
|
654
|
+
// Stage D — write-back succeeded → this consumer's local entry
|
|
655
|
+
// is now coupled to a shared cache entry. A subsequent
|
|
656
|
+
// cross-mount invalidate that wipes the cache will bust the
|
|
657
|
+
// local entry via the render-body cacheBust check.
|
|
658
|
+
if (newEntry.gen === myGen)
|
|
659
|
+
newEntry.cacheCoupled = true;
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
console.warn(`[reactra:resource] RES011: cache write failed for "${resourceName}" — falling back to no-cache for this entry`, err);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return value;
|
|
666
|
+
}, (err) => {
|
|
667
|
+
if (myGen === genRef.current) {
|
|
668
|
+
// Preserve last good data per Resource v0 §2 "data and error are
|
|
669
|
+
// mutually exclusive in steady state".
|
|
670
|
+
setState((s) => ({ data: s.data, error: err, resolvedKey: depsKey }));
|
|
671
|
+
}
|
|
672
|
+
// Stage A2 — withdraw the in-flight tombstone. Future mounts re-fire
|
|
673
|
+
// (matching A1's "do not cache rejections" rule, extended). The
|
|
674
|
+
// cache.delete may itself reject for a custom impl; swallow — the
|
|
675
|
+
// entry is logically already gone.
|
|
676
|
+
if (writingToCache) {
|
|
677
|
+
try {
|
|
678
|
+
__currentCache.delete(resourceName, writingCacheKey);
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
/* swallow — entry already gone or impl rejected */
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
throw err;
|
|
685
|
+
});
|
|
686
|
+
// `newEntry` is captured by the .then handler above so a successful
|
|
687
|
+
// write-back can flip `cacheCoupled` true (Stage D — couples the local
|
|
688
|
+
// entry to the shared cache for cross-mount bust detection).
|
|
689
|
+
const newEntry = {
|
|
690
|
+
depsKey,
|
|
691
|
+
gen: myGen,
|
|
692
|
+
controller,
|
|
693
|
+
promise,
|
|
694
|
+
cacheCoupled: false,
|
|
695
|
+
};
|
|
696
|
+
entry = newEntry;
|
|
697
|
+
}
|
|
698
|
+
entryRef.current = entry;
|
|
699
|
+
}
|
|
700
|
+
// Resource v1 §3 refetch row: invalidate the resource's cache entries so the
|
|
701
|
+
// re-fired fn re-populates. A1 uses `invalidate(name)` (the spec's public
|
|
702
|
+
// surface) which nukes every entry for the resource — over-invalidation is
|
|
703
|
+
// acceptable here; a future stage may add a per-key signature for surgical
|
|
704
|
+
// refetch. The refetchTick bump then causes a local-cache miss and refires.
|
|
705
|
+
const refetchResourceName = opts.resourceName;
|
|
706
|
+
const refetchCacheActive = useCache && refetchResourceName !== undefined;
|
|
707
|
+
const refetch = useCallback(() => {
|
|
708
|
+
if (refetchCacheActive && refetchResourceName !== undefined) {
|
|
709
|
+
__currentCache.invalidate(refetchResourceName);
|
|
710
|
+
}
|
|
711
|
+
setRefetchTick((t) => t + 1);
|
|
712
|
+
}, [refetchResourceName, refetchCacheActive]);
|
|
713
|
+
// Stage A1: on a cache HIT this render, synthesize the handle from the
|
|
714
|
+
// cached entry directly. Local `state` is updated by the microtask above and
|
|
715
|
+
// takes over from the second render on; without this synth the first cache-
|
|
716
|
+
// hit render would briefly show `data: undefined` while React queues the
|
|
717
|
+
// microtask, producing a flicker the cache exists to avoid.
|
|
718
|
+
if (renderCached !== undefined) {
|
|
719
|
+
return {
|
|
720
|
+
data: applySel(renderCached.value),
|
|
721
|
+
error: renderCached.error,
|
|
722
|
+
isPending: false,
|
|
723
|
+
refetch,
|
|
724
|
+
promise: entry.promise,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
data: state.data,
|
|
729
|
+
error: state.error,
|
|
730
|
+
// Derived: pending iff the most recent resolution doesn't match the
|
|
731
|
+
// currently rendered deps. Null/undefined deps are never pending.
|
|
732
|
+
isPending: deps === null || deps === undefined ? false : state.resolvedKey !== depsKey,
|
|
733
|
+
refetch,
|
|
734
|
+
promise: entry.promise,
|
|
735
|
+
};
|
|
736
|
+
};
|
|
737
|
+
/**
|
|
738
|
+
* Props for the ErrorBoundary emitted by `await(r) error(e) { … }` blocks.
|
|
739
|
+
* `fallback` is a render-fn (not an element) so the boundary can pass the
|
|
740
|
+
* caught error to the user's `error(e) { JSX }` body verbatim.
|
|
741
|
+
*/
|
|
742
|
+
// ---------------------------------------------------------------------------
|
|
743
|
+
// Resource v1 §8 — `warmResource` (Stage E)
|
|
744
|
+
// ---------------------------------------------------------------------------
|
|
745
|
+
/**
|
|
746
|
+
* Resource v1 §8.1 — the runtime helper a compiler-emitted `warm(inputs,
|
|
747
|
+
* signal)` companion calls per-resource. Best-effort cache pre-fill; never
|
|
748
|
+
* throws. The returned promise resolves when the entry settles (success or
|
|
749
|
+
* any failure, including AbortError).
|
|
750
|
+
*
|
|
751
|
+
* Behaviour:
|
|
752
|
+
* - If `(name, depsToKey(deps))` already has a cache entry (settled OR
|
|
753
|
+
* in-flight), resolve immediately. Stage E treats any present entry as
|
|
754
|
+
* "fresh enough" — consumers with SWR drive their own refresh from the
|
|
755
|
+
* render path; warm doesn't aggressively refresh.
|
|
756
|
+
* - Otherwise: write an in-flight tombstone (Stage A2 shape) keyed under
|
|
757
|
+
* a fresh `AbortController` so concurrent warm + consumer mounts dedup.
|
|
758
|
+
* Forward `callerSignal.abort` to the cache controller, propagating
|
|
759
|
+
* cancel-on-real-navigation through to `fn(deps, { signal })`.
|
|
760
|
+
* - On `fn` success: supersede the tombstone with the resolved entry
|
|
761
|
+
* (same controller → idempotent supersede per §6).
|
|
762
|
+
* - On `fn` failure (including AbortError): `cache.delete(name, depsKey)`
|
|
763
|
+
* withdraws the tombstone. Any consumer mid-adopt sees the
|
|
764
|
+
* `cacheBust = cacheCoupled && cached === undefined` check (Stage D)
|
|
765
|
+
* and re-fires fresh on its next render. Failures are silently
|
|
766
|
+
* resolved by the returned promise — RES015 was deliberately not
|
|
767
|
+
* minted (§5; failed warms are debug-only).
|
|
768
|
+
*/
|
|
769
|
+
export const warmResource = async (name, deps, fn, callerSignal) => {
|
|
770
|
+
// Short-circuit on a pre-aborted signal — don't even write a tombstone.
|
|
771
|
+
if (callerSignal.aborted)
|
|
772
|
+
return;
|
|
773
|
+
const depsKey = depsToKey(deps);
|
|
774
|
+
// Cache-hit short-circuit. A1's settled entries OR A2's in-flight
|
|
775
|
+
// tombstones both qualify — present-in-cache means "someone else is
|
|
776
|
+
// (or has) handled this fetch", warm has nothing to add.
|
|
777
|
+
if (__currentCache.get(name, depsKey) !== undefined)
|
|
778
|
+
return;
|
|
779
|
+
const cacheCtrl = new AbortController();
|
|
780
|
+
// Two-layer signal composition: caller abort → cache controller abort →
|
|
781
|
+
// signal to fn. We own cacheCtrl; we listen for callerSignal so the cache
|
|
782
|
+
// entry's controller stays under our control (cache internals call .abort()
|
|
783
|
+
// on it freely without us having to expose / share the caller's signal).
|
|
784
|
+
const onCallerAbort = () => cacheCtrl.abort();
|
|
785
|
+
callerSignal.addEventListener("abort", onCallerAbort, { once: true });
|
|
786
|
+
let settledPromiseResolve;
|
|
787
|
+
const settledPromise = new Promise((resolve) => {
|
|
788
|
+
settledPromiseResolve = resolve;
|
|
789
|
+
});
|
|
790
|
+
// Wrap fn in its own .then chain so we can route success / failure into
|
|
791
|
+
// the cache without affecting the returned best-effort promise's shape.
|
|
792
|
+
const rawPromise = fn(deps, { signal: cacheCtrl.signal });
|
|
793
|
+
void rawPromise.then((value) => {
|
|
794
|
+
callerSignal.removeEventListener("abort", onCallerAbort);
|
|
795
|
+
try {
|
|
796
|
+
__currentCache.set(name, depsKey, {
|
|
797
|
+
value,
|
|
798
|
+
error: undefined,
|
|
799
|
+
promise: Promise.resolve(value),
|
|
800
|
+
controller: cacheCtrl,
|
|
801
|
+
writtenAt: Date.now(),
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
// Best-effort — swallow cache impl rejections silently. The whole
|
|
806
|
+
// warm path is "make consumer mounts faster if possible".
|
|
807
|
+
}
|
|
808
|
+
settledPromiseResolve?.();
|
|
809
|
+
}, () => {
|
|
810
|
+
// Failure path (network error OR AbortError from caller cancel).
|
|
811
|
+
// Withdraw the tombstone so a future consumer mount fires fresh
|
|
812
|
+
// (matches A2's on-rejection rule; aligns with Stage D's cacheBust
|
|
813
|
+
// for any consumer that adopted the tombstone before it failed).
|
|
814
|
+
callerSignal.removeEventListener("abort", onCallerAbort);
|
|
815
|
+
try {
|
|
816
|
+
__currentCache.delete(name, depsKey);
|
|
817
|
+
}
|
|
818
|
+
catch {
|
|
819
|
+
/* swallow */
|
|
820
|
+
}
|
|
821
|
+
settledPromiseResolve?.();
|
|
822
|
+
});
|
|
823
|
+
// Tombstone write goes AFTER attaching .then handlers but is still
|
|
824
|
+
// synchronous — concurrent renders + warm calls see it on their next
|
|
825
|
+
// microtask. RES011 path on the cache impl rejection: swallow (warm is
|
|
826
|
+
// best-effort; nothing else degrades).
|
|
827
|
+
try {
|
|
828
|
+
__currentCache.set(name, depsKey, {
|
|
829
|
+
value: undefined,
|
|
830
|
+
error: undefined,
|
|
831
|
+
promise: rawPromise,
|
|
832
|
+
controller: cacheCtrl,
|
|
833
|
+
writtenAt: Date.now(),
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
/* swallow */
|
|
838
|
+
}
|
|
839
|
+
return settledPromise;
|
|
840
|
+
};
|
|
841
|
+
/**
|
|
842
|
+
* Resource v1 §7 — `useMutation(fn, opts?)`. A user-called hook for
|
|
843
|
+
* non-idempotent side effects (POST, PATCH, DELETE, …) that may need to
|
|
844
|
+
* invalidate cached resources on success. Lives in `@reactra/resource`; NOT
|
|
845
|
+
* emitted by the compiler from any DSL keyword (architect Q6 — `mutation` is
|
|
846
|
+
* deliberately not added to the 25-keyword surface in v1).
|
|
847
|
+
*
|
|
848
|
+
* Behaviour:
|
|
849
|
+
* - `mutate(args)` immediately calls `fn(args, { signal })` and returns the
|
|
850
|
+
* promise. `isPending` toggles `true → false` on settle (success OR error).
|
|
851
|
+
* - On success: writes `data`, clears `error`, then runs
|
|
852
|
+
* `__currentCache.invalidate(opts.invalidate)` if `invalidate` is set. The
|
|
853
|
+
* returned promise resolves AFTER invalidation is initiated.
|
|
854
|
+
* - On rejection: writes `error`, retains the previous `data`. Retries (if
|
|
855
|
+
* `opts.retry`) drain before the rejection lands. A final failure does NOT
|
|
856
|
+
* invalidate.
|
|
857
|
+
* - `reset()` aborts the in-flight call, clears `data` + `error`, toggles
|
|
858
|
+
* `isPending` false. Reference is stable across renders.
|
|
859
|
+
* - **RES013** throws if `mutate()` is called while `isPending` is true and
|
|
860
|
+
* `concurrent !== true`. Set `concurrent: true` to opt in to overlap.
|
|
861
|
+
* - **RES014** warns if `opts.invalidate` lists a name not yet known to the
|
|
862
|
+
* cache (likely a typo). The mutation still completes.
|
|
863
|
+
*
|
|
864
|
+
* StrictMode: no `useEffect`. The in-flight controller lives in a `useRef`
|
|
865
|
+
* and is aborted by `reset()` or replaced on the next `mutate()` call.
|
|
866
|
+
*/
|
|
867
|
+
export const useMutation = (fn, opts = {}) => {
|
|
868
|
+
__firstMountFired = true;
|
|
869
|
+
const [state, setState] = useState({ data: undefined, error: undefined, isPending: false });
|
|
870
|
+
// The LIVE in-flight controller. Only the most recent `mutate()` call is
|
|
871
|
+
// tracked here — older overlapping calls (concurrent mode) run to completion
|
|
872
|
+
// but won't update handle state. Comparing `inFlightRef.current === myCtrl`
|
|
873
|
+
// inside the .then/.catch handlers is the supersede check.
|
|
874
|
+
const inFlightRef = useRef(null);
|
|
875
|
+
// Hold the latest opts so `mutate` doesn't need `opts` in its dep array
|
|
876
|
+
// (which would break the consumer pattern of passing a fresh object
|
|
877
|
+
// literal every render). reset is stable; mutate may re-create.
|
|
878
|
+
const optsRef = useRef(opts);
|
|
879
|
+
optsRef.current = opts;
|
|
880
|
+
const fnRef = useRef(fn);
|
|
881
|
+
fnRef.current = fn;
|
|
882
|
+
const reset = useCallback(() => {
|
|
883
|
+
if (inFlightRef.current !== null) {
|
|
884
|
+
inFlightRef.current.abort();
|
|
885
|
+
inFlightRef.current = null;
|
|
886
|
+
}
|
|
887
|
+
setState({ data: undefined, error: undefined, isPending: false });
|
|
888
|
+
}, []);
|
|
889
|
+
const mutate = useCallback(async (args) => {
|
|
890
|
+
const currentOpts = optsRef.current;
|
|
891
|
+
const currentFn = fnRef.current;
|
|
892
|
+
if (inFlightRef.current !== null && currentOpts.concurrent !== true) {
|
|
893
|
+
// RES013 — overlap without explicit opt-in. Throw synchronously so
|
|
894
|
+
// the calling code's `try { await mutate(args) } catch` sees it.
|
|
895
|
+
throw new Error("[reactra:resource] RES013: useMutation.mutate() called while previous call is in-flight; pass { concurrent: true } to allow overlap");
|
|
896
|
+
}
|
|
897
|
+
const controller = new AbortController();
|
|
898
|
+
inFlightRef.current = controller;
|
|
899
|
+
const myCtrl = controller;
|
|
900
|
+
setState((s) => ({ ...s, isPending: true }));
|
|
901
|
+
const retryConfig = __normalizeRetry({ retry: currentOpts.retry });
|
|
902
|
+
try {
|
|
903
|
+
const result = retryConfig === null
|
|
904
|
+
? await currentFn(args, { signal: controller.signal })
|
|
905
|
+
: await __fireWithRetry(
|
|
906
|
+
// Mutation args have no null/undefined-as-skip semantics (that
|
|
907
|
+
// is useResource's domain), so widening to NonNullable<Args>
|
|
908
|
+
// is safe even if the consumer's Args generic admits null.
|
|
909
|
+
args, controller.signal, retryConfig, currentFn);
|
|
910
|
+
// Supersede check — only the LATEST in-flight updates handle state.
|
|
911
|
+
// Older overlapping calls still resolve their own promise (the caller
|
|
912
|
+
// gets the return value) but don't disturb data/error/isPending.
|
|
913
|
+
if (inFlightRef.current === myCtrl) {
|
|
914
|
+
inFlightRef.current = null;
|
|
915
|
+
setState({ data: result, error: undefined, isPending: false });
|
|
916
|
+
// §7 — invalidate fires AFTER setState (the promise resolves after
|
|
917
|
+
// invalidation is initiated). RES014 (the unknown-name backstop behind
|
|
918
|
+
// the compile-time `.reactra/resource-names.d.ts` net) now lives in
|
|
919
|
+
// `cache.invalidate` itself, so BOTH this path and the command-emitted
|
|
920
|
+
// `__getResourceCache().invalidate(...)` get the same warning.
|
|
921
|
+
const inv = currentOpts.invalidate;
|
|
922
|
+
if (inv !== undefined && inv.length > 0) {
|
|
923
|
+
__currentCache.invalidate(inv);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return result;
|
|
927
|
+
}
|
|
928
|
+
catch (err) {
|
|
929
|
+
if (inFlightRef.current === myCtrl) {
|
|
930
|
+
inFlightRef.current = null;
|
|
931
|
+
setState((s) => ({ data: s.data, error: err, isPending: false }));
|
|
932
|
+
}
|
|
933
|
+
throw err;
|
|
934
|
+
}
|
|
935
|
+
}, []);
|
|
936
|
+
return { mutate, isPending: state.isPending, data: state.data, error: state.error, reset };
|
|
937
|
+
};
|
|
938
|
+
/**
|
|
939
|
+
* React class component used by compiled `await(r) error(e) { … }` blocks.
|
|
940
|
+
* Phase-1 surface: catches render-time errors below the boundary, then
|
|
941
|
+
* renders `fallback(error)`. No reset API yet — a fresh boundary is created
|
|
942
|
+
* on every parent re-render that re-emits a new JSX subtree.
|
|
943
|
+
*
|
|
944
|
+
* Resource v0 §4: errors thrown inside `fn` reject the promise; React.use()
|
|
945
|
+
* re-throws on subsequent render; this boundary catches the re-throw.
|
|
946
|
+
*/
|
|
947
|
+
export class ErrorBoundary extends React.Component {
|
|
948
|
+
state = { error: null };
|
|
949
|
+
static getDerivedStateFromError = (error) => ({ error });
|
|
950
|
+
render() {
|
|
951
|
+
if (this.state.error != null)
|
|
952
|
+
return this.props.fallback(this.state.error);
|
|
953
|
+
return this.props.children;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
//# sourceMappingURL=index.js.map
|