@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/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