opshot 0.3.4 → 0.5.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.
Files changed (4) hide show
  1. package/README.md +43 -113
  2. package/dist/index.d.ts +156 -69
  3. package/dist/index.js +1105 -1789
  4. package/package.json +19 -12
package/README.md CHANGED
@@ -28,12 +28,6 @@ const user = useMutableState({ name: "Ada", age: 36 });
28
28
  user.age = 37;
29
29
  ```
30
30
 
31
- The state is created once, on the first render. Pass a function to build the properties once as well, exactly as `useState` does:
32
-
33
- ```tsx
34
- const navigation = useMutableState(createNavigation);
35
- ```
36
-
37
31
  ## Bounded re-renders
38
32
 
39
33
  React re-renders a component and its children when its state changes.
@@ -88,23 +82,23 @@ This is how you optimize re-rendering across your component tree: place `scope`
88
82
  ## Creating State
89
83
 
90
84
  ```tsx
91
- import { ignore, unsafeTrack, useMutableState, type Ignored, type UnsafeTracked } from "opshot";
85
+ import { ignore, unsafeTrack, useMutableState } from "opshot";
92
86
 
93
87
  interface PlayerState {
94
88
  position: number;
95
- element: Ignored<HTMLAudioElement>;
96
- queue: UnsafeTracked<Playlist>;
89
+ element: HTMLAudioElement;
90
+ queue: Playlist;
97
91
  seek: (position: number) => void;
98
92
  }
99
93
 
