@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 +58 -0
- package/src/bfcache.ts +121 -0
- package/src/client.ts +54 -0
- package/src/data-client.ts +361 -0
- package/src/data-resolve.tsx +120 -0
- package/src/data.ts +183 -0
- package/src/define-island.tsx +222 -0
- package/src/errors.ts +36 -0
- package/src/hydrate.tsx +563 -0
- package/src/index.ts +165 -0
- package/src/island.ts +258 -0
- package/src/link.tsx +103 -0
- package/src/metadata.ts +195 -0
- package/src/mount.ts +99 -0
- package/src/node.ts +8 -0
- package/src/props.ts +95 -0
- package/src/registry.ts +141 -0
- package/src/render.tsx +349 -0
- package/src/resources.ts +233 -0
- package/src/route.ts +62 -0
- package/src/routes.ts +101 -0
- package/src/schema.ts +244 -0
- package/src/serialize.ts +148 -0
- package/src/server-preact.ts +59 -0
- package/src/server.ts +44 -0
- package/src/softnav-contract.ts +144 -0
- package/src/softnav.ts +934 -0
- package/src/stream.tsx +387 -0
- package/src/types.ts +46 -0
- package/src/validate.ts +107 -0
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
|
+
}
|