opshot 0.1.0 → 0.2.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/README.md +111 -68
- package/dist/index.d.ts +24 -15
- package/dist/index.js +23 -13
- package/dist/react.d.ts +7 -5
- package/dist/react.js +51 -39
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -2,12 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# opshot
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- **Self-contained**: a state carries its data, its methods, and its own subscription; pass it around like any object.
|
|
8
|
-
- **Reactive reads**: components read plain properties, and reads are tracked per component: a component re-renders only when a property it read changes.
|
|
9
|
-
- **Safe mutation**: write by mutating the actual object directly inside `mutate`.
|
|
10
|
-
- **Ops events**: every mutation emits its changes as `{ do, undo }` JSON Patch pairs, ready for history, sync, and persistence.
|
|
5
|
+
Mutable state for React, with re-render for only the components that read what changed. (It's [valtio](https://github.com/pmndrs/valtio), but not a footgun.)
|
|
11
6
|
|
|
12
7
|
## Install
|
|
13
8
|
|
|
@@ -15,29 +10,81 @@ Plain-object state for React: mutate it directly, re-render only the components
|
|
|
15
10
|
npm install opshot
|
|
16
11
|
```
|
|
17
12
|
|
|
18
|
-
##
|
|
13
|
+
## Mutable state
|
|
14
|
+
|
|
15
|
+
React state is immutable: changing one field means spreading the old object into a new one.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
const [user, setUser] = useState({ name: "Ada", age: 36 });
|
|
19
|
+
|
|
20
|
+
setUser((prev) => ({ ...prev, age: 37 }));
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
opshot state is mutable: you assign the field.
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
const user = useTrackedState({ name: "Ada", age: 36 });
|
|
27
|
+
|
|
28
|
+
user.mutate((mutable) => (mutable.age = 37));
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Bounded re-renders
|
|
32
|
+
|
|
33
|
+
React re-renders a component and its children when its state changes.
|
|
19
34
|
|
|
20
35
|
```tsx
|
|
21
|
-
|
|
36
|
+
interface User {
|
|
37
|
+
name: string;
|
|
38
|
+
age: number;
|
|
39
|
+
}
|
|
22
40
|
|
|
23
|
-
const
|
|
24
|
-
const
|
|
41
|
+
const Parent = () => {
|
|
42
|
+
const [user, setUser] = useState<User>({ name: "Ada", age: 36 });
|
|
25
43
|
|
|
26
|
-
|
|
27
|
-
const increment = () => counter.op.mutate((proxy) => proxy.count++);
|
|
44
|
+
const birthday = () => setUser((prev) => ({ ...prev, age: prev.age + 1 }));
|
|
28
45
|
|
|
29
|
-
|
|
46
|
+
// A click re-renders Parent and Child.
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<button onClick={birthday}>+</button>
|
|
50
|
+
<Child user={user} />
|
|
51
|
+
</>
|
|
52
|
+
);
|
|
30
53
|
};
|
|
54
|
+
|
|
55
|
+
const Child = ({ user }: { user: User }) => <p>{user.age}</p>;
|
|
31
56
|
```
|
|
32
57
|
|
|
33
|
-
|
|
58
|
+
opshot re-renders only what read the change. Wrap a child in `retrack` and it subscribes to the fields it reads. **Where the mutation happens doesn't matter** — here Parent writes, and only Child re-renders, because renders follow reads, not writes.
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
const Parent = () => {
|
|
62
|
+
const user = useTrackedState<User>({ name: "Ada", age: 36 });
|
|
63
|
+
|
|
64
|
+
const birthday = () => user.mutate((mutable) => mutable.age++);
|
|
65
|
+
|
|
66
|
+
// A click re-renders only Child.
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
<button onClick={birthday}>+</button>
|
|
70
|
+
<Child user={user} />
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const Child = retrack<{ user: State<User> }>(({ user }) => <p>{user.age}</p>);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This is how you optimize re-rendering across your component tree: place `retrack` boundaries where you want re-renders contained, and each boundary re-renders only when a field it read changes. `useTrackedState` is a boundary itself.
|
|
79
|
+
|
|
80
|
+
## Creating State
|
|
34
81
|
|
|
35
82
|
```tsx
|
|
36
83
|
import { ref } from "opshot";
|
|
37
|
-
import {
|
|
84
|
+
import { useTrackedState } from "opshot/react";
|
|
38
85
|
|
|
39
86
|
const Player = () => {
|
|
40
|
-
const player =
|
|
87
|
+
const player = useTrackedState((mutate, get) => ({
|
|
41
88
|
position: 0,
|
|
42
89
|
|
|
43
90
|
// ref() keeps a value out of reactivity and ops.
|
|
@@ -47,7 +94,7 @@ const Player = () => {
|
|
|
47
94
|
seek: (position: number) => {
|
|
48
95
|
get().element.currentTime = position;
|
|
49
96
|
|
|
50
|
-
mutate((
|
|
97
|
+
mutate((mutable) => (mutable.position = position));
|
|
51
98
|
},
|
|
52
99
|
}));
|
|
53
100
|
|
|
@@ -55,16 +102,16 @@ const Player = () => {
|
|
|
55
102
|
};
|
|
56
103
|
```
|
|
57
104
|
|
|
58
|
-
##
|
|
105
|
+
## Tracked State
|
|
59
106
|
|
|
60
|
-
Everything opshot attaches lives under
|
|
107
|
+
Everything opshot attaches lives under two reserved keys, `mutate` and `op`.
|
|
61
108
|
|
|
62
109
|
```ts
|
|
63
110
|
// The write path. An optional second argument is passed to every subscriber.
|
|
64
|
-
counter.
|
|
111
|
+
counter.mutate((mutable) => mutable.count++, { transactionKey: "drag" });
|
|
65
112
|
|
|
66
113
|
// Hears every op this state emits; returns an unsubscribe.
|
|
67
|
-
const unsubscribe = counter.op.subscribe((state, ops,
|
|
114
|
+
const unsubscribe = counter.op.subscribe((state, ops, meta) => {
|
|
68
115
|
// ...
|
|
69
116
|
});
|
|
70
117
|
|
|
@@ -78,13 +125,13 @@ counter.op.isMutating;
|
|
|
78
125
|
counter.op.unwrap();
|
|
79
126
|
|
|
80
127
|
// The underlying valtio proxy, typed object: an escape hatch.
|
|
81
|
-
counter.op.
|
|
128
|
+
counter.op.unsafeMutable;
|
|
82
129
|
```
|
|
83
130
|
|
|
84
131
|
## Ops
|
|
85
132
|
|
|
86
133
|
```ts
|
|
87
|
-
const unsubscribe = counter.op.subscribe((state, ops,
|
|
134
|
+
const unsubscribe = counter.op.subscribe((state, ops, meta) => {
|
|
88
135
|
// state: the snapshot these ops produced
|
|
89
136
|
// ops: [{
|
|
90
137
|
// do: { op: "replace", path: "/count", value: 1 },
|
|
@@ -99,75 +146,71 @@ A subscriber must not write to the state it subscribes to; writing to a differen
|
|
|
99
146
|
|
|
100
147
|
Ops cost nothing until someone listens: a state with no subscribers, on itself or its group, skips computing them entirely.
|
|
101
148
|
|
|
102
|
-
##
|
|
149
|
+
## Meta
|
|
103
150
|
|
|
104
|
-
|
|
151
|
+
`mutate`'s optional second argument is delivered to every subscriber alongside the ops.
|
|
152
|
+
|
|
153
|
+
To type it, declare a meta token once and pass it in.
|
|
105
154
|
|
|
106
155
|
```tsx
|
|
107
156
|
import { useEffect } from "react";
|
|
108
|
-
import {
|
|
157
|
+
import { createMeta } from "opshot";
|
|
158
|
+
import { useTrackedState } from "opshot/react";
|
|
159
|
+
|
|
160
|
+
interface DocumentMeta {
|
|
161
|
+
replay?: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Declared once, at module scope.
|
|
165
|
+
const documentMeta = createMeta<DocumentMeta>();
|
|
109
166
|
|
|
110
167
|
const Editor = () => {
|
|
111
|
-
|
|
112
|
-
const group = useCreateGroup();
|
|
168
|
+
const doc = useTrackedState({ title: "Untitled" }, documentMeta);
|
|
113
169
|
|
|
114
|
-
//
|
|
115
|
-
|
|
170
|
+
// A history replaying an undone op marks the write, so recorders can tell it apart.
|
|
171
|
+
// The meta argument is typed DocumentMeta.
|
|
172
|
+
const undo = () => doc.mutate((mutable) => (mutable.title = "Untitled"), { replay: true });
|
|
116
173
|
|
|
117
174
|
useEffect(
|
|
118
175
|
() =>
|
|
119
|
-
//
|
|
120
|
-
|
|
176
|
+
// The subscriber's meta parameter is typed DocumentMeta.
|
|
177
|
+
doc.op.subscribe((state, ops, meta) => {
|
|
178
|
+
// A recorder skips its own replays.
|
|
179
|
+
if (meta.replay) return;
|
|
180
|
+
|
|
121
181
|
// ...
|
|
122
182
|
}),
|
|
123
|
-
[
|
|
183
|
+
[doc.op],
|
|
124
184
|
);
|
|
125
185
|
|
|
126
186
|
// ...
|
|
127
187
|
};
|
|
128
188
|
```
|
|
129
189
|
|
|
130
|
-
##
|
|
131
|
-
|
|
132
|
-
A component re-renders when a property it read changes. Reads belong to the nearest subscribed component above them: `useCreateState` subscribes the component that created the state, and `resnapshot` subscribes the component it wraps.
|
|
190
|
+
## Groups
|
|
133
191
|
|
|
134
|
-
|
|
192
|
+
A group creates states and hears every op from the states it created: one stream for history, sync, and persistence.
|
|
135
193
|
|
|
136
194
|
```tsx
|
|
137
|
-
import
|
|
138
|
-
import {
|
|
195
|
+
import { useEffect } from "react";
|
|
196
|
+
import { useGroup, useTrackedState } from "opshot/react";
|
|
139
197
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
198
|
+
const Editor = () => {
|
|
199
|
+
// A lifetime-stable group.
|
|
200
|
+
const group = useGroup();
|
|
144
201
|
|
|
145
|
-
//
|
|
146
|
-
const
|
|
147
|
-
const counter = useCreateState<Counter>({ title: "Hits", count: 0 });
|
|
202
|
+
// Created through the group, so its ops reach the group's subscribers.
|
|
203
|
+
const doc = useTrackedState({ items: new Array<string>() }, group);
|
|
148
204
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
205
|
+
useEffect(
|
|
206
|
+
() =>
|
|
207
|
+
// Fires for doc and every other state the group created.
|
|
208
|
+
group.subscribe((state, ops, meta) => {
|
|
209
|
+
// ...
|
|
210
|
+
}),
|
|
211
|
+
[group],
|
|
154
212
|
);
|
|
155
|
-
};
|
|
156
213
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
);
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
Wrapping `CounterButton` in `resnapshot` subscribes it, so its `count` read becomes its own. A click now re-renders `CounterButton` alone:
|
|
163
|
-
|
|
164
|
-
```tsx
|
|
165
|
-
import { resnapshot } from "opshot/react";
|
|
166
|
-
|
|
167
|
-
// A click re-renders only CounterButton. A title change would still re-render App.
|
|
168
|
-
const CounterButton = resnapshot<{ counter: State<Counter> }>(({ counter }) => (
|
|
169
|
-
<button onClick={() => counter.op.mutate((proxy) => proxy.count++)}>{counter.count}</button>
|
|
170
|
-
));
|
|
214
|
+
// ...
|
|
215
|
+
};
|
|
171
216
|
```
|
|
172
|
-
|
|
173
|
-
Subscribed components re-render independently: had `App` also read `count`, both would re-render.
|
package/dist/index.d.ts
CHANGED
|
@@ -19,29 +19,38 @@ interface Op {
|
|
|
19
19
|
}
|
|
20
20
|
declare function diffSnapshots(before: unknown, after: unknown): Array<Op>;
|
|
21
21
|
|
|
22
|
-
type
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
readonly
|
|
22
|
+
type MetaRecord = Record<string, unknown>;
|
|
23
|
+
declare const metaIn: unique symbol;
|
|
24
|
+
interface Meta<In extends object = MetaRecord, Out extends object = MetaRecord> {
|
|
25
|
+
readonly defaults?: Out;
|
|
26
|
+
readonly [metaIn]?: (value: In) => void;
|
|
27
|
+
}
|
|
28
|
+
type Mutate<T extends object, In extends object = MetaRecord> = (callback: (mutable: T) => void, ...meta: {} extends In ? [meta?: In] : [meta: In]) => void;
|
|
29
|
+
type StateListener<T extends object, In extends object = MetaRecord, Out extends object = MetaRecord> = (state: State<T, In, Out>, ops: Array<Op>, meta: Out) => void;
|
|
30
|
+
interface OpshotHandle<T extends object, In extends object = MetaRecord, Out extends object = MetaRecord> {
|
|
31
|
+
readonly unsafeMutable: object;
|
|
27
32
|
readonly isMutating: boolean;
|
|
28
|
-
readonly
|
|
29
|
-
readonly subscribe: (listener: StateListener<T>) => () => void;
|
|
33
|
+
readonly subscribe: (listener: StateListener<T, In, Out>) => () => void;
|
|
30
34
|
readonly isSameState: (other: unknown) => boolean;
|
|
31
35
|
readonly unwrap: () => Snapshot<T>;
|
|
32
36
|
}
|
|
33
|
-
type State<T extends object> = Snapshot<T> & {
|
|
34
|
-
readonly
|
|
37
|
+
type State<T extends object, In extends object = MetaRecord, Out extends object = MetaRecord> = Snapshot<T> & {
|
|
38
|
+
readonly mutate: Mutate<T, In>;
|
|
39
|
+
readonly op: OpshotHandle<T, In, Out>;
|
|
35
40
|
};
|
|
36
|
-
type DefineCallback<T extends object> = (mutate: Mutate<T>, get: () => State<T>) => T;
|
|
37
|
-
type Define<T extends object> = DefineCallback<T> | T;
|
|
41
|
+
type DefineCallback<T extends object, In extends object = MetaRecord, Out extends object = MetaRecord> = (mutate: Mutate<T, In>, get: () => State<T, In, Out>) => T;
|
|
42
|
+
type Define<T extends object, In extends object = MetaRecord, Out extends object = MetaRecord> = DefineCallback<T, In, Out> | T;
|
|
43
|
+
declare function createMeta<M extends object>(): Meta<M, M>;
|
|
44
|
+
declare function createMeta<M extends object>(defaults: M): Meta<Partial<M>, M>;
|
|
38
45
|
declare function createState<T extends object>(define: Define<T>): State<T>;
|
|
46
|
+
declare function createState<T extends object, In extends object, Out extends object>(define: Define<T, In, Out>, meta: Meta<In, Out>): State<T, In, Out>;
|
|
39
47
|
declare function isState(value: unknown): value is State<object>;
|
|
40
48
|
|
|
41
|
-
interface Group {
|
|
42
|
-
createState<T extends object>(define: Define<T>): State<T>;
|
|
43
|
-
subscribe(listener: StateListener<object>): () => void;
|
|
49
|
+
interface Group<In extends object = MetaRecord, Out extends object = MetaRecord> {
|
|
50
|
+
createState<T extends object>(define: Define<T, In, Out>): State<T, In, Out>;
|
|
51
|
+
subscribe(listener: StateListener<object, In, Out>): () => void;
|
|
44
52
|
}
|
|
45
53
|
declare function createGroup(): Group;
|
|
54
|
+
declare function createGroup<In extends object, Out extends object>(meta: Meta<In, Out>): Group<In, Out>;
|
|
46
55
|
|
|
47
|
-
export { type Define, type DefineCallback, type Group, type
|
|
56
|
+
export { type Define, type DefineCallback, type Group, type Meta, type MetaRecord, type Mutate, type Op, type OpshotHandle, type PatchOperation, type State, type StateListener, createGroup, createMeta, createState, diffSnapshots, isState };
|
package/dist/index.js
CHANGED
|
@@ -66,11 +66,16 @@ function diffSnapshots(before, after) {
|
|
|
66
66
|
|
|
67
67
|
// src/createState.ts
|
|
68
68
|
var stateBrand = /* @__PURE__ */ Symbol.for("opshot.state");
|
|
69
|
+
var metaBrand = /* @__PURE__ */ Symbol.for("opshot.meta");
|
|
69
70
|
var hasOwn = (value, key) => Object.hasOwn(value, key);
|
|
70
|
-
function
|
|
71
|
-
|
|
71
|
+
function createMeta(defaults) {
|
|
72
|
+
const token = defaults === void 0 ? { [metaBrand]: true } : { defaults, [metaBrand]: true };
|
|
73
|
+
return token;
|
|
72
74
|
}
|
|
73
|
-
function
|
|
75
|
+
function createState(define, meta) {
|
|
76
|
+
return createGroupState(define, void 0, meta);
|
|
77
|
+
}
|
|
78
|
+
function createGroupState(define, groupListeners, metaToken) {
|
|
74
79
|
const callback = typeof define === "function" ? define : () => define;
|
|
75
80
|
const listeners = /* @__PURE__ */ new Set();
|
|
76
81
|
const created = {};
|
|
@@ -80,7 +85,8 @@ function createGroupState(define, groupListeners) {
|
|
|
80
85
|
return proxied;
|
|
81
86
|
};
|
|
82
87
|
const get = () => snapshot(requireProxy());
|
|
83
|
-
const mutate = (callback2,
|
|
88
|
+
const mutate = (callback2, ...metaArgs) => {
|
|
89
|
+
const [meta] = metaArgs;
|
|
84
90
|
const proxied = requireProxy();
|
|
85
91
|
if (handle.isMutating) throw new Error("opshot: nested mutate on the same state");
|
|
86
92
|
handle.isMutating = true;
|
|
@@ -95,8 +101,9 @@ function createGroupState(define, groupListeners) {
|
|
|
95
101
|
if (listeners.size === 0 && (groupListeners?.size ?? 0) === 0) return;
|
|
96
102
|
const ops = diffSnapshots(before, after);
|
|
97
103
|
if (ops.length === 0) return;
|
|
98
|
-
|
|
99
|
-
for (const listener of [...
|
|
104
|
+
const emittedMeta = metaToken?.defaults !== void 0 ? { ...metaToken.defaults, ...meta } : meta ?? {};
|
|
105
|
+
for (const listener of [...groupListeners ?? []]) listener(after, ops, emittedMeta);
|
|
106
|
+
for (const listener of [...listeners]) listener(after, ops, emittedMeta);
|
|
100
107
|
};
|
|
101
108
|
const subscribe = (listener) => {
|
|
102
109
|
listeners.add(listener);
|
|
@@ -106,17 +113,20 @@ function createGroupState(define, groupListeners) {
|
|
|
106
113
|
};
|
|
107
114
|
const isSameState = (other) => isState(other) && other.op === handle;
|
|
108
115
|
const unwrap = () => {
|
|
109
|
-
const { op, ...rest } = get();
|
|
116
|
+
const { op, mutate: mutate2, ...rest } = get();
|
|
110
117
|
return rest;
|
|
111
118
|
};
|
|
112
119
|
const literal = callback(mutate, get);
|
|
113
|
-
|
|
120
|
+
for (const key of ["op", "mutate"]) {
|
|
121
|
+
if (Object.hasOwn(literal, key)) throw new Error(`opshot: "${key}" is a reserved key on a state`);
|
|
122
|
+
}
|
|
114
123
|
const base = Object.create(Reflect.getPrototypeOf(literal));
|
|
115
124
|
Object.defineProperties(base, Object.getOwnPropertyDescriptors(literal));
|
|
116
|
-
const handle = {
|
|
125
|
+
const handle = { unsafeMutable: base, isMutating: false, subscribe, isSameState, unwrap, [stateBrand]: true };
|
|
117
126
|
Object.defineProperty(base, "op", { value: ref(handle), enumerable: true, writable: false, configurable: false });
|
|
127
|
+
Object.defineProperty(base, "mutate", { value: mutate, enumerable: true, writable: false, configurable: false });
|
|
118
128
|
created.proxied = proxy(base);
|
|
119
|
-
handle.
|
|
129
|
+
handle.unsafeMutable = created.proxied;
|
|
120
130
|
return get();
|
|
121
131
|
}
|
|
122
132
|
function isState(value) {
|
|
@@ -127,11 +137,11 @@ function isState(value) {
|
|
|
127
137
|
}
|
|
128
138
|
|
|
129
139
|
// src/createGroup.ts
|
|
130
|
-
function createGroup() {
|
|
140
|
+
function createGroup(meta) {
|
|
131
141
|
const listeners = /* @__PURE__ */ new Set();
|
|
132
142
|
return {
|
|
133
143
|
createState(define) {
|
|
134
|
-
return createGroupState(define, listeners);
|
|
144
|
+
return createGroupState(define, listeners, meta);
|
|
135
145
|
},
|
|
136
146
|
subscribe(listener) {
|
|
137
147
|
listeners.add(listener);
|
|
@@ -142,4 +152,4 @@ function createGroup() {
|
|
|
142
152
|
};
|
|
143
153
|
}
|
|
144
154
|
|
|
145
|
-
export { createGroup, createState, diffSnapshots, isState };
|
|
155
|
+
export { createGroup, createMeta, createState, diffSnapshots, isState };
|
package/dist/react.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { FC } from 'react';
|
|
2
|
-
import { Group, Define, State } from './index.js';
|
|
2
|
+
import { Group, Meta, Define, State } from './index.js';
|
|
3
3
|
import 'valtio/vanilla';
|
|
4
4
|
|
|
5
|
-
declare function
|
|
6
|
-
declare
|
|
7
|
-
declare
|
|
5
|
+
declare function retrack<P extends object>(component: FC<P>): FC<P>;
|
|
6
|
+
declare function useGroup(): Group;
|
|
7
|
+
declare function useGroup<In extends object, Out extends object>(meta: Meta<In, Out>): Group<In, Out>;
|
|
8
|
+
declare function useTrackedState<T extends object>(define: Define<T>): State<T>;
|
|
9
|
+
declare function useTrackedState<T extends object, In extends object, Out extends object>(define: Define<T, In, Out>, groupOrMeta: Group<In, Out> | Meta<In, Out>): State<T, In, Out>;
|
|
8
10
|
|
|
9
|
-
export {
|
|
11
|
+
export { retrack, useGroup, useTrackedState };
|
package/dist/react.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isChanged, createProxy } from 'proxy-compare';
|
|
2
2
|
import { memo, useState, useMemo, useRef, useCallback, useSyncExternalStore, useLayoutEffect } from 'react';
|
|
3
|
-
import { unstable_getInternalStates,
|
|
3
|
+
import { unstable_getInternalStates, ref, proxy, snapshot, subscribe } from 'valtio/vanilla';
|
|
4
4
|
|
|
5
5
|
// src/react.tsx
|
|
6
6
|
var { refSet } = unstable_getInternalStates();
|
|
@@ -67,11 +67,13 @@ function diffSnapshots(before, after) {
|
|
|
67
67
|
|
|
68
68
|
// src/createState.ts
|
|
69
69
|
var stateBrand = /* @__PURE__ */ Symbol.for("opshot.state");
|
|
70
|
+
var metaBrand = /* @__PURE__ */ Symbol.for("opshot.meta");
|
|
70
71
|
var hasOwn = (value, key) => Object.hasOwn(value, key);
|
|
71
|
-
function
|
|
72
|
-
|
|
72
|
+
function isMeta(value) {
|
|
73
|
+
if (typeof value !== "object" || value === null || !hasOwn(value, metaBrand)) return false;
|
|
74
|
+
return value[metaBrand] === true;
|
|
73
75
|
}
|
|
74
|
-
function createGroupState(define, groupListeners) {
|
|
76
|
+
function createGroupState(define, groupListeners, metaToken) {
|
|
75
77
|
const callback = typeof define === "function" ? define : () => define;
|
|
76
78
|
const listeners = /* @__PURE__ */ new Set();
|
|
77
79
|
const created = {};
|
|
@@ -81,7 +83,8 @@ function createGroupState(define, groupListeners) {
|
|
|
81
83
|
return proxied;
|
|
82
84
|
};
|
|
83
85
|
const get = () => snapshot(requireProxy());
|
|
84
|
-
const mutate = (callback2,
|
|
86
|
+
const mutate = (callback2, ...metaArgs) => {
|
|
87
|
+
const [meta] = metaArgs;
|
|
85
88
|
const proxied = requireProxy();
|
|
86
89
|
if (handle.isMutating) throw new Error("opshot: nested mutate on the same state");
|
|
87
90
|
handle.isMutating = true;
|
|
@@ -96,8 +99,9 @@ function createGroupState(define, groupListeners) {
|
|
|
96
99
|
if (listeners.size === 0 && (groupListeners?.size ?? 0) === 0) return;
|
|
97
100
|
const ops = diffSnapshots(before, after);
|
|
98
101
|
if (ops.length === 0) return;
|
|
99
|
-
|
|
100
|
-
for (const listener of [...
|
|
102
|
+
const emittedMeta = metaToken?.defaults !== void 0 ? { ...metaToken.defaults, ...meta } : meta ?? {};
|
|
103
|
+
for (const listener of [...groupListeners ?? []]) listener(after, ops, emittedMeta);
|
|
104
|
+
for (const listener of [...listeners]) listener(after, ops, emittedMeta);
|
|
101
105
|
};
|
|
102
106
|
const subscribe = (listener) => {
|
|
103
107
|
listeners.add(listener);
|
|
@@ -107,17 +111,20 @@ function createGroupState(define, groupListeners) {
|
|
|
107
111
|
};
|
|
108
112
|
const isSameState = (other) => isState(other) && other.op === handle;
|
|
109
113
|
const unwrap = () => {
|
|
110
|
-
const { op, ...rest } = get();
|
|
114
|
+
const { op, mutate: mutate2, ...rest } = get();
|
|
111
115
|
return rest;
|
|
112
116
|
};
|
|
113
117
|
const literal = callback(mutate, get);
|
|
114
|
-
|
|
118
|
+
for (const key of ["op", "mutate"]) {
|
|
119
|
+
if (Object.hasOwn(literal, key)) throw new Error(`opshot: "${key}" is a reserved key on a state`);
|
|
120
|
+
}
|
|
115
121
|
const base = Object.create(Reflect.getPrototypeOf(literal));
|
|
116
122
|
Object.defineProperties(base, Object.getOwnPropertyDescriptors(literal));
|
|
117
|
-
const handle = {
|
|
123
|
+
const handle = { unsafeMutable: base, isMutating: false, subscribe, isSameState, unwrap, [stateBrand]: true };
|
|
118
124
|
Object.defineProperty(base, "op", { value: ref(handle), enumerable: true, writable: false, configurable: false });
|
|
125
|
+
Object.defineProperty(base, "mutate", { value: mutate, enumerable: true, writable: false, configurable: false });
|
|
119
126
|
created.proxied = proxy(base);
|
|
120
|
-
handle.
|
|
127
|
+
handle.unsafeMutable = created.proxied;
|
|
121
128
|
return get();
|
|
122
129
|
}
|
|
123
130
|
function isState(value) {
|
|
@@ -128,11 +135,11 @@ function isState(value) {
|
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
// src/createGroup.ts
|
|
131
|
-
function createGroup() {
|
|
138
|
+
function createGroup(meta) {
|
|
132
139
|
const listeners = /* @__PURE__ */ new Set();
|
|
133
140
|
return {
|
|
134
141
|
createState(define) {
|
|
135
|
-
return createGroupState(define, listeners);
|
|
142
|
+
return createGroupState(define, listeners, meta);
|
|
136
143
|
},
|
|
137
144
|
subscribe(listener) {
|
|
138
145
|
listeners.add(listener);
|
|
@@ -150,7 +157,7 @@ function shouldTraverse(value) {
|
|
|
150
157
|
const prototype = Object.getPrototypeOf(value);
|
|
151
158
|
return prototype === Object.prototype || prototype === null;
|
|
152
159
|
}
|
|
153
|
-
function
|
|
160
|
+
function findStatePaths(value, path = [], paths = []) {
|
|
154
161
|
if (isState(value)) {
|
|
155
162
|
paths.push(path);
|
|
156
163
|
return paths;
|
|
@@ -158,12 +165,12 @@ function findSnapshotPaths(value, path = [], paths = []) {
|
|
|
158
165
|
if (!shouldTraverse(value)) return paths;
|
|
159
166
|
if (Array.isArray(value)) {
|
|
160
167
|
value.forEach((item, index) => {
|
|
161
|
-
|
|
168
|
+
findStatePaths(item, [...path, index], paths);
|
|
162
169
|
});
|
|
163
170
|
} else {
|
|
164
171
|
for (const [key, propertyValue] of Object.entries(value)) {
|
|
165
172
|
if (key === "children") continue;
|
|
166
|
-
|
|
173
|
+
findStatePaths(propertyValue, [...path, key], paths);
|
|
167
174
|
}
|
|
168
175
|
}
|
|
169
176
|
return paths;
|
|
@@ -191,10 +198,10 @@ function setAtPath(object, path, value) {
|
|
|
191
198
|
return { ...object, [head]: updated };
|
|
192
199
|
}
|
|
193
200
|
var targetCache = /* @__PURE__ */ new WeakMap();
|
|
194
|
-
function
|
|
201
|
+
function useRetrackAll(states) {
|
|
195
202
|
const lastRendered = useRef([]);
|
|
196
203
|
const lastReturned = useRef([]);
|
|
197
|
-
const nextProxies =
|
|
204
|
+
const nextProxies = states.map((state) => state.op.unsafeMutable);
|
|
198
205
|
const [proxies, setProxies] = useState(nextProxies);
|
|
199
206
|
const isStale = proxies.length !== nextProxies.length || proxies.some((proxied, index) => proxied !== nextProxies[index]);
|
|
200
207
|
if (isStale) setProxies(nextProxies);
|
|
@@ -228,40 +235,45 @@ function useResnapshotAll(snapshots) {
|
|
|
228
235
|
},
|
|
229
236
|
[proxies, trackings]
|
|
230
237
|
);
|
|
231
|
-
const
|
|
238
|
+
const freshStates = useSyncExternalStore(subscribe$1, getSnapshot);
|
|
232
239
|
useLayoutEffect(() => {
|
|
233
|
-
lastRendered.current =
|
|
240
|
+
lastRendered.current = freshStates;
|
|
234
241
|
});
|
|
235
242
|
const trackedSnapshots = useMemo(
|
|
236
|
-
() =>
|
|
243
|
+
() => freshStates.map((snap, index) => {
|
|
237
244
|
const tracking = trackings[index];
|
|
238
245
|
if (!tracking) return snap;
|
|
239
246
|
return createProxy(snap, tracking.affected, tracking.proxyCache, targetCache);
|
|
240
247
|
}),
|
|
241
|
-
[
|
|
248
|
+
[freshStates, trackings]
|
|
242
249
|
);
|
|
243
|
-
return isStale ?
|
|
250
|
+
return isStale ? states : trackedSnapshots;
|
|
244
251
|
}
|
|
245
|
-
function
|
|
252
|
+
function retrack(component) {
|
|
246
253
|
const componentName = component.displayName ?? component.name;
|
|
247
|
-
const
|
|
248
|
-
const snapshotPaths = useMemo(() =>
|
|
249
|
-
const
|
|
250
|
-
const
|
|
254
|
+
const Retracked = (props) => {
|
|
255
|
+
const snapshotPaths = useMemo(() => findStatePaths(props), [props]);
|
|
256
|
+
const staleStates = useMemo(() => snapshotPaths.map((path) => getAtPath(props, path)).filter(isState), [props, snapshotPaths]);
|
|
257
|
+
const freshStates = useRetrackAll(staleStates);
|
|
251
258
|
const freshProps = useMemo(() => {
|
|
252
|
-
if (
|
|
253
|
-
return snapshotPaths.reduce((acc, path, index) => setAtPath(acc, path,
|
|
254
|
-
}, [props, snapshotPaths,
|
|
259
|
+
if (freshStates === staleStates) return props;
|
|
260
|
+
return snapshotPaths.reduce((acc, path, index) => setAtPath(acc, path, freshStates[index]), props);
|
|
261
|
+
}, [props, snapshotPaths, staleStates, freshStates]);
|
|
255
262
|
return component(freshProps);
|
|
256
263
|
};
|
|
257
|
-
|
|
258
|
-
return memo(
|
|
264
|
+
Retracked.displayName = `retrack(${componentName === "" ? "Anonymous" : componentName})`;
|
|
265
|
+
return memo(Retracked);
|
|
266
|
+
}
|
|
267
|
+
function useGroup(meta) {
|
|
268
|
+
return useState(() => meta === void 0 ? createGroup() : createGroup(meta))[0];
|
|
259
269
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
270
|
+
function useTrackedState(define, groupOrMeta) {
|
|
271
|
+
const created = useState(() => {
|
|
272
|
+
if (groupOrMeta !== void 0 && !isMeta(groupOrMeta)) return groupOrMeta.createState(define);
|
|
273
|
+
return createGroupState(define, void 0, groupOrMeta);
|
|
274
|
+
})[0];
|
|
275
|
+
const [fresh] = useRetrackAll([created]);
|
|
264
276
|
return fresh;
|
|
265
|
-
}
|
|
277
|
+
}
|
|
266
278
|
|
|
267
|
-
export {
|
|
279
|
+
export { retrack, useGroup, useTrackedState };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opshot",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Plain-object state for React: mutate it directly, re-render only the components that read what changed, and track every change operation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -68,5 +68,9 @@
|
|
|
68
68
|
"typescript": "^5.9.3",
|
|
69
69
|
"typescript-eslint": "^8.64.0",
|
|
70
70
|
"vitest": "^4.1.10"
|
|
71
|
+
},
|
|
72
|
+
"allowScripts": {
|
|
73
|
+
"esbuild@0.27.7": true,
|
|
74
|
+
"unrs-resolver@1.12.2": true
|
|
71
75
|
}
|
|
72
76
|
}
|