100
94
  const Player = () => {
101
- const player = useMutableState<PlayerState>({
95
+ const player: PlayerState = useMutableState({
102
96
  position: 0,
103
97
 
104
- // ignore() keeps a value out of reactivity and ops.
98
+ // ignore() stores it as-is; opshot never looks inside.
105
99
  element: ignore(new Audio()),
106
100
 
107
- // unsafeTrack() tracks all the values it can, even if there is weird behaviour
101
+ // unsafeTrack() takes it anyway, tracking the plain data on it.
108
102
  queue: unsafeTrack(new Playlist()),
109
103
 
110
104
  seek(position: number) {
@@ -126,13 +120,14 @@ opshot tracks plain data.
126
120
 
127
121
  It can't track:
128
122
 
129
- - Internal slots (language level features like in Map)
130
- - #private fields (hidden at the language level)
131
- - Array subclasses (the prototype is lost when copied)
123
+ - Hidden stores (language-level features like in Map)
124
+ - #private fields
125
+ - Own function properties on class instances
126
+ - Non-writable properties that hold an object
132
127
 
133
- And `this` for arrow methods on classes refers to the original and **not** the tracked state.
128
+ By default opshot throws when it meets one of these, naming the value that caused it. Passing `strict: false` turns off those errors but may cause unpredictable behaviour.
134
129
 
135
- Use `ignore` or `unsafeTrack` when dealing with these.
130
+ `ignore(value)` stores a value without state inside it being tracked, and `ignore(value, false)` undoes that. `unsafeTrack(value)` does the reverse: it takes a value strict mode would reject, tracking the plain data on it and quietly missing the rest. Either mark only affects states the value enters afterwards.
136
131
 
137
132
  ## Tracked collections
138
133
 
@@ -159,12 +154,8 @@ const Counter = () => {
159
154
 
160
155
  useEffect(
161
156
  () =>
162
- subscribe(counter, (ops, meta) => {
163
- // ops: [{
164
- // do: { op: "replace", path: ["count"], value: 1 },
165
- // undo: { op: "replace", path: ["count"], value: 0 },
166
- // }]
167
- // meta: whatever the writer passed, or undefined for bare writes
157
+ subscribe(counter, (operations) => {
158
+ // operations: [{ node, key: "count", before: 0, after: 1, meta: undefined }]
168
159
  }),
169
160
  [counter],
170
161
  );
@@ -173,127 +164,63 @@ const Counter = () => {
173
164
  };
174
165
  ```
175
166
 
176
- ## Ops
167
+ ## Operations
177
168
 
178
- An op is an invertible pair of `Operation` halves. Every half uses one of three verbs:
169
+ An operation is one key's change on one node:
179
170
 
180
171
  ```ts
181
- type OperationPath = ReadonlyArray<string | number>;
182
-
183
- type Operation =
184
- | { readonly op: "add"; readonly path: OperationPath; readonly value: unknown }
185
- | { readonly op: "replace"; readonly path: OperationPath; readonly value: unknown }
186
- | { readonly op: "remove"; readonly path: OperationPath };
187
-
188
- interface Op {
189
- readonly do: Operation;
190
- readonly undo: Operation;
172
+ interface Operation {
173
+ readonly node: object;
174
+ readonly key: string;
175
+ readonly before?: unknown;
176
+ readonly after?: unknown;
177
+ readonly meta: unknown;
191
178
  }
192
179
  ```
193
180
 
194
- `applyOps` puts them back on a state, so a history is a list of ops and an undo is their `undo` halves in reverse.
195
-
196
- ```tsx
197
- import { useEffect, useRef } from "react";
198
- import { applyOps, subscribe, useMutableState, type Op } from "opshot";
199
-
200
- const replay = {};
201
-
202
- const Counter = () => {
203
- const counter = useMutableState({ count: 0 });
204
- const history = useRef<Array<ReadonlyArray<Op>>>([]);
205
-
206
- useEffect(
207
- () =>
208
- subscribe(counter, (ops, meta) => {
209
- // Skip our own replays, so undo doesn't record itself.
210
- if (meta === replay) return;
181
+ `before` and `after` are absent properties when the key was absent.
211
182
 
212
- history.current.push(ops);
213
- }),
214
- [counter],
215
- );
183
+ ## Emission
216
184
 
217
- const undo = () => {
218
- const ops = history.current.pop();
185
+ A state gathers its writes and delivers them together. The window is a microtask by default, so everything you change in one go arrives as one emission carrying the net change — a listener hears where a field ended up, not every step it took there.
219
186
 
220
- if (!ops) return;
221
-
222
- applyOps(
223
- counter,
224
- [...ops].reverse().map((op) => op.undo),
225
- replay,
226
- );
227
- };
228
-
229
- return (
230
- <>
231
- <button onClick={() => counter.count++}>+</button>
232
- <button onClick={undo}>Undo</button>
233
- </>
234
- );
235
- };
236
- ```
237
-
238
- Replay is exact for anything opshot can see: plain data. State behind a constraint is the exception.
239
-
240
- If your state is JSON serializable, **then ops are too**.
241
-
242
- ## Groups
243
-
244
- A group creates states and hears every op from the states it created: one stream for history, sync, persistence, etc.
187
+ `emitOn` sets the window instead. opshot hands you a `flush`, and the state waits until you call it.
245
188
 
246
189
  ```tsx
247
190
  import { useEffect } from "react";
248
- import { subscribe, useGroup, useMutableState } from "opshot";
249
-
250
- const Editor = () => {
251
- const group = useGroup();
191
+ import { subscribe, useMutableState } from "opshot";
252
192
 
253
- // Created through the group, so their ops reach the group's subscribers.
254
- const doc = useMutableState({ items: new Array<string>() }, group);
255
- const selection = useMutableState({ index: 0 }, group);
193
+ const Chart = () => {
194
+ // One emission per frame, however many writes land in between.
195
+ const cursor = useMutableState({ x: 0, y: 0 }, { emitOn: (flush) => requestAnimationFrame(flush) });
256
196
 
257
197
  useEffect(
258
198
  () =>
259
- // Fires for doc, selection, and every other state the group created.
260
- // state is whichever one changed.
261
- subscribe(group, (state, ops, meta) => {
199
+ subscribe(cursor, (operations) => {
262
200
  // ...
263
201
  }),
264
- [group],
202
+ [cursor],
265
203
  );
266
204
 
267
205
  // ...
268
206
  };
269
207
  ```
270
208
 
271
- ## Channels
209
+ ## Batches
272
210
 
273
- A channel binds `transact`, `subscribe`, and `applyOps` to a typed meta convention, so a listener can tell its own writes from everyone else's.
211
+ `batch` runs a callback and tags every write inside it with your `meta`, so a listener can tell its own writes from everyone else's.
274
212
 
275
213
  ```tsx
276
214
  import { useEffect } from "react";
277
- import { createChannel, useMutableState } from "opshot";
278
-
279
- interface DocumentMeta {
280
- replay?: boolean;
281
- source?: string;
282
- }
283
-
284
- const docChannel = createChannel<DocumentMeta>({ source: "editor" }); // set defaults
215
+ import { batch, subscribe, useMutableState } from "opshot";
285
216
 
286
217
  const TitleBar = () => {
287
218
  const doc = useMutableState({ title: "Untitled" });
288
219
 
289
220
  useEffect(
290
221
  () =>
291
- docChannel.subscribe(doc, (ops, context) => {
292
- // A bare write, or a transact from another channel: meta is unknown.
293
- if (!context.isTransaction) return;
294
-
295
- // Own-channel transaction: meta is typed, with defaults merged.
296
- if (context.meta.replay) return;
222
+ subscribe(doc, (operations) => {
223
+ if (operations[0]?.meta === "replay") return;
297
224
 
298
225
  // ...
299
226
  }),
@@ -301,9 +228,12 @@ const TitleBar = () => {
301
228
  );
302
229
 
303
230
  const rename = () => {
304
- docChannel.transact(doc, () => {
305
- doc.title = "Draft";
306
- });
231
+ batch(
232
+ () => {
233
+ doc.title = "Draft";
234
+ },
235
+ { source: "editor" },
236
+ );
307
237
  };
308
238
 
309
239
  // ...
package/dist/index.d.ts CHANGED
@@ -1,75 +1,112 @@
1
1
  import { ComponentType, FC } from 'react';
2
2
 
3
- type OperationPath = ReadonlyArray<string | number>;
4
-
5
- interface AddOperation {
6
- readonly op: "add";
7
- readonly path: OperationPath;
8
- readonly value: unknown;
9
- }
10
- interface ReplaceOperation {
11
- readonly op: "replace";
12
- readonly path: OperationPath;
13
- readonly value: unknown;
14
- }
15
- interface RemoveOperation {
16
- readonly op: "remove";
17
- readonly path: OperationPath;
18
- }
19
- type Operation = AddOperation | ReplaceOperation | RemoveOperation;
20
- interface Op {
21
- readonly do: Operation;
22
- readonly undo: Operation;
23
- }
24
-
25
- type StateListener = (ops: ReadonlyArray<Op>, meta: unknown) => void;
26
- type GroupListener = (state: object, ops: ReadonlyArray<Op>, meta: unknown) => void;
27
-
28
- interface Group {
29
- createMutableState<T extends object>(properties: T): T;
3
+ /**
4
+ * Sets when a state's window flushes. Call `flush` once.
5
+ *
6
+ * @param flush - Delivers pending operations.
7
+ * @returns Nothing.
8
+ */
9
+ type EmissionScheduler = (flush: () => void) => void;
10
+ /**
11
+ * Options for `createMutableState`.
12
+ *
13
+ * @example
14
+ * createMutableState({ count: 0 }, { emitOn, strict: false })
15
+ */
16
+ interface MutableStateOptions {
17
+ /**
18
+ * When the window flushes for all writes. Defaults to a microtask.
19
+ */
20
+ readonly emitOn?: EmissionScheduler;
21
+ /**
22
+ * When true, throws at a dangerous edge, at the cause. Defaults to true.
23
+ */
24
+ readonly strict?: boolean;
30
25
  }
31
- declare function createGroup(): Group;
32
-
33
- type Context<M> = {
34
- readonly isTransaction: true;
35
- readonly meta: M;
36
- } | {
37
- readonly isTransaction: false;
38
- readonly meta: unknown;
39
- };
40
- declare function subscribe(group: Group, listener: GroupListener): () => void;
41
- declare function subscribe(state: object, listener: StateListener): () => void;
42
-
43
- interface Channel<M extends object> {
44
- transact(state: object, mutate: () => void, meta?: Partial<M>): void;
45
- subscribe(group: Group, listener: (state: object, ops: ReadonlyArray<Op>, context: Context<M>) => void): () => void;
46
- subscribe(state: object, listener: (ops: ReadonlyArray<Op>, context: Context<M>) => void): () => void;
47
- applyOps(state: object, operations: ReadonlyArray<Operation>, meta?: Partial<M>): void;
48
- }
49
- declare function createChannel<M extends object>(defaults?: M): Channel<M>;
50
-
51
- declare function createMutableState<T extends object>(properties: T, group?: Group): T;
26
+ /**
27
+ * Creates a mutable state object.
28
+ *
29
+ * `ignore()` marks an object so every edge to it is untracked. `unsafeTrack()` marks an object so a node entering while marked, or entering beneath an exempt node, is exempt from strict.
30
+ *
31
+ * @typeParam T - State shape.
32
+ * @param properties - Initial fields.
33
+ * @param options - Creation options.
34
+ * @returns The state.
35
+ */
36
+ declare function createMutableState<T extends object>(properties: T, options?: MutableStateOptions): T;
52
37
 
38
+ /**
39
+ * Returns a stable identity key for a value.
40
+ *
41
+ * @param value - Value to identify.
42
+ * @returns Identity key.
43
+ */
53
44
  declare function identify(value: object): object;
45
+ /**
46
+ * Returns whether two values share the same identity.
47
+ *
48
+ * @param first - First value.
49
+ * @param second - Second value.
50
+ * @returns True if they match.
51
+ */
54
52
  declare function isSameIdentity(first: object, second: object): boolean;
55
53
 
54
+ /**
55
+ * Returns whether a value is an opshot state.
56
+ *
57
+ * @param value - Value to test.
58
+ * @returns True if it is a state.
59
+ */
56
60
  declare function isState(value: unknown): value is object;
57
61
 
58
- declare function applyOps(state: object, operations: ReadonlyArray<Operation>, meta?: unknown): void;
59
-
60
- declare function diffSnapshots(before: object, after: object): Array<Op>;
62
+ /**
63
+ * A change to one key of a node.
64
+ *
65
+ * @example
66
+ * { node, key: "count", before: 0, after: 1, meta: undefined }
67
+ */
68
+ interface Operation {
69
+ /**
70
+ * Node that changed.
71
+ */
72
+ readonly node: object;
73
+ /**
74
+ * Key that changed.
75
+ */
76
+ readonly key: string;
77
+ /**
78
+ * Value before, absent when the key was absent.
79
+ */
80
+ readonly before?: unknown;
81
+ /**
82
+ * Value after, absent when the key is absent.
83
+ */
84
+ readonly after?: unknown;
85
+ /**
86
+ * Meta of the batch the write was made in.
87
+ */
88
+ readonly meta: unknown;
89
+ }
61
90
 
62
- declare const ignoredMarker: unique symbol;
63
- type Ignored<T extends object> = T & {
64
- readonly [ignoredMarker]: true;
65
- };
66
- declare const ignore: <T extends object>(value: T) => Ignored<T>;
91
+ /**
92
+ * Marks an object so every edge to it is untracked in every state.
93
+ *
94
+ * @typeParam T - Value type.
95
+ * @param value - Value to mark or unmark.
96
+ * @param on - Whether the mark is set.
97
+ * @returns `value`.
98
+ */
99
+ declare function ignore<T>(value: T, on?: boolean): T;
67
100
 
68
- declare const unsafeTrackedBrand: unique symbol;
69
- type UnsafeTracked<T extends object> = T & {
70
- readonly [unsafeTrackedBrand]: true;
71
- };
72
- declare function unsafeTrack<T extends object>(value: T): UnsafeTracked<T>;
101
+ /**
102
+ * Marks an object so a node entering a state while marked, or entering beneath an exempt node, is exempt from strict.
103
+ *
104
+ * @typeParam T - Value type.
105
+ * @param value - Value to mark or unmark.
106
+ * @param on - Whether the mark is set.
107
+ * @returns `value`.
108
+ */
109
+ declare function unsafeTrack<T>(value: T, on?: boolean): T;
73
110
 
74
111
  type DateConstructorArgs = [] | [value: number | string] | [
75
112
  year: number,
@@ -80,10 +117,17 @@ type DateConstructorArgs = [] | [value: number | string] | [
80
117
  seconds?: number,
81
118
  milliseconds?: number
82
119
  ];
120
+ /**
121
+ * Tracked `Date` for use in state.
122
+ *
123
+ * @example
124
+ * createMutableState({ when: new TrackedDate() })
125
+ */
83
126
  declare class TrackedDate {
84
127
  private epochMs;
85
128
  constructor(...args: DateConstructorArgs);
86
129
  private readDate;
130
+ private readEpochMs;
87
131
  private write;
88
132
  toString(): string;
89
133
  toDateString(): string;
@@ -134,6 +178,12 @@ declare class TrackedDate {
134
178
  readonly [Symbol.toStringTag]: "TrackedDate";
135
179
  }
136
180
 
181
+ /**
182
+ * Tracked `Map` for use in state.
183
+ *
184
+ * @typeParam K - Key type.
185
+ * @typeParam V - Value type.
186
+ */
137
187
  declare class TrackedMap<K, V> {
138
188
  private slots;
139
189
  private index;
@@ -153,6 +203,11 @@ declare class TrackedMap<K, V> {
153
203
  readonly [Symbol.toStringTag]: "TrackedMap";
154
204
  }
155
205
 
206
+ /**
207
+ * Tracked `Set` for use in state.
208
+ *
209
+ * @typeParam T - Member type.
210
+ */
156
211
  declare class TrackedSet<T> {
157
212
  private slots;
158
213
  private index;
@@ -171,15 +226,47 @@ declare class TrackedSet<T> {
171
226
  readonly [Symbol.toStringTag]: "TrackedSet";
172
227
  }
173
228
 
174
- declare function transact(state: object, mutate: () => void, meta?: unknown): void;
229
+ /**
230
+ * Listener for one state's changes.
231
+ *
232
+ * @param operations - Operations for the change.
233
+ */
234
+ type StateListener = (operations: ReadonlyArray<Operation>) => void;
175
235
 
176
- interface ScopeOptions {
177
- readonly maxDepth?: number;
178
- }
179
- declare function scope<P extends object>(Component: ComponentType<P>, options?: ScopeOptions): FC<P>;
236
+ /**
237
+ * Listens for changes to a state.
238
+ *
239
+ * @param state - State to listen to.
240
+ * @param listener - Called on each change.
241
+ * @returns Unsubscribe function.
242
+ */
243
+ declare function subscribe(state: object, listener: StateListener): () => void;
244
+
245
+ /**
246
+ * Runs writes carrying `meta`.
247
+ *
248
+ * @param callback - Function that writes any states.
249
+ * @param meta - Carried by each write's operation.
250
+ */
251
+ declare function batch(callback: () => void, meta?: unknown): void;
180
252
 
181
- declare function useGroup(): Group;
253
+ /**
254
+ * Wraps a component so it re-renders only when fields it read change.
255
+ *
256
+ * @typeParam P - Props type.
257
+ * @param Component - Component to wrap.
258
+ * @returns The wrapped component.
259
+ */
260
+ declare function scope<P extends object>(Component: ComponentType<P>): FC<P>;
182
261
 
183
- declare function useMutableState<T extends object>(properties: (() => T) | T, group?: Group): T;
262
+ /**
263
+ * Creates mutable state for a component.
264
+ *
265
+ * @typeParam T - State shape.
266
+ * @param properties - Initial fields, or a function that returns them.
267
+ * @param options - Creation options.
268
+ * @returns The state.
269
+ */
270
+ declare function useMutableState<T extends object>(properties: (() => T) | T, options?: MutableStateOptions): T;
184
271
 
185
- export { type AddOperation, type Channel, type Context, type Group, type GroupListener, type Ignored, type Op, type Operation, type OperationPath, type RemoveOperation, type ReplaceOperation, type ScopeOptions, type StateListener, TrackedDate, TrackedMap, TrackedSet, type UnsafeTracked, applyOps, createChannel, createGroup, createMutableState, diffSnapshots, identify, ignore, isSameIdentity, isState, scope, subscribe, transact, unsafeTrack, useGroup, useMutableState };
272
+ export { type EmissionScheduler, type MutableStateOptions, type Operation, type StateListener, TrackedDate, TrackedMap, TrackedSet, batch, createMutableState, identify, ignore, isSameIdentity, isState, scope, subscribe, unsafeTrack, useMutableState };