opshot 0.4.0 → 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.
package/README.md CHANGED
@@ -95,10 +95,10 @@ const Player = () => {
95
95
  const player: PlayerState = useMutableState({
96
96
  position: 0,
97
97
 
98
- // ignore() on a value in the factory argument makes the edge at that path untracked.
98
+ // ignore() stores it as-is; opshot never looks inside.
99
99
  element: ignore(new Audio()),
100
100
 
101
- // unsafeTrack() on a value in the factory argument disables strict at and under that path.
101
+ // unsafeTrack() takes it anyway, tracking the plain data on it.
102
102
  queue: unsafeTrack(new Playlist()),
103
103
 
104
104
  seek(position: number) {
@@ -125,9 +125,9 @@ It can't track:
125
125
  - Own function properties on class instances
126
126
  - Non-writable properties that hold an object
127
127
 
128
- `strict: true` throws at a dangerous edge, at the cause.
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.
129
129
 
130
- Use `ignore` on a value in the factory argument to make the edge at that path untracked. Use `unsafeTrack` on a value in the factory argument to disable strict at and under that path.
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.
131
131
 
132
132
  ## Tracked collections
133
133
 
@@ -154,12 +154,8 @@ const Counter = () => {
154
154
 
155
155
  useEffect(
156
156
  () =>
157
- subscribe(counter, (ops, meta) => {
158
- // ops: [{
159
- // do: { verb: "assign", path: ["count"], value: 1 },
160
- // undo: { verb: "assign", path: ["count"], value: 0 },
161
- // }]
162
- // 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 }]
163
159
  }),
164
160
  [counter],
165
161
  );
@@ -168,126 +164,63 @@ const Counter = () => {
168
164
  };
169
165
  ```
170
166
 
171
- ## Ops
167
+ ## Operations
172
168
 
173
- An op is an invertible pair of halves. Every half uses one of three verbs:
169
+ An operation is one key's change on one node:
174
170
 
175
171
  ```ts
176
- type OperationPath = ReadonlyArray<string | number>;
177
-
178
172
  interface Operation {
179
- readonly do:
180
- | {
181
- readonly verb: "assign";
182
- readonly path: OperationPath;
183
- readonly value: unknown;
184
- readonly ids?: ReadonlyArray<number>;
185
- }
186
- | { readonly verb: "delete"; readonly path: OperationPath }
187
- | { readonly verb: "link"; readonly path: OperationPath; readonly ref: number };
188
- readonly undo: Operation["do"];
173
+ readonly node: object;
174
+ readonly key: string;
175
+ readonly before?: unknown;
176
+ readonly after?: unknown;
177
+ readonly meta: unknown;
189
178
  }
190
179
  ```
191
180
 
192
- Ids vend in admission-walk order over the emitted artifact; a departure's undo assign may carry `ids` to rebind that walk, the one naming fact construction cannot re-derive. `applyOperations` puts them back on a state, so a history is a list of ops and an undo is `applyOperations` with `"undo"`.
193
-
194
- ```tsx
195
- import { useEffect, useRef } from "react";
196
- import { applyOperations, subscribe, useMutableState, type Operation } from "opshot";
197
-
198
- const replay = {};
199
-
200
- const Counter = () => {
201
- const counter = useMutableState({ count: 0 });
202
- const history = useRef<Array<ReadonlyArray<Operation>>>([]);
203
-
204
- useEffect(
205
- () =>
206
- subscribe(counter, (ops, meta) => {
207
- // Skip our own replays, so undo doesn't record itself.
208
- if (meta === replay) return;
209
-
210
- history.current.push(ops);
211
- }),
212
- [counter],
213
- );
214
-
215
- const undo = () => {
216
- const ops = history.current.pop();
217
-
218
- if (!ops) return;
219
-
220
- applyOperations(counter, ops, "undo", replay);
221
- };
222
-
223
- return (
224
- <>
225
- <button onClick={() => counter.count++}>+</button>
226
- <button onClick={undo}>Undo</button>
227
- </>
228
- );
229
- };
230
- ```
231
-
232
- Replay is exact for anything opshot can see: plain data. State behind a constraint is the exception.
181
+ `before` and `after` are absent properties when the key was absent.
233
182
 
234
- If your state is JSON serializable, **then ops are too**.
183
+ ## Emission
235
184
 
236
- ## Groups
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.
237
186
 
238
- 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.
239
188
 
240
189
  ```tsx
241
190
  import { useEffect } from "react";
242
- import { subscribe, useGroup, useMutableState } from "opshot";
243
-
244
- const Editor = () => {
245
- const group = useGroup();
191
+ import { subscribe, useMutableState } from "opshot";
246
192
 
247
- // Created through the group, so their ops reach the group's subscribers.
248
- const doc = useMutableState({ items: new Array<string>() }, { group });
249
- 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) });
250
196
 
251
197
  useEffect(
252
198
  () =>
253
- // Fires for doc, selection, and every other state the group created.
254
- // state is whichever one changed.
255
- subscribe(group, (state, ops, meta) => {
199
+ subscribe(cursor, (operations) => {
256
200
  // ...
257
201
  }),
258
- [group],
202
+ [cursor],
259
203
  );
260
204
 
261
205
  // ...
262
206
  };
263
207
  ```
264
208
 
265
- ## Channels
209
+ ## Batches
266
210
 
267
- A channel binds `transact`, `subscribe`, and `applyOperations` 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.
268
212
 
269
213
  ```tsx
270
214
  import { useEffect } from "react";
271
- import { createChannel, useMutableState } from "opshot";
272
-
273
- interface DocumentMeta {
274
- replay?: boolean;
275
- source?: string;
276
- }
277
-
278
- const docChannel = createChannel<DocumentMeta>({ source: "editor" }); // set defaults
215
+ import { batch, subscribe, useMutableState } from "opshot";
279
216
 
280
217
  const TitleBar = () => {
281
218
  const doc = useMutableState({ title: "Untitled" });
282
219
 
283
220
  useEffect(
284
221
  () =>
285
- docChannel.subscribe(doc, (ops, context) => {
286
- // A bare write, or a transact from another channel: meta is unknown.
287
- if (!context.isTransaction) return;
288
-
289
- // Own-channel transaction: meta is typed, with defaults merged.
290
- if (context.meta.replay) return;
222
+ subscribe(doc, (operations) => {
223
+ if (operations[0]?.meta === "replay") return;
291
224
 
292
225
  // ...
293
226
  }),
@@ -295,9 +228,12 @@ const TitleBar = () => {
295
228
  );
296
229
 
297
230
  const rename = () => {
298
- docChannel.transact(doc, () => {
299
- doc.title = "Draft";
300
- });
231
+ batch(
232
+ () => {
233
+ doc.title = "Draft";
234
+ },
235
+ { source: "editor" },
236
+ );
301
237
  };
302
238
 
303
239
  // ...