@plutoframework/frontend 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +197 -2
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.js +7 -0
- package/dist/state/index.d.ts +2 -0
- package/dist/state/index.js +1 -0
- package/dist/state/machine.d.ts +3 -0
- package/dist/state/machine.js +94 -0
- package/dist/state/types.d.ts +32 -0
- package/dist/state/types.js +1 -0
- package/package.json +57 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pluto Framework contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,198 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pluto Frontend
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Typed frontend primitives for Pluto applications. Start with explicit UI state
|
|
4
|
+
transitions and effects; subscribe from React when needed.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install @plutoframework/frontend
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { createMachine } from "@plutoframework/frontend/state";
|
|
12
|
+
import type { Decision } from "@plutoframework/frontend/state";
|
|
13
|
+
import { useMachine } from "@plutoframework/frontend/react";
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The state entrypoint has **zero runtime dependencies**. React is an optional peer;
|
|
17
|
+
install React 18.3.1 or 19 separately when using the React entrypoint. This package
|
|
18
|
+
is ESM-only and includes TypeScript declarations. The root import is intentionally
|
|
19
|
+
absent: choose a specific primitive. No CSS, component library or Go runtime is
|
|
20
|
+
required. Future frontend primitives can use additional entrypoints here.
|
|
21
|
+
|
|
22
|
+
## Define a flow
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { createMachine } from "@plutoframework/frontend/state";
|
|
26
|
+
import type { Decision } from "@plutoframework/frontend/state";
|
|
27
|
+
|
|
28
|
+
type State = { readonly status: "idle" | "pending" | "done" };
|
|
29
|
+
type Event = { readonly type: "start" | "done" };
|
|
30
|
+
type Effect = { readonly type: "load" };
|
|
31
|
+
|
|
32
|
+
function transition(current: State, event: Event): Decision<State, Effect> {
|
|
33
|
+
if (current.status === "idle" && event.type === "start") {
|
|
34
|
+
return {
|
|
35
|
+
accepted: true,
|
|
36
|
+
state: { status: "pending" },
|
|
37
|
+
effect: { type: "load" },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (current.status === "pending" && event.type === "done") {
|
|
42
|
+
return { accepted: true, state: { status: "done" } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { accepted: false, state: current };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const flow = createMachine<State, Event, Effect>({
|
|
49
|
+
initial: { status: "idle" },
|
|
50
|
+
transition,
|
|
51
|
+
async execute(_effect, { signal, send }) {
|
|
52
|
+
const response = await fetch("/api/summary", { signal });
|
|
53
|
+
if (!response.ok) throw new Error("Summary request failed");
|
|
54
|
+
await send({ type: "done" });
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Commits pending synchronously, then awaits its effect. Handle errors explicitly.
|
|
59
|
+
await flow.send({ type: "start" });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Use your feature's existing query/mutation adapter for real applications. The
|
|
63
|
+
small fetch example demonstrates the boundary; it is not a new HTTP client.
|
|
64
|
+
|
|
65
|
+
## Transition rules
|
|
66
|
+
|
|
67
|
+
Transitions are pure application functions. They must neither perform I/O nor
|
|
68
|
+
mutate state/events or call the controller. Return a new immutable snapshot for
|
|
69
|
+
accepted changes. Rejected decisions preserve the controller's existing snapshot,
|
|
70
|
+
even if the returned decision supplies a different state. TypeScript readonly
|
|
71
|
+
fields are compile-time guidance; this library does not clone or freeze objects.
|
|
72
|
+
|
|
73
|
+
An accepted decision may describe one effect. Missing executors are rejected
|
|
74
|
+
before committing an effect-bearing decision. Effects execute outside rendering,
|
|
75
|
+
reducers and subscription setup. States, events and guards remain ordinary typed
|
|
76
|
+
application code, with no global registration, reflection, DSL or automatic I/O.
|
|
77
|
+
|
|
78
|
+
## Controller lifecycle
|
|
79
|
+
|
|
80
|
+
| Method | Behavior |
|
|
81
|
+
| ---------------------- | ---------------------------------------------------------------------------------------------- |
|
|
82
|
+
| `getSnapshot()` | Returns the current stable snapshot |
|
|
83
|
+
| `getInitialSnapshot()` | Returns the immutable initial snapshot for SSR |
|
|
84
|
+
| `subscribe(listener)` | Subscribes until the returned cleanup is called |
|
|
85
|
+
| `send(event)` | Commits an accepted transition synchronously; resolves to acceptance after its effect finishes |
|
|
86
|
+
| `reset()` | Aborts executions, fences their callbacks and restores the initial snapshot |
|
|
87
|
+
| `dispose()` | Permanently aborts executions, removes subscribers and rejects further events |
|
|
88
|
+
|
|
89
|
+
Always await or catch `send()` promises. Transition/executor/subscriber exceptions
|
|
90
|
+
reject the sending promise. Transition errors do not commit; executor or subscriber
|
|
91
|
+
errors after commit do not roll state back. Subscription callbacks must be small,
|
|
92
|
+
synchronous and nonthrowing. Model expected request failures as typed events, as
|
|
93
|
+
shown in [the purchase flow](https://github.com/plutoframework/pluto-frontend/blob/main/examples/purchases/approval.ts).
|
|
94
|
+
|
|
95
|
+
Each effect receives an AbortSignal and a scoped `send`. That callback returns
|
|
96
|
+
false after reset, disposal or executor completion, so late responses cannot
|
|
97
|
+
update a replacement interaction. Cancellation is cooperative: an executor that
|
|
98
|
+
ignores its signal can keep running, and its original sending promise can remain
|
|
99
|
+
pending. There are no automatic retries, execution timeouts or concurrency limits.
|
|
100
|
+
The transition function defines which events may start additional work.
|
|
101
|
+
|
|
102
|
+
## React
|
|
103
|
+
|
|
104
|
+
Pass an existing controller into your component:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
import type { Machine } from "@plutoframework/frontend/state";
|
|
108
|
+
|
|
109
|
+
function Summary({
|
|
110
|
+
flow,
|
|
111
|
+
onError,
|
|
112
|
+
}: {
|
|
113
|
+
flow: Machine<State, Event>;
|
|
114
|
+
onError: (error: unknown) => void;
|
|
115
|
+
}) {
|
|
116
|
+
const { state, send } = useMachine(flow);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<button
|
|
120
|
+
disabled={state.status !== "idle"}
|
|
121
|
+
onClick={() => void send({ type: "start" }).catch(onError)}
|
|
122
|
+
>
|
|
123
|
+
{state.status}
|
|
124
|
+
</button>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The hook uses `useSyncExternalStore`. Rendering, resubscription and Strict Mode
|
|
130
|
+
checks do not execute effects. The hook subscribes only: unmounting a subscriber
|
|
131
|
+
**does not dispose a shared controller**. The owning application/route/session
|
|
132
|
+
lifecycle must dispose it when its resource or identity scope ends. Create a fresh
|
|
133
|
+
controller for the new scope; do not reuse one across tenants or users. Do not
|
|
134
|
+
create a module-global controller for server requests. SSR needs equivalent
|
|
135
|
+
request-local initial data on server and client for hydration. Do not send events
|
|
136
|
+
from render or pure reducers. Strict Mode can replay caller-owned React Effects;
|
|
137
|
+
an imperative call placed in such an Effect is still the caller's responsibility.
|
|
138
|
+
|
|
139
|
+
See the fully typed [React purchase component](https://github.com/plutoframework/pluto-frontend/blob/main/examples/purchases/ApprovalButton.tsx)
|
|
140
|
+
and the [flow/executor](https://github.com/plutoframework/pluto-frontend/blob/main/examples/purchases/approval.ts). These original examples
|
|
141
|
+
are type-checked in CI and are independent of HeroUI/shadcn styling.
|
|
142
|
+
|
|
143
|
+
## Backend authority
|
|
144
|
+
|
|
145
|
+
A frontend interaction state such as `submitting` is distinct from the purchase's
|
|
146
|
+
persisted business status. The backend verifies identity, RBAC, tenant scope,
|
|
147
|
+
resource policy and state transitions on every write. Refresh authoritative query
|
|
148
|
+
data after a mutation or uncertain failure. An aborted request can still commit
|
|
149
|
+
on the server. UI request IDs correlate responses; they are not backend
|
|
150
|
+
idempotency keys, authorization or an exactly-once guarantee. Preserve your
|
|
151
|
+
feature mutation adapter's existing stable idempotency key for retries.
|
|
152
|
+
|
|
153
|
+
## Develop
|
|
154
|
+
|
|
155
|
+
Use Node 24.22.1:
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
npm ci
|
|
159
|
+
npm run check
|
|
160
|
+
npm run test:mutation
|
|
161
|
+
npm pack
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Tests cover transition rejection, duplicate actions, stale callbacks, reset,
|
|
165
|
+
disposal, executor failures, React Strict Mode/SSR and 1,500 deterministic property
|
|
166
|
+
runs over arbitrary event sequences. Mutation smoke is a bounded set of seeded
|
|
167
|
+
mutations, not a comprehensive mutation engine. CI tests React 18.3.1 and 19.3.1.
|
|
168
|
+
Releases publish through GitHub Actions using npm trusted publishing (OIDC);
|
|
169
|
+
no npm credentials are stored in this repository or in GitHub Actions.
|
|
170
|
+
|
|
171
|
+
## Publish a release
|
|
172
|
+
|
|
173
|
+
The `publish.yml` workflow publishes stable `vMAJOR.MINOR.PATCH` tags using npm
|
|
174
|
+
trusted publishing and provenance. It checks tag/version agreement and main-branch
|
|
175
|
+
ancestry, runs tests and mutation checks, and verifies packed consumers before
|
|
176
|
+
publishing. Prereleases are rejected. Manual dispatch accepts an existing tag for
|
|
177
|
+
retrying a failed publish; npm never overwrites an already published version.
|
|
178
|
+
|
|
179
|
+
After merging the intended changes into main:
|
|
180
|
+
|
|
181
|
+
```sh
|
|
182
|
+
npm version patch
|
|
183
|
+
git push origin main --follow-tags
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The one-time npm trusted publisher is scoped to:
|
|
187
|
+
|
|
188
|
+
| Setting | Value |
|
|
189
|
+
| -------------- | ---------------- |
|
|
190
|
+
| Organization | `plutoframework` |
|
|
191
|
+
| Repository | `pluto-frontend` |
|
|
192
|
+
| Workflow | `publish.yml` |
|
|
193
|
+
| Environment | None |
|
|
194
|
+
| Allowed action | `npm publish` |
|
|
195
|
+
|
|
196
|
+
The workflow uses GitHub-hosted runners with `id-token: write`; npm exchanges the
|
|
197
|
+
workflow identity for a short-lived publish credential. No `NPM_TOKEN` secret is
|
|
198
|
+
needed. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Machine } from "../state/index.js";
|
|
2
|
+
/** Subscribe only. The host owns the controller's reset/disposal lifecycle. */
|
|
3
|
+
export declare function useMachine<State, Event>(machine: Machine<State, Event>): {
|
|
4
|
+
state: State;
|
|
5
|
+
send: (event: Event) => Promise<boolean>;
|
|
6
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useSyncExternalStore } from "react";
|
|
3
|
+
/** Subscribe only. The host owns the controller's reset/disposal lifecycle. */
|
|
4
|
+
export function useMachine(machine) {
|
|
5
|
+
const state = useSyncExternalStore(machine.subscribe, machine.getSnapshot, machine.getInitialSnapshot);
|
|
6
|
+
return { state, send: machine.send };
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createMachine } from "./machine.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { Machine, MachineOptions } from "./types.js";
|
|
2
|
+
/** One controller owns one interaction. Constructors perform no effects. */
|
|
3
|
+
export declare function createMachine<State, Event, Effect = never>(options: MachineOptions<State, Event, Effect>): Machine<State, Event>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** One controller owns one interaction. Constructors perform no effects. */
|
|
2
|
+
export function createMachine(options) {
|
|
3
|
+
const { initial, transition, execute } = options;
|
|
4
|
+
let current = initial;
|
|
5
|
+
let epoch = 0;
|
|
6
|
+
let disposed = false;
|
|
7
|
+
let evaluating = false;
|
|
8
|
+
const listeners = new Set();
|
|
9
|
+
const executions = new Set();
|
|
10
|
+
function notify() {
|
|
11
|
+
for (const listener of [...listeners])
|
|
12
|
+
listener();
|
|
13
|
+
}
|
|
14
|
+
function cancel() {
|
|
15
|
+
// Fence callbacks BEFORE synchronous abort handlers can send events.
|
|
16
|
+
epoch++;
|
|
17
|
+
for (const controller of executions)
|
|
18
|
+
controller.abort();
|
|
19
|
+
executions.clear();
|
|
20
|
+
}
|
|
21
|
+
async function send(event) {
|
|
22
|
+
if (disposed)
|
|
23
|
+
return false;
|
|
24
|
+
if (evaluating)
|
|
25
|
+
throw new Error("Transitions must not send events");
|
|
26
|
+
const generation = epoch;
|
|
27
|
+
evaluating = true;
|
|
28
|
+
let decision;
|
|
29
|
+
try {
|
|
30
|
+
decision = transition(current, event);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
evaluating = false;
|
|
34
|
+
}
|
|
35
|
+
if (!decision.accepted)
|
|
36
|
+
return false;
|
|
37
|
+
if (decision.effect !== undefined && !execute) {
|
|
38
|
+
throw new Error("An effect requires an executor");
|
|
39
|
+
}
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
let active = true;
|
|
42
|
+
executions.add(controller);
|
|
43
|
+
current = decision.state;
|
|
44
|
+
try {
|
|
45
|
+
notify();
|
|
46
|
+
// Subscribers may reset/dispose the flow during notification.
|
|
47
|
+
if (generation !== epoch || disposed)
|
|
48
|
+
return true;
|
|
49
|
+
if (decision.effect !== undefined && execute) {
|
|
50
|
+
await execute(decision.effect, {
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
send: (next) => {
|
|
53
|
+
if (!active || generation !== epoch || disposed) {
|
|
54
|
+
return Promise.resolve(false);
|
|
55
|
+
}
|
|
56
|
+
return send(next);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
active = false;
|
|
64
|
+
executions.delete(controller);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
getSnapshot: () => current,
|
|
69
|
+
getInitialSnapshot: () => initial,
|
|
70
|
+
subscribe(listener) {
|
|
71
|
+
if (disposed)
|
|
72
|
+
return () => { };
|
|
73
|
+
listeners.add(listener);
|
|
74
|
+
return () => {
|
|
75
|
+
listeners.delete(listener);
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
send,
|
|
79
|
+
reset() {
|
|
80
|
+
if (disposed)
|
|
81
|
+
return;
|
|
82
|
+
cancel();
|
|
83
|
+
current = initial;
|
|
84
|
+
notify();
|
|
85
|
+
},
|
|
86
|
+
dispose() {
|
|
87
|
+
if (disposed)
|
|
88
|
+
return;
|
|
89
|
+
disposed = true;
|
|
90
|
+
cancel();
|
|
91
|
+
listeners.clear();
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Domain facts belong in immutable state/events supplied by the host. */
|
|
2
|
+
export type Decision<State, Effect = never> = {
|
|
3
|
+
readonly accepted: true;
|
|
4
|
+
readonly state: State;
|
|
5
|
+
readonly effect?: Effect;
|
|
6
|
+
} | {
|
|
7
|
+
readonly accepted: false;
|
|
8
|
+
readonly state: State;
|
|
9
|
+
};
|
|
10
|
+
export type Transition<State, Event, Effect = never> = (current: State, event: Event) => Decision<State, Effect>;
|
|
11
|
+
export interface EffectContext<Event> {
|
|
12
|
+
readonly signal: AbortSignal;
|
|
13
|
+
/** Scoped to this execution; returns false after reset/disposal/completion. */
|
|
14
|
+
readonly send: (event: Event) => Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
export interface MachineOptions<State, Event, Effect = never> {
|
|
17
|
+
readonly initial: State;
|
|
18
|
+
readonly transition: Transition<State, Event, Effect>;
|
|
19
|
+
readonly execute?: (effect: Effect, context: EffectContext<Event>) => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export interface Machine<State, Event> {
|
|
22
|
+
readonly getSnapshot: () => State;
|
|
23
|
+
/** Stable initial snapshot for SSR; host must hydrate equivalent initial data. */
|
|
24
|
+
readonly getInitialSnapshot: () => State;
|
|
25
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
26
|
+
/** Commits synchronously, resolves after its effect. Unexpected errors reject. */
|
|
27
|
+
readonly send: (event: Event) => Promise<boolean>;
|
|
28
|
+
/** Cancel outstanding executions, fence their callbacks, restore initial state. */
|
|
29
|
+
readonly reset: () => void;
|
|
30
|
+
/** Permanent teardown. Further events are rejected. */
|
|
31
|
+
readonly dispose: () => void;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutoframework/frontend",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Explicit, typed frontend state transitions and React subscriptions for Pluto applications.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/plutoframework/pluto-frontend.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/plutoframework/pluto-frontend#readme",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"exports": {
|
|
19
|
+
"./state": {
|
|
20
|
+
"types": "./dist/state/index.d.ts",
|
|
21
|
+
"import": "./dist/state/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./react": {
|
|
24
|
+
"types": "./dist/react/index.d.ts",
|
|
25
|
+
"import": "./dist/react/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^18.3.1 || ^19.0.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"react": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.build.json",
|
|
41
|
+
"fmt": "prettier --write .",
|
|
42
|
+
"check": "prettier --check . && tsc --noEmit && npm test && npm run build",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"test:mutation": "python3 scripts/mutation.py",
|
|
45
|
+
"prepack": "npm run build"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"typescript": "5.9.3",
|
|
49
|
+
"vitest": "5.0.5",
|
|
50
|
+
"fast-check": "4.10.2",
|
|
51
|
+
"react": "19.3.1",
|
|
52
|
+
"react-dom": "19.3.1",
|
|
53
|
+
"@types/react": "19.3.0",
|
|
54
|
+
"@types/react-dom": "19.3.0",
|
|
55
|
+
"@testing-library/react": "16.3.3",
|
|
56
|
+
"jsdom": "30.1.3",
|
|
57
|
+
"prettier": "3.9.10"
|
|
58
|
+
}
|
|
59
|
+
}
|