opshot 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/README.md ADDED
@@ -0,0 +1,173 @@
1
+ <p align="center"><img src="https://raw.githubusercontent.com/visionsofparadise/opshot/main/logo.svg" width="200" alt="The React logo holding a smoking revolver" /></p>
2
+
3
+ # opshot
4
+
5
+ Plain-object state for React: mutate it directly, re-render only the components that read what changed, and track every change operation.
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.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm install opshot
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ```tsx
21
+ import { useCreateState } from "opshot/react";
22
+
23
+ const Counter = () => {
24
+ const counter = useCreateState({ count: 0 });
25
+
26
+ // Mutate the object directly: assignments, push, delete all work.
27
+ const increment = () => counter.op.mutate((proxy) => proxy.count++);
28
+
29
+ return <button onClick={increment}>{counter.count}</button>;
30
+ };
31
+ ```
32
+
33
+ ## Creating state
34
+
35
+ ```tsx
36
+ import { ref } from "opshot";
37
+ import { useCreateState } from "opshot/react";
38
+
39
+ const Player = () => {
40
+ const player = useCreateState((mutate, get) => ({
41
+ position: 0,
42
+
43
+ // ref() keeps a value out of reactivity and ops.
44
+ element: ref(new Audio()),
45
+
46
+ // get() reads the current values.
47
+ seek: (position: number) => {
48
+ get().element.currentTime = position;
49
+
50
+ mutate((proxy) => (proxy.position = position));
51
+ },
52
+ }));
53
+
54
+ // ...
55
+ };
56
+ ```
57
+
58
+ ## state.op
59
+
60
+ Everything opshot attaches lives under one reserved key, `op`.
61
+
62
+ ```ts
63
+ // The write path. An optional second argument is passed to every subscriber.
64
+ counter.op.mutate((proxy) => {}, { transactionKey: "drag" });
65
+
66
+ // Hears every op this state emits; returns an unsubscribe.
67
+ const unsubscribe = counter.op.subscribe((state, ops, options) => {
68
+ // ...
69
+ });
70
+
71
+ // State references are not reliable for equality: every mutation produces a new one. Use this instead.
72
+ counter.op.isSameState(other);
73
+
74
+ // True while a mutate callback is running.
75
+ counter.op.isMutating;
76
+
77
+ // The current values as your plain object, op stripped: for serializing and reads outside render.
78
+ counter.op.unwrap();
79
+
80
+ // The underlying valtio proxy, typed object: an escape hatch.
81
+ counter.op.proxy;
82
+ ```
83
+
84
+ ## Ops
85
+
86
+ ```ts
87
+ const unsubscribe = counter.op.subscribe((state, ops, options) => {
88
+ // state: the snapshot these ops produced
89
+ // ops: [{
90
+ // do: { op: "replace", path: "/count", value: 1 },
91
+ // undo: { op: "replace", path: "/count", value: 0 },
92
+ // }]
93
+ });
94
+ ```
95
+
96
+ An op is a pair of [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) patch operations, each half carrying its own value, so any JSON Patch tool applies and inverts them.
97
+
98
+ A subscriber must not write to the state it subscribes to; writing to a different state is fine.
99
+
100
+ Ops cost nothing until someone listens: a state with no subscribers, on itself or its group, skips computing them entirely.
101
+
102
+ ## Groups
103
+
104
+ A group creates states and hears every op from the states it created: one stream for history, sync, and persistence.
105
+
106
+ ```tsx
107
+ import { useEffect } from "react";
108
+ import { useCreateGroup, useCreateState } from "opshot/react";
109
+
110
+ const Editor = () => {
111
+ // A lifetime-stable group.
112
+ const group = useCreateGroup();
113
+
114
+ // Created through the group, so its ops reach the group's subscribers.
115
+ const doc = useCreateState({ items: new Array<string>() }, group);
116
+
117
+ useEffect(
118
+ () =>
119
+ // Fires for doc and every other state the group created.
120
+ group.subscribe((state, ops, options) => {
121
+ // ...
122
+ }),
123
+ [group],
124
+ );
125
+
126
+ // ...
127
+ };
128
+ ```
129
+
130
+ ## resnapshot
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.
133
+
134
+ This is a lever for bounding re-renders. Here `CounterButton` is plain, so its `count` read belongs to `App`, and every click re-renders `App` and everything under it:
135
+
136
+ ```tsx
137
+ import type { State } from "opshot";
138
+ import { useCreateState } from "opshot/react";
139
+
140
+ interface Counter {
141
+ title: string;
142
+ count: number;
143
+ }
144
+
145
+ // Every click re-renders App and its whole subtree.
146
+ const App = () => {
147
+ const counter = useCreateState<Counter>({ title: "Hits", count: 0 });
148
+
149
+ return (
150
+ <>
151
+ <h1>{counter.title}</h1>
152
+ <CounterButton counter={counter} />
153
+ </>
154
+ );
155
+ };
156
+
157
+ const CounterButton = ({ counter }: { counter: State<Counter> }) => (
158
+ <button onClick={() => counter.op.mutate((proxy) => proxy.count++)}>{counter.count}</button>
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
+ ));
171
+ ```
172
+
173
+ Subscribed components re-render independently: had `App` also read `count`, both would re-render.
@@ -0,0 +1,47 @@
1
+ import { Snapshot } from 'valtio/vanilla';
2
+ export { Snapshot, ref } from 'valtio/vanilla';
3
+
4
+ type PatchOperation = {
5
+ readonly op: "add";
6
+ readonly path: string;
7
+ readonly value: unknown;
8
+ } | {
9
+ readonly op: "replace";
10
+ readonly path: string;
11
+ readonly value: unknown;
12
+ } | {
13
+ readonly op: "remove";
14
+ readonly path: string;
15
+ };
16
+ interface Op {
17
+ readonly do: PatchOperation;
18
+ readonly undo: PatchOperation;
19
+ }
20
+ declare function diffSnapshots(before: unknown, after: unknown): Array<Op>;
21
+
22
+ type MutateOptions = Record<string, unknown>;
23
+ type Mutate<T extends object> = (callback: (proxy: T) => void, options?: MutateOptions) => void;
24
+ type StateListener<T extends object> = (state: State<T>, ops: Array<Op>, options: MutateOptions) => void;
25
+ interface OpshotHandle<T extends object> {
26
+ readonly proxy: object;
27
+ readonly isMutating: boolean;
28
+ readonly mutate: Mutate<T>;
29
+ readonly subscribe: (listener: StateListener<T>) => () => void;
30
+ readonly isSameState: (other: unknown) => boolean;
31
+ readonly unwrap: () => Snapshot<T>;
32
+ }
33
+ type State<T extends object> = Snapshot<T> & {
34
+ readonly op: OpshotHandle<T>;
35
+ };
36
+ type DefineCallback<T extends object> = (mutate: Mutate<T>, get: () => State<T>) => T;
37
+ type Define<T extends object> = DefineCallback<T> | T;
38
+ declare function createState<T extends object>(define: Define<T>): State<T>;
39
+ declare function isState(value: unknown): value is State<object>;
40
+
41
+ interface Group {
42
+ createState<T extends object>(define: Define<T>): State<T>;
43
+ subscribe(listener: StateListener<object>): () => void;
44
+ }
45
+ declare function createGroup(): Group;
46
+
47
+ export { type Define, type DefineCallback, type Group, type Mutate, type MutateOptions, type Op, type OpshotHandle, type PatchOperation, type State, type StateListener, createGroup, createState, diffSnapshots, isState };
package/dist/index.js ADDED
@@ -0,0 +1,145 @@
1
+ import { unstable_getInternalStates, ref, proxy, snapshot } from 'valtio/vanilla';
2
+ export { ref } from 'valtio/vanilla';
3
+
4
+ // src/index.ts
5
+ var { refSet } = unstable_getInternalStates();
6
+ var isPlainArray = (value) => Array.isArray(value) && !refSet.has(value);
7
+ var isPlainObject = (value) => {
8
+ if (typeof value !== "object" || value === null || Array.isArray(value) || refSet.has(value)) return false;
9
+ const prototype = Object.getPrototypeOf(value);
10
+ return prototype === Object.prototype || prototype === null;
11
+ };
12
+ var isCloneable = (value) => isPlainObject(value) || isPlainArray(value);
13
+ var cloneValue = (value) => {
14
+ if (isPlainArray(value)) return value.map(cloneValue);
15
+ if (isPlainObject(value)) {
16
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneValue(child)]));
17
+ }
18
+ return value;
19
+ };
20
+ var toPointer = (path) => {
21
+ if (path.length === 0) return "";
22
+ return `/${path.map((segment) => String(segment).replaceAll("~", "~0").replaceAll("/", "~1")).join("/")}`;
23
+ };
24
+ var removing = (pointer) => ({ op: "remove", path: pointer });
25
+ var carrying = (op, pointer, value) => {
26
+ if (!isCloneable(value)) return { op, path: pointer, value };
27
+ return {
28
+ op,
29
+ path: pointer,
30
+ get value() {
31
+ return cloneValue(value);
32
+ }
33
+ };
34
+ };
35
+ var addPair = (pointer, after) => ({ do: carrying("add", pointer, after), undo: removing(pointer) });
36
+ var removePair = (pointer, before) => ({ do: removing(pointer), undo: carrying("add", pointer, before) });
37
+ var replacePair = (pointer, before, after) => ({
38
+ do: carrying("replace", pointer, after),
39
+ undo: carrying("replace", pointer, before)
40
+ });
41
+ var diffValue = (before, after, path, ops) => {
42
+ if (Object.is(before, after)) return;
43
+ if (isPlainArray(before) && isPlainArray(after)) {
44
+ if (before.length !== after.length) {
45
+ ops.push(replacePair(toPointer(path), before, after));
46
+ return;
47
+ }
48
+ for (let index = 0; index < after.length; index++) diffValue(before[index], after[index], [...path, index], ops);
49
+ return;
50
+ }
51
+ if (isPlainObject(before) && isPlainObject(after)) {
52
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])) {
53
+ if (!Object.hasOwn(before, key)) ops.push(addPair(toPointer([...path, key]), after[key]));
54
+ else if (!Object.hasOwn(after, key)) ops.push(removePair(toPointer([...path, key]), before[key]));
55
+ else diffValue(before[key], after[key], [...path, key], ops);
56
+ }
57
+ return;
58
+ }
59
+ ops.push(replacePair(toPointer(path), before, after));
60
+ };
61
+ function diffSnapshots(before, after) {
62
+ const ops = [];
63
+ diffValue(before, after, [], ops);
64
+ return ops;
65
+ }
66
+
67
+ // src/createState.ts
68
+ var stateBrand = /* @__PURE__ */ Symbol.for("opshot.state");
69
+ var hasOwn = (value, key) => Object.hasOwn(value, key);
70
+ function createState(define) {
71
+ return createGroupState(define);
72
+ }
73
+ function createGroupState(define, groupListeners) {
74
+ const callback = typeof define === "function" ? define : () => define;
75
+ const listeners = /* @__PURE__ */ new Set();
76
+ const created = {};
77
+ const requireProxy = () => {
78
+ const { proxied } = created;
79
+ if (!proxied) throw new Error("opshot: called during createState definition");
80
+ return proxied;
81
+ };
82
+ const get = () => snapshot(requireProxy());
83
+ const mutate = (callback2, options = {}) => {
84
+ const proxied = requireProxy();
85
+ if (handle.isMutating) throw new Error("opshot: nested mutate on the same state");
86
+ handle.isMutating = true;
87
+ const before = snapshot(proxied);
88
+ try {
89
+ callback2(proxied);
90
+ } finally {
91
+ handle.isMutating = false;
92
+ }
93
+ const after = snapshot(proxied);
94
+ if (before === after) return;
95
+ if (listeners.size === 0 && (groupListeners?.size ?? 0) === 0) return;
96
+ const ops = diffSnapshots(before, after);
97
+ if (ops.length === 0) return;
98
+ for (const listener of [...groupListeners ?? []]) listener(after, ops, options);
99
+ for (const listener of [...listeners]) listener(after, ops, options);
100
+ };
101
+ const subscribe = (listener) => {
102
+ listeners.add(listener);
103
+ return () => {
104
+ listeners.delete(listener);
105
+ };
106
+ };
107
+ const isSameState = (other) => isState(other) && other.op === handle;
108
+ const unwrap = () => {
109
+ const { op, ...rest } = get();
110
+ return rest;
111
+ };
112
+ const literal = callback(mutate, get);
113
+ if (Object.hasOwn(literal, "op")) throw new Error('opshot: "op" is a reserved key on a state');
114
+ const base = Object.create(Reflect.getPrototypeOf(literal));
115
+ Object.defineProperties(base, Object.getOwnPropertyDescriptors(literal));
116
+ const handle = { proxy: base, isMutating: false, mutate, subscribe, isSameState, unwrap, [stateBrand]: true };
117
+ Object.defineProperty(base, "op", { value: ref(handle), enumerable: true, writable: false, configurable: false });
118
+ created.proxied = proxy(base);
119
+ handle.proxy = created.proxied;
120
+ return get();
121
+ }
122
+ function isState(value) {
123
+ if (typeof value !== "object" || value === null || !hasOwn(value, "op")) return false;
124
+ const handle = value.op;
125
+ if (typeof handle !== "object" || handle === null || !hasOwn(handle, stateBrand)) return false;
126
+ return handle[stateBrand] === true;
127
+ }
128
+
129
+ // src/createGroup.ts
130
+ function createGroup() {
131
+ const listeners = /* @__PURE__ */ new Set();
132
+ return {
133
+ createState(define) {
134
+ return createGroupState(define, listeners);
135
+ },
136
+ subscribe(listener) {
137
+ listeners.add(listener);
138
+ return () => {
139
+ listeners.delete(listener);
140
+ };
141
+ }
142
+ };
143
+ }
144
+
145
+ export { createGroup, createState, diffSnapshots, isState };
@@ -0,0 +1,9 @@
1
+ import { FC } from 'react';
2
+ import { Group, Define, State } from './index.js';
3
+ import 'valtio/vanilla';
4
+
5
+ declare function resnapshot<P extends object>(component: FC<P>): FC<P>;
6
+ declare const useCreateGroup: () => Group;
7
+ declare const useCreateState: <T extends object>(define: Define<T>, group?: Group) => State<T>;
8
+
9
+ export { resnapshot, useCreateGroup, useCreateState };
package/dist/react.js ADDED
@@ -0,0 +1,267 @@
1
+ import { isChanged, createProxy } from 'proxy-compare';
2
+ import { memo, useState, useMemo, useRef, useCallback, useSyncExternalStore, useLayoutEffect } from 'react';
3
+ import { unstable_getInternalStates, snapshot, subscribe, ref, proxy } from 'valtio/vanilla';
4
+
5
+ // src/react.tsx
6
+ var { refSet } = unstable_getInternalStates();
7
+ var isPlainArray = (value) => Array.isArray(value) && !refSet.has(value);
8
+ var isPlainObject = (value) => {
9
+ if (typeof value !== "object" || value === null || Array.isArray(value) || refSet.has(value)) return false;
10
+ const prototype = Object.getPrototypeOf(value);
11
+ return prototype === Object.prototype || prototype === null;
12
+ };
13
+ var isCloneable = (value) => isPlainObject(value) || isPlainArray(value);
14
+ var cloneValue = (value) => {
15
+ if (isPlainArray(value)) return value.map(cloneValue);
16
+ if (isPlainObject(value)) {
17
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneValue(child)]));
18
+ }
19
+ return value;
20
+ };
21
+ var toPointer = (path) => {
22
+ if (path.length === 0) return "";
23
+ return `/${path.map((segment) => String(segment).replaceAll("~", "~0").replaceAll("/", "~1")).join("/")}`;
24
+ };
25
+ var removing = (pointer) => ({ op: "remove", path: pointer });
26
+ var carrying = (op, pointer, value) => {
27
+ if (!isCloneable(value)) return { op, path: pointer, value };
28
+ return {
29
+ op,
30
+ path: pointer,
31
+ get value() {
32
+ return cloneValue(value);
33
+ }
34
+ };
35
+ };
36
+ var addPair = (pointer, after) => ({ do: carrying("add", pointer, after), undo: removing(pointer) });
37
+ var removePair = (pointer, before) => ({ do: removing(pointer), undo: carrying("add", pointer, before) });
38
+ var replacePair = (pointer, before, after) => ({
39
+ do: carrying("replace", pointer, after),
40
+ undo: carrying("replace", pointer, before)
41
+ });
42
+ var diffValue = (before, after, path, ops) => {
43
+ if (Object.is(before, after)) return;
44
+ if (isPlainArray(before) && isPlainArray(after)) {
45
+ if (before.length !== after.length) {
46
+ ops.push(replacePair(toPointer(path), before, after));
47
+ return;
48
+ }
49
+ for (let index = 0; index < after.length; index++) diffValue(before[index], after[index], [...path, index], ops);
50
+ return;
51
+ }
52
+ if (isPlainObject(before) && isPlainObject(after)) {
53
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])) {
54
+ if (!Object.hasOwn(before, key)) ops.push(addPair(toPointer([...path, key]), after[key]));
55
+ else if (!Object.hasOwn(after, key)) ops.push(removePair(toPointer([...path, key]), before[key]));
56
+ else diffValue(before[key], after[key], [...path, key], ops);
57
+ }
58
+ return;
59
+ }
60
+ ops.push(replacePair(toPointer(path), before, after));
61
+ };
62
+ function diffSnapshots(before, after) {
63
+ const ops = [];
64
+ diffValue(before, after, [], ops);
65
+ return ops;
66
+ }
67
+
68
+ // src/createState.ts
69
+ var stateBrand = /* @__PURE__ */ Symbol.for("opshot.state");
70
+ var hasOwn = (value, key) => Object.hasOwn(value, key);
71
+ function createState(define) {
72
+ return createGroupState(define);
73
+ }
74
+ function createGroupState(define, groupListeners) {
75
+ const callback = typeof define === "function" ? define : () => define;
76
+ const listeners = /* @__PURE__ */ new Set();
77
+ const created = {};
78
+ const requireProxy = () => {
79
+ const { proxied } = created;
80
+ if (!proxied) throw new Error("opshot: called during createState definition");
81
+ return proxied;
82
+ };
83
+ const get = () => snapshot(requireProxy());
84
+ const mutate = (callback2, options = {}) => {
85
+ const proxied = requireProxy();
86
+ if (handle.isMutating) throw new Error("opshot: nested mutate on the same state");
87
+ handle.isMutating = true;
88
+ const before = snapshot(proxied);
89
+ try {
90
+ callback2(proxied);
91
+ } finally {
92
+ handle.isMutating = false;
93
+ }
94
+ const after = snapshot(proxied);
95
+ if (before === after) return;
96
+ if (listeners.size === 0 && (groupListeners?.size ?? 0) === 0) return;
97
+ const ops = diffSnapshots(before, after);
98
+ if (ops.length === 0) return;
99
+ for (const listener of [...groupListeners ?? []]) listener(after, ops, options);
100
+ for (const listener of [...listeners]) listener(after, ops, options);
101
+ };
102
+ const subscribe = (listener) => {
103
+ listeners.add(listener);
104
+ return () => {
105
+ listeners.delete(listener);
106
+ };
107
+ };
108
+ const isSameState = (other) => isState(other) && other.op === handle;
109
+ const unwrap = () => {
110
+ const { op, ...rest } = get();
111
+ return rest;
112
+ };
113
+ const literal = callback(mutate, get);
114
+ if (Object.hasOwn(literal, "op")) throw new Error('opshot: "op" is a reserved key on a state');
115
+ const base = Object.create(Reflect.getPrototypeOf(literal));
116
+ Object.defineProperties(base, Object.getOwnPropertyDescriptors(literal));
117
+ const handle = { proxy: base, isMutating: false, mutate, subscribe, isSameState, unwrap, [stateBrand]: true };
118
+ Object.defineProperty(base, "op", { value: ref(handle), enumerable: true, writable: false, configurable: false });
119
+ created.proxied = proxy(base);
120
+ handle.proxy = created.proxied;
121
+ return get();
122
+ }
123
+ function isState(value) {
124
+ if (typeof value !== "object" || value === null || !hasOwn(value, "op")) return false;
125
+ const handle = value.op;
126
+ if (typeof handle !== "object" || handle === null || !hasOwn(handle, stateBrand)) return false;
127
+ return handle[stateBrand] === true;
128
+ }
129
+
130
+ // src/createGroup.ts
131
+ function createGroup() {
132
+ const listeners = /* @__PURE__ */ new Set();
133
+ return {
134
+ createState(define) {
135
+ return createGroupState(define, listeners);
136
+ },
137
+ subscribe(listener) {
138
+ listeners.add(listener);
139
+ return () => {
140
+ listeners.delete(listener);
141
+ };
142
+ }
143
+ };
144
+ }
145
+
146
+ // src/react.tsx
147
+ function shouldTraverse(value) {
148
+ if (value === null || typeof value !== "object") return false;
149
+ if (Array.isArray(value)) return true;
150
+ const prototype = Object.getPrototypeOf(value);
151
+ return prototype === Object.prototype || prototype === null;
152
+ }
153
+ function findSnapshotPaths(value, path = [], paths = []) {
154
+ if (isState(value)) {
155
+ paths.push(path);
156
+ return paths;
157
+ }
158
+ if (!shouldTraverse(value)) return paths;
159
+ if (Array.isArray(value)) {
160
+ value.forEach((item, index) => {
161
+ findSnapshotPaths(item, [...path, index], paths);
162
+ });
163
+ } else {
164
+ for (const [key, propertyValue] of Object.entries(value)) {
165
+ if (key === "children") continue;
166
+ findSnapshotPaths(propertyValue, [...path, key], paths);
167
+ }
168
+ }
169
+ return paths;
170
+ }
171
+ function getAtPath(object, path) {
172
+ let current = object;
173
+ for (const segment of path) {
174
+ if (current === null || current === void 0) return void 0;
175
+ current = current[segment];
176
+ }
177
+ return current;
178
+ }
179
+ function setAtPath(object, path, value) {
180
+ if (path.length === 0) return value;
181
+ const head = path[0];
182
+ if (head === void 0) throw new Error("setAtPath: non-empty path yielded no head segment");
183
+ const tail = path.slice(1);
184
+ const current = object[head];
185
+ const updated = setAtPath(current, tail, value);
186
+ if (Array.isArray(object)) {
187
+ const clone = [...object];
188
+ clone[head] = updated;
189
+ return clone;
190
+ }
191
+ return { ...object, [head]: updated };
192
+ }
193
+ var targetCache = /* @__PURE__ */ new WeakMap();
194
+ function useResnapshotAll(snapshots) {
195
+ const lastRendered = useRef([]);
196
+ const lastReturned = useRef([]);
197
+ const nextProxies = snapshots.map((snap) => snap.op.proxy);
198
+ const [proxies, setProxies] = useState(nextProxies);
199
+ const isStale = proxies.length !== nextProxies.length || proxies.some((proxied, index) => proxied !== nextProxies[index]);
200
+ if (isStale) setProxies(nextProxies);
201
+ const trackings = useMemo(() => proxies.map(() => ({ affected: /* @__PURE__ */ new WeakMap(), proxyCache: /* @__PURE__ */ new WeakMap() })), [proxies]);
202
+ const getSnapshot = useCallback(() => {
203
+ const next = proxies.map((proxied) => snapshot(proxied));
204
+ const last = lastReturned.current;
205
+ if (last.length === next.length && last.every((snap, index) => snap === next[index])) return last;
206
+ lastReturned.current = next;
207
+ return next;
208
+ }, [proxies]);
209
+ const subscribe$1 = useCallback(
210
+ (callback) => {
211
+ const unsubscribes = proxies.map(
212
+ (proxied, index) => subscribe(proxied, () => {
213
+ const prev = lastRendered.current[index];
214
+ const tracking = trackings[index];
215
+ if (prev && tracking && prev !== snapshot(proxied)) {
216
+ if (!tracking.affected.has(prev)) return;
217
+ try {
218
+ if (!isChanged(prev, snapshot(proxied), tracking.affected, /* @__PURE__ */ new WeakMap())) return;
219
+ } catch {
220
+ }
221
+ }
222
+ callback();
223
+ })
224
+ );
225
+ return () => {
226
+ for (const unsubscribe of unsubscribes) unsubscribe();
227
+ };
228
+ },
229
+ [proxies, trackings]
230
+ );
231
+ const freshSnapshots = useSyncExternalStore(subscribe$1, getSnapshot);
232
+ useLayoutEffect(() => {
233
+ lastRendered.current = freshSnapshots;
234
+ });
235
+ const trackedSnapshots = useMemo(
236
+ () => freshSnapshots.map((snap, index) => {
237
+ const tracking = trackings[index];
238
+ if (!tracking) return snap;
239
+ return createProxy(snap, tracking.affected, tracking.proxyCache, targetCache);
240
+ }),
241
+ [freshSnapshots, trackings]
242
+ );
243
+ return isStale ? snapshots : trackedSnapshots;
244
+ }
245
+ function resnapshot(component) {
246
+ const componentName = component.displayName ?? component.name;
247
+ const Resnapshotted = (props) => {
248
+ const snapshotPaths = useMemo(() => findSnapshotPaths(props), [props]);
249
+ const staleSnapshots = useMemo(() => snapshotPaths.map((path) => getAtPath(props, path)).filter(isState), [props, snapshotPaths]);
250
+ const freshSnapshots = useResnapshotAll(staleSnapshots);
251
+ const freshProps = useMemo(() => {
252
+ if (freshSnapshots === staleSnapshots) return props;
253
+ return snapshotPaths.reduce((acc, path, index) => setAtPath(acc, path, freshSnapshots[index]), props);
254
+ }, [props, snapshotPaths, staleSnapshots, freshSnapshots]);
255
+ return component(freshProps);
256
+ };
257
+ Resnapshotted.displayName = `resnapshot(${componentName === "" ? "Anonymous" : componentName})`;
258
+ return memo(Resnapshotted);
259
+ }
260
+ var useCreateGroup = () => useState(() => createGroup())[0];
261
+ var useCreateState = (define, group) => {
262
+ const created = useState(() => group ? group.createState(define) : createState(define))[0];
263
+ const [fresh] = useResnapshotAll([created]);
264
+ return fresh;
265
+ };
266
+
267
+ export { resnapshot, useCreateGroup, useCreateState };
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "opshot",
3
+ "version": "0.1.0",
4
+ "description": "Valtio state with snapshot reads, one mutate write path, and snapshot-diff ops",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/index.js",
9
+ "types": "./dist/index.d.ts"
10
+ },
11
+ "./react": {
12
+ "import": "./dist/react.js",
13
+ "types": "./dist/react.d.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "sideEffects": false,
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "author": "Matt Cavender",
24
+ "license": "ISC",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/visionsofparadise/opshot.git"
28
+ },
29
+ "scripts": {
30
+ "check": "concurrently \"eslint . --fix --cache --format ./agent-eslint.js\" \"tsc --noEmit --pretty false 2>&1 | node ./agent-tsc.js\"",
31
+ "check:verbose": "concurrently \"eslint . --cache\" \"tsc --noEmit\"",
32
+ "lint": "eslint . --fix --cache --format ./agent-eslint.js",
33
+ "lint:verbose": "eslint .",
34
+ "lint:fix": "eslint . --fix",
35
+ "unit": "vitest run unit",
36
+ "build": "tsup --config tsup.config.ts"
37
+ },
38
+ "peerDependencies": {
39
+ "react": ">=18.0.0"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "react": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "dependencies": {
47
+ "proxy-compare": "^3.0.1",
48
+ "valtio": "2.3.2"
49
+ },
50
+ "devDependencies": {
51
+ "@eslint/js": "^9.39.5",
52
+ "@stylistic/eslint-plugin": "^5.10.0",
53
+ "@testing-library/react": "^16.3.2",
54
+ "@types/react": "^19.2.17",
55
+ "concurrently": "^10.0.3",
56
+ "eslint": "^9.39.5",
57
+ "eslint-plugin-barrel-files": "^3.0.1",
58
+ "eslint-plugin-check-file": "^3.3.1",
59
+ "eslint-plugin-import-x": "^4.17.1",
60
+ "eslint-plugin-react": "^7.37.5",
61
+ "eslint-plugin-react-hooks": "^7.1.1",
62
+ "fast-json-patch": "^3.1.1",
63
+ "globals": "^17.7.0",
64
+ "jsdom": "^29.1.1",
65
+ "react": "^19.2.7",
66
+ "react-dom": "^19.2.7",
67
+ "tsup": "^8.5.1",
68
+ "typescript": "^5.9.3",
69
+ "typescript-eslint": "^8.64.0",
70
+ "vitest": "^4.1.10"
71
+ }
72
+ }