@lesto/ui 0.1.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/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@lesto/ui",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "Lesto's AI-native UI rendering engine core — validate a JSON UI tree and render it to React against a vetted component registry.",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.ts",
10
+ "import": "./src/index.ts"
11
+ },
12
+ "./client": {
13
+ "types": "./src/client.ts",
14
+ "import": "./src/client.ts"
15
+ },
16
+ "./server": {
17
+ "types": "./src/server.ts",
18
+ "import": "./src/server.ts"
19
+ }
20
+ },
21
+ "dependencies": {
22
+ "@lesto/errors": "workspace:*",
23
+ "@lesto/router": "workspace:*",
24
+ "react": "^19",
25
+ "react-dom": "^19"
26
+ },
27
+ "peerDependencies": {
28
+ "preact-render-to-string": "^6"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "preact-render-to-string": {
32
+ "optional": true
33
+ }
34
+ },
35
+ "scripts": {
36
+ "test": "vitest run",
37
+ "test:cov": "vitest run --coverage",
38
+ "typecheck": "tsc --noEmit",
39
+ "lint": "oxlint src test",
40
+ "format": "oxfmt src test",
41
+ "format:check": "oxfmt --check src test"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "files": [
47
+ "src"
48
+ ],
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/lesto-run/lesto.git",
52
+ "directory": "packages/ui"
53
+ },
54
+ "homepage": "https://lesto.run",
55
+ "bugs": {
56
+ "url": "https://github.com/lesto-run/lesto/issues"
57
+ }
58
+ }
package/src/bfcache.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * bfcache-safe page lifecycle.
3
+ *
4
+ * The back/forward cache (bfcache) keeps a fully-formed page in memory when the
5
+ * user navigates away, so Back/Forward is instant. A page is DISQUALIFIED the
6
+ * moment it registers an `unload` or `beforeunload` listener — those handlers
7
+ * were the historical way to "clean up on leave," and they are exactly what this
8
+ * module refuses to let a Lesto client runtime use.
9
+ *
10
+ * The bfcache-friendly lifecycle is:
11
+ * - `pagehide` — the page is being hidden, possibly to enter the bfcache; check
12
+ * `event.persisted` to know which. The place to flush/persist, NOT to tear
13
+ * down listeners.
14
+ * - `pageshow` — the page is shown; `event.persisted === true` means it was
15
+ * restored FROM the bfcache (no fresh load fired), the cue to refresh
16
+ * per-visitor state (a live island may need to re-resolve the session).
17
+ * - `visibilitychange` — finer-grained hidden/visible, the modern signal for
18
+ * "save now, the user may not come back."
19
+ *
20
+ * This is the one place the framework's client code attaches lifecycle handlers,
21
+ * so the invariant ("never unload/beforeunload") lives in one auditable spot. The
22
+ * `target` (defaults to `window`) is injected so the whole thing is testable
23
+ * under jsdom against a fake event target — no real navigation needed.
24
+ */
25
+
26
+ /** The lifecycle moments a Lesto client runtime may react to. All bfcache-safe. */
27
+ export interface PageLifecycleHandlers {
28
+ /** The page is being hidden. `persisted` is true iff it is entering the bfcache. */
29
+ onPageHide?: (persisted: boolean) => void;
30
+
31
+ /** The page is being shown. `persisted` is true iff it was RESTORED from bfcache. */
32
+ onPageShow?: (persisted: boolean) => void;
33
+
34
+ /** Visibility flipped. `visible` is true for "visible", false for "hidden". */
35
+ onVisibilityChange?: (visible: boolean) => void;
36
+ }
37
+
38
+ /** The minimal event-target surface we attach to — `window` satisfies it. */
39
+ export interface LifecycleTarget {
40
+ addEventListener(type: string, listener: (event: Event) => void): void;
41
+ removeEventListener(type: string, listener: (event: Event) => void): void;
42
+ }
43
+
44
+ /** Detach every listener `observePageLifecycle` attached. Idempotent. */
45
+ export type StopLifecycle = () => void;
46
+
47
+ /** Options: where to listen, and how to read document visibility (both injectable). */
48
+ export interface ObserveOptions {
49
+ target?: LifecycleTarget;
50
+ /** Reads the current visibility state; defaults to `document.visibilityState`. */
51
+ visibilityState?: () => string;
52
+ }
53
+
54
+ /**
55
+ * Attach bfcache-safe lifecycle listeners and return a `stop()` that removes them.
56
+ *
57
+ * Only the handlers you provide are wired — an absent handler attaches no
58
+ * listener at all. The function attaches NOTHING to `unload`/`beforeunload`,
59
+ * which is the property that keeps the page bfcache-eligible.
60
+ *
61
+ * `pageshow`/`pagehide` carry a `persisted` flag on the native `PageTransitionEvent`;
62
+ * we read it defensively (treating a missing flag as `false`) so the helper holds
63
+ * up under a bare fake target whose events do not carry it.
64
+ */
65
+ export function observePageLifecycle(
66
+ handlers: PageLifecycleHandlers,
67
+ options: ObserveOptions = {},
68
+ ): StopLifecycle {
69
+ const target: LifecycleTarget = options.target ?? window;
70
+
71
+ const readVisibility: () => string = options.visibilityState ?? (() => document.visibilityState);
72
+
73
+ // Every (type, listener) we attach, recorded so `stop()` can remove exactly
74
+ // these and nothing else — a clean teardown with no global state.
75
+ const attached: Array<[string, (event: Event) => void]> = [];
76
+
77
+ const listen = (type: string, listener: (event: Event) => void): void => {
78
+ target.addEventListener(type, listener);
79
+
80
+ attached.push([type, listener]);
81
+ };
82
+
83
+ if (handlers.onPageHide !== undefined) {
84
+ const handler = handlers.onPageHide;
85
+
86
+ listen("pagehide", (event) => handler(isPersisted(event)));
87
+ }
88
+
89
+ if (handlers.onPageShow !== undefined) {
90
+ const handler = handlers.onPageShow;
91
+
92
+ listen("pageshow", (event) => handler(isPersisted(event)));
93
+ }
94
+
95
+ if (handlers.onVisibilityChange !== undefined) {
96
+ const handler = handlers.onVisibilityChange;
97
+
98
+ listen("visibilitychange", () => handler(readVisibility() === "visible"));
99
+ }
100
+
101
+ return () => {
102
+ for (const [type, listener] of attached) {
103
+ target.removeEventListener(type, listener);
104
+ }
105
+
106
+ // Drop our references so a second stop() is a harmless no-op.
107
+ attached.length = 0;
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Read a `pageshow`/`pagehide` event's `persisted` flag, defaulting to `false`.
113
+ *
114
+ * The native event is a `PageTransitionEvent` carrying `persisted: boolean`; a
115
+ * plain `Event` (or a test double) has no such field, and "not persisted" is the
116
+ * safe assumption — it means "treat this as a normal hide/show," never "restored
117
+ * from cache."
118
+ */
119
+ function isPersisted(event: Event): boolean {
120
+ return (event as { persisted?: boolean }).persisted === true;
121
+ }
package/src/client.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `@lesto/ui/client` — the browser-only runtime.
3
+ *
4
+ * This barrel gathers everything that touches the DOM (`document`, `window`,
5
+ * React's client renderer) behind one subpath, so a server importer of
6
+ * `@lesto/ui` never pulls DOM code into a build that lacks the DOM lib. Mirrors
7
+ * react-dom's server/client split.
8
+ *
9
+ * It re-exports the island hydration runtime and the bfcache-safe page-lifecycle
10
+ * helper — the two pieces of client code the framework ships.
11
+ */
12
+
13
+ export { hydrateDocumentIslands, hydrateIslands } from "./hydrate";
14
+ export type {
15
+ Disconnect,
16
+ HydrateOptions,
17
+ HydrationResult,
18
+ IslandRoot,
19
+ MountContext,
20
+ MountErrorSink,
21
+ MountFn,
22
+ ObserveFn,
23
+ RecoverableErrorSink,
24
+ } from "./hydrate";
25
+
26
+ export { observePageLifecycle } from "./bfcache";
27
+ export type {
28
+ LifecycleTarget,
29
+ ObserveOptions,
30
+ PageLifecycleHandlers,
31
+ StopLifecycle,
32
+ } from "./bfcache";
33
+
34
+ // Client-side soft navigation (ADR 0024): the browser runtime half of `<Link>`.
35
+ // `enableSoftNav` installs the delegated click listener that fetches + swaps the
36
+ // next page, re-hydrates its islands, and wires Back/Forward — all over injected
37
+ // seams, so it lives behind the DOM-only `/client` subpath alongside the hydration
38
+ // runtime it composes. `<Link>` and the DOM-free contract ship from the isomorphic
39
+ // barrel; everything that touches `fetch`/`document`/`history` is here.
40
+ export { enableSoftNav } from "./softnav";
41
+ export type {
42
+ DisableSoftNav,
43
+ FetchedPage,
44
+ PageFetcher,
45
+ PageSwapper,
46
+ PopStateTarget,
47
+ Rehydrate,
48
+ ScrollPosition,
49
+ SoftNavEvent,
50
+ SoftNavHistory,
51
+ SoftNavKind,
52
+ SoftNavOptions,
53
+ SoftNavWindow,
54
+ } from "./softnav";
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Client data hooks — `useQuery` / `useMutation` over a tiny shared cache.
3
+ *
4
+ * The smallest credible step of the reactive data layer (ADR 0027), and no more:
5
+ * islands today hand-roll `useState`+`useEffect` to fetch (a re-implemented loading/error
6
+ * machine per island, no sharing) and re-build a mutation client per submit. These
7
+ * hooks replace that with one cache that gives:
8
+ *
9
+ * - **in-flight dedupe** — N components asking for the same key while a request
10
+ * is in flight share ONE request, not N;
11
+ * - **an explicit-invalidation cache** — a resolved key stays cached until a
12
+ * mutation (or a manual `refetch`) invalidates it, so a re-mount or a sibling
13
+ * reading the same key paints instantly;
14
+ * - **`useMutation`** — `{ mutate, isPending, error, data }` with optimistic
15
+ * update + rollback and an `onSuccess` hook that typically invalidates keys.
16
+ *
17
+ * What this is NOT (so the doc never over-promises): it is NOT the full reactive
18
+ * layer. There is no schema-INFERRED invalidation (a mutation does not know which
19
+ * queries it dirties — you invalidate by key, explicitly), no normalized `(table, pk)`
20
+ * store, no automatic background revalidation, and no cache EVICTION — a key's snapshot +
21
+ * last-fetcher live for the page's lifetime (bounded by the distinct keys an app
22
+ * queries; fine for a per-session SPA, revisit if a long-lived app accumulates many).
23
+ * Those are the later ADR 0027 phases (server-pushed invalidation over LISTEN/NOTIFY,
24
+ * durable storage); this is the thin hook layer an app adopts now and they build on.
25
+ *
26
+ * Decoupled by design: the hooks never import `@lesto/client`. The caller passes a
27
+ * `fetcher` / `mutationFn` thunk (typically closing over a `createApi` /
28
+ * `createMutationClient` call), so this stays in the isomorphic `@lesto/ui` core
29
+ * with no new dependency and no server code dragged into the browser bundle.
30
+ */
31
+
32
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
33
+
34
+ /** A query key: a string, or a tuple serialized to one (the `["listing", id]` form). */
35
+ export type QueryKey = string | readonly (string | number | boolean)[];
36
+
37
+ /** A cache entry's lifecycle. `idle` = never fetched (or invalidated to empty). */
38
+ export type QueryStatus = "idle" | "loading" | "success" | "error";
39
+
40
+ /**
41
+ * The immutable snapshot a `useQuery` subscriber reads. A NEW object is published
42
+ * on every change (never mutated in place) so `useSyncExternalStore` re-renders
43
+ * exactly when the value changes and never loops on a stable read.
44
+ */
45
+ export interface QuerySnapshot<T> {
46
+ readonly status: QueryStatus;
47
+
48
+ readonly data?: T;
49
+
50
+ readonly error?: unknown;
51
+ }
52
+
53
+ /** The single shared "nothing here yet" snapshot — one frozen ref for every idle key. */
54
+ const IDLE_SNAPSHOT: QuerySnapshot<never> = Object.freeze({ status: "idle" });
55
+
56
+ /** Serialize a {@link QueryKey} to its cache string — a tuple becomes JSON. */
57
+ export function serializeQueryKey(key: QueryKey): string {
58
+ return typeof key === "string" ? key : JSON.stringify(key);
59
+ }
60
+
61
+ /**
62
+ * The cache + request coordinator a set of `useQuery`/`useMutation` hooks share.
63
+ *
64
+ * One instance backs every hook by default ({@link defaultQueryClient}); a test
65
+ * (or an app wanting an isolated cache) constructs its own and passes it via the
66
+ * hook's `client` option. Holds the published snapshots, the in-flight promises
67
+ * (for dedupe), the last fetcher per key (so `invalidate` can refetch), and the
68
+ * per-key subscriber sets — kept OUT of the snapshot so a re-render is driven only
69
+ * by a value change.
70
+ */
71
+ export class QueryClient {
72
+ readonly #snapshots = new Map<string, QuerySnapshot<unknown>>();
73
+
74
+ readonly #inflight = new Map<string, Promise<unknown>>();
75
+
76
+ readonly #fetchers = new Map<string, () => Promise<unknown>>();
77
+
78
+ readonly #listeners = new Map<string, Set<() => void>>();
79
+
80
+ /** The current published snapshot for `key` — the shared idle one until it has one. */
81
+ getSnapshot(key: string): QuerySnapshot<unknown> {
82
+ return this.#snapshots.get(key) ?? IDLE_SNAPSHOT;
83
+ }
84
+
85
+ /** Subscribe to `key`'s snapshot changes; returns an unsubscribe that prunes empty sets. */
86
+ subscribe(key: string, listener: () => void): () => void {
87
+ let set = this.#listeners.get(key);
88
+
89
+ if (set === undefined) {
90
+ set = new Set();
91
+ this.#listeners.set(key, set);
92
+ }
93
+
94
+ set.add(listener);
95
+
96
+ return () => {
97
+ set.delete(listener);
98
+
99
+ if (set.size === 0) this.#listeners.delete(key);
100
+ };
101
+ }
102
+
103
+ /** Read the cached value for `key` (used by an optimistic update to snapshot/rollback). */
104
+ getData(key: string): unknown {
105
+ return this.#snapshots.get(key)?.data;
106
+ }
107
+
108
+ /** Publish `data` as `key`'s value (an optimistic write, or priming a known value). */
109
+ setData(key: string, data: unknown): void {
110
+ this.#publish(key, { status: "success", data });
111
+ }
112
+
113
+ /**
114
+ * Fetch `key` through `fetcher`, SHARING an in-flight request: a second caller
115
+ * while a request is pending gets the same promise and issues no second request.
116
+ * A settled key always starts a fresh request (a `refetch`). The latest fetcher
117
+ * is remembered so {@link invalidate} can refetch without the caller re-passing it.
118
+ */
119
+ fetch(key: string, fetcher: () => Promise<unknown>): Promise<unknown> {
120
+ this.#fetchers.set(key, fetcher);
121
+
122
+ const existing = this.#inflight.get(key);
123
+
124
+ if (existing !== undefined) return existing;
125
+
126
+ // Keep the prior value visible while reloading (a refetch shows stale-then-fresh,
127
+ // not a flash of empty). A first load has no prior value, so `data` is undefined.
128
+ const previous = this.#snapshots.get(key);
129
+
130
+ this.#publish(key, { status: "loading", data: previous?.data });
131
+
132
+ const promise = fetcher().then(
133
+ (data) => {
134
+ this.#inflight.delete(key);
135
+ this.#publish(key, { status: "success", data });
136
+
137
+ return data;
138
+ },
139
+ (error: unknown) => {
140
+ this.#inflight.delete(key);
141
+ this.#publish(key, { status: "error", error });
142
+
143
+ throw error;
144
+ },
145
+ );
146
+
147
+ this.#inflight.set(key, promise);
148
+
149
+ // A deduped caller may never attach a handler; swallow the rejection on the
150
+ // STORED branch so it raises no `unhandledrejection`. The error is already on
151
+ // the snapshot, and a direct awaiter still sees the re-thrown rejection above.
152
+ promise.catch(() => {});
153
+
154
+ return promise;
155
+ }
156
+
157
+ /**
158
+ * Invalidate `key`: drop its cached value and refetch with its last fetcher, so
159
+ * every mounted `useQuery(key)` re-renders fresh. Explicit-only — a mutation
160
+ * names the keys it dirties; there is no inferred invalidation (a later ADR 0027 phase). A
161
+ * key never fetched (no remembered fetcher) is simply reset to idle.
162
+ */
163
+ invalidate(key: string): Promise<unknown> | undefined {
164
+ const fetcher = this.#fetchers.get(key);
165
+
166
+ if (fetcher === undefined) {
167
+ this.#publish(key, IDLE_SNAPSHOT);
168
+
169
+ return undefined;
170
+ }
171
+
172
+ return this.fetch(key, fetcher);
173
+ }
174
+
175
+ /** Publish a new snapshot for `key` and notify its subscribers. */
176
+ #publish(key: string, snapshot: QuerySnapshot<unknown>): void {
177
+ this.#snapshots.set(key, snapshot);
178
+
179
+ const set = this.#listeners.get(key);
180
+
181
+ if (set !== undefined) for (const listener of set) listener();
182
+ }
183
+ }
184
+
185
+ /** The cache every hook shares unless given its own `client` — the common case. */
186
+ export const defaultQueryClient = new QueryClient();
187
+
188
+ /** What `useQuery` returns. `data` is undefined until the first success. */
189
+ export interface QueryResult<T> {
190
+ data: T | undefined;
191
+
192
+ error: unknown;
193
+
194
+ isLoading: boolean;
195
+
196
+ /** Force a fresh fetch (deduped against any request already in flight). */
197
+ refetch: () => void;
198
+ }
199
+
200
+ /**
201
+ * Subscribe a component to `key`, fetching it through `fetcher` when the key is
202
+ * uncached. Concurrent callers of the same key share one request; a settled value
203
+ * stays cached until invalidated. SSR-safe: the fetch is kicked from an effect
204
+ * (never during render), so a server render reads the idle snapshot and never
205
+ * fetches — the client-only data island (`ssr: false`) is the intended caller.
206
+ *
207
+ * `isLoading` is true while uncached or in flight. A FRESH mount of an uncached
208
+ * OR previously-errored key kicks a fetch — so navigating away and back to a
209
+ * listing that failed transiently retries (matching a plain mount-effect fetch),
210
+ * while a SUCCESS stays cached (the dedupe/cache win). The fetch fires once per
211
+ * mount / key-change (the effect deps are only `[client, keyStr]`), never per
212
+ * render, so there is no retry storm; an in-mount retry is a manual
213
+ * {@link QueryResult.refetch}.
214
+ */
215
+ export function useQuery<T>(
216
+ key: QueryKey,
217
+ fetcher: () => Promise<T>,
218
+ options?: { client?: QueryClient },
219
+ ): QueryResult<T> {
220
+ const client = options?.client ?? defaultQueryClient;
221
+ const keyStr = serializeQueryKey(key);
222
+
223
+ // The latest fetcher closure, read through a ref so `refetch`/the effect always
224
+ // run the current one without re-subscribing when the closure identity changes.
225
+ const fetcherRef = useRef(fetcher);
226
+ fetcherRef.current = fetcher;
227
+
228
+ // One getSnapshot for both client and server reads (same idle/cached value), so
229
+ // there is a single function to cover and no SSR/CSR snapshot divergence.
230
+ const getSnapshot = useCallback(
231
+ () => client.getSnapshot(keyStr) as QuerySnapshot<T>,
232
+ [client, keyStr],
233
+ );
234
+
235
+ const snapshot = useSyncExternalStore(
236
+ useCallback((onChange) => client.subscribe(keyStr, onChange), [client, keyStr]),
237
+ getSnapshot,
238
+ getSnapshot,
239
+ );
240
+
241
+ useEffect(() => {
242
+ // Fetch on a fresh mount when the key is uncached (`idle`) OR previously
243
+ // errored — so a remount retries a transient failure rather than showing a
244
+ // terminal stale error. A `loading`/`success` key is left alone: a sibling
245
+ // already has it in flight or cached (the dedupe at mount time).
246
+ const status = client.getSnapshot(keyStr).status;
247
+
248
+ if (status === "idle" || status === "error") {
249
+ void client.fetch(keyStr, () => fetcherRef.current());
250
+ }
251
+ }, [client, keyStr]);
252
+
253
+ const refetch = useCallback(() => {
254
+ void client.fetch(keyStr, () => fetcherRef.current());
255
+ }, [client, keyStr]);
256
+
257
+ return {
258
+ data: snapshot.data,
259
+ error: snapshot.error,
260
+ isLoading: snapshot.status === "idle" || snapshot.status === "loading",
261
+ refetch,
262
+ };
263
+ }
264
+
265
+ /** `useMutation`'s lifecycle. `idle` until the first `mutate`. */
266
+ export type MutationStatus = "idle" | "pending" | "success" | "error";
267
+
268
+ /** Options for {@link useMutation} — the optimistic + invalidate hooks. */
269
+ export interface MutationOptions<Input, Data> {
270
+ /**
271
+ * Run BEFORE the request (the optimistic write, typically `client.setData`).
272
+ * Return a rollback thunk and it is invoked if the request fails — so an
273
+ * optimistic update is undone on error. Return nothing to skip rollback.
274
+ */
275
+ onMutate?: (input: Input) => (() => void) | void;
276
+
277
+ /** Run after success — typically `client.invalidate(key)` to revalidate reads. */
278
+ onSuccess?: (data: Data, input: Input) => void;
279
+
280
+ /** Run after a failed request (after any rollback). */
281
+ onError?: (error: unknown, input: Input) => void;
282
+ }
283
+
284
+ /** What `useMutation` returns. */
285
+ export interface MutationResultApi<Input, Data> {
286
+ /** Run the mutation. Resolves to the data on success, or `undefined` on error
287
+ * (the error lands on {@link error}) — so a caller needs no try/catch. */
288
+ mutate: (input: Input) => Promise<Data | undefined>;
289
+
290
+ /** Clear back to idle (drop the last result/error). */
291
+ reset: () => void;
292
+
293
+ data: Data | undefined;
294
+
295
+ error: unknown;
296
+
297
+ isPending: boolean;
298
+
299
+ status: MutationStatus;
300
+ }
301
+
302
+ /**
303
+ * Manage one write: `mutate(input)` flips `isPending`, runs `mutationFn`, and lands
304
+ * the result on `data` or the throw on `error` (never re-thrown — the result-union
305
+ * style). Supports an optimistic `onMutate` (with rollback on failure) and an
306
+ * `onSuccess`/`onError` pair (the former usually invalidates queries).
307
+ *
308
+ * `mutationFn` may itself return a discriminated result (e.g. a `@lesto/client`
309
+ * mutation's `{ ok, … }` union) — that union simply becomes `data`; throw inside
310
+ * `mutationFn` to drive the `error` path instead.
311
+ */
312
+ export function useMutation<Input, Data>(
313
+ mutationFn: (input: Input) => Promise<Data>,
314
+ options?: MutationOptions<Input, Data>,
315
+ ): MutationResultApi<Input, Data> {
316
+ const [state, setState] = useState<{ status: MutationStatus; data?: Data; error?: unknown }>({
317
+ status: "idle",
318
+ });
319
+
320
+ // Latest fn/options through refs so `mutate` is a stable callback but always
321
+ // sees the current closure (props captured by the island, e.g. the current row).
322
+ const fnRef = useRef(mutationFn);
323
+ fnRef.current = mutationFn;
324
+
325
+ const optionsRef = useRef(options);
326
+ optionsRef.current = options;
327
+
328
+ const mutate = useCallback(async (input: Input): Promise<Data | undefined> => {
329
+ setState({ status: "pending" });
330
+
331
+ const rollback = optionsRef.current?.onMutate?.(input);
332
+
333
+ try {
334
+ const data = await fnRef.current(input);
335
+
336
+ setState({ status: "success", data });
337
+ optionsRef.current?.onSuccess?.(data, input);
338
+
339
+ return data;
340
+ } catch (error) {
341
+ // Undo the optimistic write (if any), publish the error, notify — no re-throw.
342
+ if (typeof rollback === "function") rollback();
343
+
344
+ setState({ status: "error", error });
345
+ optionsRef.current?.onError?.(error, input);
346
+
347
+ return undefined;
348
+ }
349
+ }, []);
350
+
351
+ const reset = useCallback(() => setState({ status: "idle" }), []);
352
+
353
+ return {
354
+ mutate,
355
+ reset,
356
+ data: state.data,
357
+ error: state.error,
358
+ isPending: state.status === "pending",
359
+ status: state.status,
360
+ };
361
+ }