@plutoframework/frontend 0.1.3 → 0.2.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/README.md +68 -0
- package/dist/actions/index.d.ts +15 -0
- package/dist/actions/index.js +177 -0
- package/dist/actions/types.d.ts +28 -0
- package/dist/actions/types.js +1 -0
- package/dist/query/index.d.ts +16 -0
- package/dist/query/index.js +12 -0
- package/dist/react/actions.d.ts +14 -0
- package/dist/react/actions.js +42 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +1 -0
- package/package.json +16 -3
package/README.md
CHANGED
|
@@ -213,3 +213,71 @@ code below. JavaScript and TypeScript lint/format checks enforce this convention
|
|
|
213
213
|
including the start of a function or branch. Inline trailing comments are kept
|
|
214
214
|
in place, and next-line compiler/linter directives retain their target. The
|
|
215
215
|
cross-language convention is also recorded in AGENTS.md for future changes.
|
|
216
|
+
|
|
217
|
+
## Responsive actions
|
|
218
|
+
|
|
219
|
+
Use `@plutoframework/frontend/actions` for a dependency-free action controller,
|
|
220
|
+
`useActions` from `/react` for lifecycle-owned React feedback, or
|
|
221
|
+
`useActionMutation` from `/query` to receive your existing TanStack Query client.
|
|
222
|
+
The query entrypoint requires the optional React and TanStack Query v5 peers;
|
|
223
|
+
state/actions consumers install neither. These helpers do not add a transport,
|
|
224
|
+
automatic retries or backend authorization.
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
import { useActionMutation } from "@plutoframework/frontend/query";
|
|
228
|
+
|
|
229
|
+
const action = useActionMutation({
|
|
230
|
+
scope: JSON.stringify([actor, session, tenant]),
|
|
231
|
+
execute: (id: string, { signal }) => removeRecord(id, signal),
|
|
232
|
+
confirm: (_result, id, client) => applyConfirmedRemoval(client, id),
|
|
233
|
+
reconcile: (_id, client) =>
|
|
234
|
+
client.invalidateQueries({ queryKey: recordsKey }),
|
|
235
|
+
classifyError: (error) =>
|
|
236
|
+
isDefiniteRejection(error) ? "rejected" : "uncertain",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const rows = action.project(query.data ?? [], {
|
|
240
|
+
mode: "optimistic",
|
|
241
|
+
project: (current, id) => current.filter((row) => row.id !== id),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Default: render canonical rows and show entry.status === "pending" per row.
|
|
245
|
+
// Opt in to the projection only when immediate removal is appropriate.
|
|
246
|
+
void action.run(record.id, record.id);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The snippet's transport, cache updater, query key and rejection classifier belong
|
|
250
|
+
to the feature; include actor/session/tenant in query keys. `scope` must change
|
|
251
|
+
when captured resource authority changes. Each resource key is locked while
|
|
252
|
+
pending or uncertain; separate keys can run concurrently. Controls must use
|
|
253
|
+
`ready` and entry status, acknowledge immediately with accessible pending text,
|
|
254
|
+
preserve input on failure and never announce success before confirmation.
|
|
255
|
+
|
|
256
|
+
`run` resolves to whether the action was accepted, **not whether it succeeded**.
|
|
257
|
+
Read the entry's outcome. `confirm` applies authoritative response data
|
|
258
|
+
synchronously before success; reconciliation runs in the background. A refresh
|
|
259
|
+
failure is `refreshError`, never a failed business write. Handle it visibly with
|
|
260
|
+
a refresh option; no detached promise rejection is left unhandled.
|
|
261
|
+
|
|
262
|
+
Optimistic projections run over current data without changing the cache. Rejection
|
|
263
|
+
removes only that action's projection, preserving concurrent changes. Keep
|
|
264
|
+
projectors pure; filtered, sorted or paginated collections may need feature-specific
|
|
265
|
+
reconciliation. Success removes the projection, so update canonical data in
|
|
266
|
+
`confirm` before it disappears. Cross-key operations on the same logical entity
|
|
267
|
+
still need a host conflict policy.
|
|
268
|
+
|
|
269
|
+
Unknown errors default to **uncertain**: timeouts, disconnects and server errors
|
|
270
|
+
may happen after commit. Their projection remains and retries stay locked until
|
|
271
|
+
an authoritative read or same-key replay establishes the outcome; call
|
|
272
|
+
`resolve(key, "succeeded" | "rejected")` only then. A declared rejection permits
|
|
273
|
+
retry. `forget` releases only terminal entries; retention is bounded to 1,000
|
|
274
|
+
keys by default. Action keys deduplicate this controller only: keep stable backend
|
|
275
|
+
idempotency keys separately for the same business intent.
|
|
276
|
+
|
|
277
|
+
Constructors perform no effects. React owns a fresh store for each committed
|
|
278
|
+
scope and disposes it on scope change/unmount, including StrictMode replay.
|
|
279
|
+
`ready` is false during initialization/SSR. Disposal aborts cooperative requests
|
|
280
|
+
and fences confirmations; it cannot undo a server write or already-started cache
|
|
281
|
+
refresh. Use scoped queries and cancel/remove old reads in the host when changing
|
|
282
|
+
identity. Headless hosts own disposal themselves. No persistence, cross-component
|
|
283
|
+
locking, offline queue or exactly-once guarantee is implied.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ActionEntry, ActionOptions, Feedback } from "./types.js";
|
|
2
|
+
export type { ActionEntry, ActionOptions, FailureKind, Feedback, } from "./types.js";
|
|
3
|
+
/** One store belongs to one actor/tenant scope; projections never mutate cache. */
|
|
4
|
+
export declare function createActions<Input, Output>(options: ActionOptions<Input, Output>): {
|
|
5
|
+
getSnapshot: () => readonly ActionEntry<Input, Output>[];
|
|
6
|
+
getInitialSnapshot: () => readonly ActionEntry<Input, Output>[];
|
|
7
|
+
subscribe: (listener: () => void) => () => void;
|
|
8
|
+
run(key: string, input: Input): Promise<boolean>;
|
|
9
|
+
forget: (key: string) => Promise<boolean>;
|
|
10
|
+
/** Host must establish the outcome through authoritative reconciliation. */
|
|
11
|
+
resolve(key: string, status: "succeeded" | "rejected"): Promise<boolean>;
|
|
12
|
+
project<View>(current: View, feedback: Feedback<Input, View>): View;
|
|
13
|
+
dispose(): void;
|
|
14
|
+
};
|
|
15
|
+
export type Actions<Input, Output> = ReturnType<typeof createActions<Input, Output>>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createMachine } from "../state/index.js";
|
|
2
|
+
/** One store belongs to one actor/tenant scope; projections never mutate cache. */
|
|
3
|
+
export function createActions(options) {
|
|
4
|
+
const capacity = options.capacity ?? 1000;
|
|
5
|
+
if (!Number.isSafeInteger(capacity) || capacity < 1) {
|
|
6
|
+
throw new Error("Action capacity must be a positive integer");
|
|
7
|
+
}
|
|
8
|
+
let attempt = 0;
|
|
9
|
+
let disposed = false;
|
|
10
|
+
const initial = [];
|
|
11
|
+
const store = createMachine({
|
|
12
|
+
initial,
|
|
13
|
+
transition(current, event) {
|
|
14
|
+
const key = event.type === "forget" ? event.key : event.entry.key;
|
|
15
|
+
const previous = current.find((entry) => entry.key === key);
|
|
16
|
+
const locked = previous?.status === "pending" || previous?.status === "uncertain";
|
|
17
|
+
if (event.type === "start") {
|
|
18
|
+
if (locked || (!previous && current.length >= capacity)) {
|
|
19
|
+
return {
|
|
20
|
+
accepted: false,
|
|
21
|
+
state: current,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
accepted: true,
|
|
26
|
+
state: [...current.filter((entry) => entry.key !== key), event.entry],
|
|
27
|
+
effect: event.entry,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (event.type === "forget") {
|
|
31
|
+
if (!previous || locked) {
|
|
32
|
+
return {
|
|
33
|
+
accepted: false,
|
|
34
|
+
state: current,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
accepted: true,
|
|
39
|
+
state: current.filter((entry) => entry.key !== key),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (!previous || previous.attempt !== event.entry.attempt) {
|
|
43
|
+
return {
|
|
44
|
+
accepted: false,
|
|
45
|
+
state: current,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
accepted: true,
|
|
50
|
+
state: current.map((entry) => entry.key === key ? event.entry : entry),
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
async execute(entry, { signal, send }) {
|
|
54
|
+
let completed;
|
|
55
|
+
try {
|
|
56
|
+
const output = await options.execute(entry.input, { signal });
|
|
57
|
+
if (signal.aborted) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let refreshError;
|
|
61
|
+
try {
|
|
62
|
+
options.confirm?.(output, entry.input);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
refreshError = error;
|
|
66
|
+
}
|
|
67
|
+
completed = {
|
|
68
|
+
...entry,
|
|
69
|
+
status: "succeeded",
|
|
70
|
+
output,
|
|
71
|
+
...(refreshError === undefined ? {} : { refreshError }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (signal.aborted) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
let status = "uncertain";
|
|
79
|
+
try {
|
|
80
|
+
status = options.classifyError?.(error) ?? "uncertain";
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Classification failure must not turn uncertainty into rejection.
|
|
84
|
+
}
|
|
85
|
+
completed = {
|
|
86
|
+
...entry,
|
|
87
|
+
status,
|
|
88
|
+
error,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (signal.aborted ||
|
|
92
|
+
!(await send({
|
|
93
|
+
type: "finish",
|
|
94
|
+
entry: completed,
|
|
95
|
+
}))) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Do not keep an acknowledged action pending while canonical reads refresh.
|
|
99
|
+
void Promise.resolve().then(async () => {
|
|
100
|
+
if (signal.aborted || disposed) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await options.reconcile?.(entry.input);
|
|
105
|
+
}
|
|
106
|
+
catch (refreshError) {
|
|
107
|
+
if (!signal.aborted && !disposed) {
|
|
108
|
+
const latest = store
|
|
109
|
+
.getSnapshot()
|
|
110
|
+
.find((item) => item.key === entry.key);
|
|
111
|
+
if (latest?.attempt === entry.attempt) {
|
|
112
|
+
await store.send({
|
|
113
|
+
type: "finish",
|
|
114
|
+
entry: {
|
|
115
|
+
...latest,
|
|
116
|
+
refreshError,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
getSnapshot: store.getSnapshot,
|
|
127
|
+
getInitialSnapshot: store.getInitialSnapshot,
|
|
128
|
+
subscribe: store.subscribe,
|
|
129
|
+
run(key, input) {
|
|
130
|
+
if (!key) {
|
|
131
|
+
return Promise.reject(new Error("An action key is required"));
|
|
132
|
+
}
|
|
133
|
+
return store.send({
|
|
134
|
+
type: "start",
|
|
135
|
+
entry: {
|
|
136
|
+
key,
|
|
137
|
+
input,
|
|
138
|
+
attempt: ++attempt,
|
|
139
|
+
status: "pending",
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
forget: (key) => store.send({
|
|
144
|
+
type: "forget",
|
|
145
|
+
key,
|
|
146
|
+
}),
|
|
147
|
+
/** Host must establish the outcome through authoritative reconciliation. */
|
|
148
|
+
resolve(key, status) {
|
|
149
|
+
const entry = store.getSnapshot().find((item) => item.key === key);
|
|
150
|
+
if (entry?.status !== "uncertain") {
|
|
151
|
+
return Promise.resolve(false);
|
|
152
|
+
}
|
|
153
|
+
return store.send({
|
|
154
|
+
type: "finish",
|
|
155
|
+
entry: {
|
|
156
|
+
...entry,
|
|
157
|
+
status,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
project(current, feedback) {
|
|
162
|
+
if (feedback.mode === "pending") {
|
|
163
|
+
return current;
|
|
164
|
+
}
|
|
165
|
+
return store.getSnapshot().reduce((view, entry) => {
|
|
166
|
+
if (entry.status !== "pending" && entry.status !== "uncertain") {
|
|
167
|
+
return view;
|
|
168
|
+
}
|
|
169
|
+
return feedback.project(view, entry.input);
|
|
170
|
+
}, current);
|
|
171
|
+
},
|
|
172
|
+
dispose() {
|
|
173
|
+
disposed = true;
|
|
174
|
+
store.dispose();
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type FailureKind = "rejected" | "uncertain";
|
|
2
|
+
export interface ActionEntry<Input, Output> {
|
|
3
|
+
readonly key: string;
|
|
4
|
+
readonly attempt: number;
|
|
5
|
+
readonly input: Input;
|
|
6
|
+
readonly status: "pending" | "succeeded" | FailureKind;
|
|
7
|
+
readonly output?: Output;
|
|
8
|
+
readonly error?: unknown;
|
|
9
|
+
readonly refreshError?: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface ActionOptions<Input, Output> {
|
|
12
|
+
readonly execute: (input: Input, context: {
|
|
13
|
+
readonly signal: AbortSignal;
|
|
14
|
+
}) => Promise<Output>;
|
|
15
|
+
/** Only classify as rejected when the server definitely refused the write. */
|
|
16
|
+
readonly classifyError?: (error: unknown) => FailureKind;
|
|
17
|
+
/** Apply authoritative response data synchronously before success is visible. */
|
|
18
|
+
readonly confirm?: (output: Output, input: Input) => void;
|
|
19
|
+
/** Refresh in the background. Failure never reclassifies a confirmed write. */
|
|
20
|
+
readonly reconcile?: (input: Input) => void | Promise<void>;
|
|
21
|
+
readonly capacity?: number;
|
|
22
|
+
}
|
|
23
|
+
export type Feedback<Input, View> = {
|
|
24
|
+
readonly mode: "pending";
|
|
25
|
+
} | {
|
|
26
|
+
readonly mode: "optimistic";
|
|
27
|
+
readonly project: (current: View, input: Input) => View;
|
|
28
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { ScopedActionOptions } from "../react/actions.js";
|
|
3
|
+
export interface ActionMutationOptions<Input, Output> extends Omit<ScopedActionOptions<Input, Output>, "confirm" | "reconcile"> {
|
|
4
|
+
readonly confirm?: (output: Output, input: Input, client: QueryClient) => void;
|
|
5
|
+
readonly reconcile?: (input: Input, client: QueryClient) => void | Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
/** Query integration is optional; use existing feature transport functions. */
|
|
8
|
+
export declare function useActionMutation<Input, Output>(options: ActionMutationOptions<Input, Output>): {
|
|
9
|
+
ready: boolean;
|
|
10
|
+
entries: readonly import("../actions/types.js").ActionEntry<Input, Output>[];
|
|
11
|
+
get: (key: string) => import("../actions/types.js").ActionEntry<Input, Output> | undefined;
|
|
12
|
+
run: (key: string, input: Input) => Promise<boolean>;
|
|
13
|
+
resolve: (key: string, status: "succeeded" | "rejected") => Promise<boolean>;
|
|
14
|
+
forget: (key: string) => Promise<boolean>;
|
|
15
|
+
project<View>(value: View, feedback: import("../actions/types.js").Feedback<Input, View>): View;
|
|
16
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useActions } from "../react/actions.js";
|
|
4
|
+
/** Query integration is optional; use existing feature transport functions. */
|
|
5
|
+
export function useActionMutation(options) {
|
|
6
|
+
const client = useQueryClient();
|
|
7
|
+
return useActions({
|
|
8
|
+
...options,
|
|
9
|
+
confirm: (output, input) => options.confirm?.(output, input, client),
|
|
10
|
+
reconcile: (input) => options.reconcile?.(input, client),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ActionEntry, ActionOptions, Feedback } from "../actions/index.js";
|
|
2
|
+
export interface ScopedActionOptions<Input, Output> extends ActionOptions<Input, Output> {
|
|
3
|
+
readonly scope: string;
|
|
4
|
+
}
|
|
5
|
+
/** Owns one action store per committed identity/tenant scope, including StrictMode. */
|
|
6
|
+
export declare function useActions<Input, Output>(options: ScopedActionOptions<Input, Output>): {
|
|
7
|
+
ready: boolean;
|
|
8
|
+
entries: readonly ActionEntry<Input, Output>[];
|
|
9
|
+
get: (key: string) => ActionEntry<Input, Output> | undefined;
|
|
10
|
+
run: (key: string, input: Input) => Promise<boolean>;
|
|
11
|
+
resolve: (key: string, status: "succeeded" | "rejected") => Promise<boolean>;
|
|
12
|
+
forget: (key: string) => Promise<boolean>;
|
|
13
|
+
project<View>(value: View, feedback: Feedback<Input, View>): View;
|
|
14
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
|
|
3
|
+
import { createActions } from "../actions/index.js";
|
|
4
|
+
const useCommittedEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
5
|
+
const empty = [];
|
|
6
|
+
const subscribeEmpty = () => () => { };
|
|
7
|
+
const getEmpty = () => empty;
|
|
8
|
+
/** Owns one action store per committed identity/tenant scope, including StrictMode. */
|
|
9
|
+
export function useActions(options) {
|
|
10
|
+
const current = useRef(options);
|
|
11
|
+
const [owned, setOwned] = useState(null);
|
|
12
|
+
useCommittedEffect(() => {
|
|
13
|
+
current.current = options;
|
|
14
|
+
});
|
|
15
|
+
useCommittedEffect(() => {
|
|
16
|
+
const store = createActions({
|
|
17
|
+
execute: (input, context) => current.current.execute(input, context),
|
|
18
|
+
classifyError: (error) => current.current.classifyError?.(error) ?? "uncertain",
|
|
19
|
+
confirm: (output, input) => current.current.confirm?.(output, input),
|
|
20
|
+
reconcile: (input) => current.current.reconcile?.(input),
|
|
21
|
+
...(options.capacity === undefined ? {} : { capacity: options.capacity }),
|
|
22
|
+
});
|
|
23
|
+
setOwned({
|
|
24
|
+
scope: options.scope,
|
|
25
|
+
store,
|
|
26
|
+
});
|
|
27
|
+
return () => store.dispose();
|
|
28
|
+
}, [options.scope, options.capacity]);
|
|
29
|
+
const store = owned?.scope === options.scope ? owned.store : null;
|
|
30
|
+
const entries = useSyncExternalStore(store?.subscribe ?? subscribeEmpty, store?.getSnapshot ?? getEmpty, store?.getInitialSnapshot ?? getEmpty);
|
|
31
|
+
return {
|
|
32
|
+
ready: store !== null,
|
|
33
|
+
entries,
|
|
34
|
+
get: (key) => store?.getSnapshot().find((entry) => entry.key === key),
|
|
35
|
+
run: (key, input) => store?.run(key, input) ?? Promise.resolve(false),
|
|
36
|
+
resolve: (key, status) => store?.resolve(key, status) ?? Promise.resolve(false),
|
|
37
|
+
forget: (key) => store?.forget(key) ?? Promise.resolve(false),
|
|
38
|
+
project(value, feedback) {
|
|
39
|
+
return store?.project(value, feedback) ?? value;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
package/dist/react/index.d.ts
CHANGED
package/dist/react/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutoframework/frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Explicit, typed frontend state transitions and React subscriptions for Pluto applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,17 +23,29 @@
|
|
|
23
23
|
"./react": {
|
|
24
24
|
"types": "./dist/react/index.d.ts",
|
|
25
25
|
"import": "./dist/react/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./actions": {
|
|
28
|
+
"types": "./dist/actions/index.d.ts",
|
|
29
|
+
"import": "./dist/actions/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./query": {
|
|
32
|
+
"types": "./dist/query/index.d.ts",
|
|
33
|
+
"import": "./dist/query/index.js"
|
|
26
34
|
}
|
|
27
35
|
},
|
|
28
36
|
"publishConfig": {
|
|
29
37
|
"access": "public"
|
|
30
38
|
},
|
|
31
39
|
"peerDependencies": {
|
|
32
|
-
"react": "^18.3.1 || ^19.0.0"
|
|
40
|
+
"react": "^18.3.1 || ^19.0.0",
|
|
41
|
+
"@tanstack/react-query": "^5.105.0"
|
|
33
42
|
},
|
|
34
43
|
"peerDependenciesMeta": {
|
|
35
44
|
"react": {
|
|
36
45
|
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@tanstack/react-query": {
|
|
48
|
+
"optional": true
|
|
37
49
|
}
|
|
38
50
|
},
|
|
39
51
|
"scripts": {
|
|
@@ -59,6 +71,7 @@
|
|
|
59
71
|
"react-dom": "19.3.1",
|
|
60
72
|
"typescript": "5.9.3",
|
|
61
73
|
"typescript-eslint": "8.72.1",
|
|
62
|
-
"vitest": "5.0.5"
|
|
74
|
+
"vitest": "5.0.5",
|
|
75
|
+
"@tanstack/react-query": "5.105.0"
|
|
63
76
|
}
|
|
64
77
|
}
|