@tldraw/store 5.6.0-next.c4e9c54a816e → 5.6.0-next.f151f7f3ebd6

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/src/lib/Store.ts CHANGED
@@ -1,4 +1,13 @@
1
- import { Atom, Reactor, Signal, atom, computed, reactor, transact } from '@tldraw/state'
1
+ import {
2
+ Atom,
3
+ Reactor,
4
+ Signal,
5
+ atom,
6
+ computed,
7
+ isUninitialized,
8
+ reactor,
9
+ transact,
10
+ } from '@tldraw/state'
2
11
  import {
3
12
  WeakCache,
4
13
  assert,
@@ -20,6 +29,10 @@ import { StoreQueries } from './StoreQueries'
20
29
  import { SerializedSchema, StoreSchema } from './StoreSchema'
21
30
  import { StoreSideEffects } from './StoreSideEffects'
22
31
 
32
+ // The value of a cached computed whose record has been deleted (see `createComputedCache`). It never
33
+ // reaches callers: `get` resolves the record's atom first, and there is none for a deleted id.
34
+ const DELETED_RECORD = Symbol('deleted record')
35
+
23
36
  /**
24
37
  * Extracts the record type from a record ID type.
25
38
  *
@@ -517,8 +530,9 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
517
530
 
518
531
  public _flushHistory() {
519
532
  // If we have accumulated history, flush it and update listeners
520
- if (this.historyAccumulator.hasChanges()) {
521
- const entries = this.historyAccumulator.flush()
533
+ const version = this.history.__unsafe__getWithoutCapture()
534
+ const entries = this.historyAccumulator.flush(version)
535
+ if (entries.length > 0) {
522
536
  const errors: unknown[] = []
523
537
  const wasFlushingHistory = this.isFlushingHistory
524
538
  this.isFlushingHistory = true
@@ -587,15 +601,22 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
587
601
  * @param changes - The changes to add to the history.
588
602
  */
589
603
  private updateHistory(changes: RecordsDiff<R>): void {
590
- this.historyAccumulator.add({
591
- changes,
592
- source: this.isMergingRemoteChanges ? 'remote' : 'user',
593
- })
604
+ // Don't capture: a reaction that writes to the store must not become a dependent of the
605
+ // history atom, or every other store change would re-run it (and it could loop forever).
606
+ const version = this.history.__unsafe__getWithoutCapture() + 1
607
+ // Set the atom before recording the entry: interceptors run inside `add`, and a flush they
608
+ // trigger (e.g. via `listen`) would otherwise discard the entry as not yet committed.
609
+ this.history.set(version, changes)
610
+ this.historyAccumulator.add(
611
+ {
612
+ changes,
613
+ source: this.isMergingRemoteChanges ? 'remote' : 'user',
614
+ },
615
+ version
616
+ )
594
617
  if (this.listeners.size === 0) {
595
618
  this.historyAccumulator.clear()
596
619
  }
597
- // Don't capture: a reaction that writes would otherwise re-run on every store change (S11).
598
- this.history.set(this.history.__unsafe__getWithoutCapture() + 1, changes)
599
620
  }
600
621
 
601
622
  validate(phase: 'initialize' | 'createRecord' | 'updateRecord' | 'tests') {
@@ -1076,11 +1097,22 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
1076
1097
  * Run `fn` and return a {@link RecordsDiff} of the changes that occurred as a result.
1077
1098
  */
1078
1099
  extractingChanges(fn: () => void): RecordsDiff<R> {
1079
- const changes: Array<RecordsDiff<R>> = []
1080
- const dispose = this.historyAccumulator.addInterceptor((entry) => changes.push(entry.changes))
1100
+ const collected: Array<{ version: number; changes: RecordsDiff<R> }> = []
1101
+ const dispose = this.historyAccumulator.addInterceptor((entry, version) => {
1102
+ // a nested transaction that rolled back inside `fn` leaves entries stamped at or past the
1103
+ // next committed version — see HistoryAccumulator
1104
+ while (collected.length && collected[collected.length - 1].version >= version) {
1105
+ collected.pop()
1106
+ }
1107
+ collected.push({ version, changes: entry.changes })
1108
+ })
1081
1109
  try {
1082
1110
  transact(fn)
1083
- return squashRecordDiffs(changes)
1111
+ const current = this.history.__unsafe__getWithoutCapture()
1112
+ while (collected.length && collected[collected.length - 1].version > current) {
1113
+ collected.pop()
1114
+ }
1115
+ return squashRecordDiffs(collected.map((c) => c.changes))
1084
1116
  } finally {
1085
1117
  dispose()
1086
1118
  }
@@ -1140,6 +1172,12 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
1140
1172
  * signal for the underlying record. Return a signal (usually a computed) for the cached value.
1141
1173
  * For simple derivations, use {@link Store.createComputedCache}. This function is useful if you
1142
1174
  * need more precise control over intermediate values.
1175
+ *
1176
+ * After the record is deleted the record signal holds `UNINITIALIZED` rather than a record, and
1177
+ * a signal that a reader still depends on can be re-derived once more in that state. Guard with
1178
+ * `isUninitialized` and return a value no live record can produce: returning the same value the
1179
+ * record last had (e.g. `undefined`) leaves readers stale if a record with that id comes back.
1180
+ * {@link Store.createComputedCache} handles this for you.
1143
1181
  */
1144
1182
  createCache<Result, Record extends R = R>(
1145
1183
  create: (id: IdOf<Record>, recordSignal: Signal<R>) => Signal<Result>
@@ -1167,18 +1205,33 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
1167
1205
  derive: (record: Record) => Result | undefined,
1168
1206
  opts?: CreateComputedCacheOpts<Result, Record>
1169
1207
  ): ComputedCache<Result, Record> {
1208
+ const areRecordsEqual = opts?.areRecordsEqual
1209
+ const areResultsEqual = opts?.areResultsEqual
1170
1210
  return this.createCache((id, record) => {
1171
- const recordSignal = opts?.areRecordsEqual
1172
- ? computed(`${name}:${id}:isEqual`, () => record.get(), { isEqual: opts.areRecordsEqual })
1211
+ const recordSignal = areRecordsEqual
1212
+ ? computed(`${name}:${id}:isEqual`, () => record.get(), {
1213
+ // a deleted record's atom holds UNINITIALIZED, which user code must not see
1214
+ isEqual: (a, b) =>
1215
+ isUninitialized(a) || isUninitialized(b) ? a === b : areRecordsEqual(a, b),
1216
+ })
1173
1217
  : record
1174
1218
 
1175
1219
  return computed<Result | undefined>(
1176
1220
  name + ':' + id,
1177
1221
  () => {
1178
- return derive(recordSignal.get() as Record)
1222
+ // The computed can be re-derived after its record is deleted (e.g. by a reaction
1223
+ // checking whether its parents changed), when the atom holds UNINITIALIZED. Return
1224
+ // DELETED_RECORD rather than undefined: it never equals a real result, so a reader
1225
+ // re-runs, looks the id up again, and so sees the record if it comes back.
1226
+ const value = recordSignal.get()
1227
+ if (isUninitialized(value)) return DELETED_RECORD as any
1228
+ return derive(value as Record)
1179
1229
  },
1180
1230
  {
1181
- isEqual: opts?.areResultsEqual,
1231
+ isEqual: areResultsEqual
1232
+ ? (a, b) =>
1233
+ a === DELETED_RECORD || b === DELETED_RECORD ? a === b : areResultsEqual(a, b)
1234
+ : undefined,
1182
1235
  }
1183
1236
  )
1184
1237
  })
@@ -1365,13 +1418,18 @@ function squashHistoryEntries<T extends UnknownRecord>(
1365
1418
  class HistoryAccumulator<T extends UnknownRecord> {
1366
1419
  private _history: HistoryEntry<T>[] = []
1367
1420
 
1368
- private _interceptors: Set<(entry: HistoryEntry<T>) => void> = new Set()
1421
+ // The `history` atom value each entry was recorded at. A transaction that aborts rolls that atom
1422
+ // back but cannot unwind this array, so an entry stamped at or past the atom's current value
1423
+ // belongs to a rolled-back change-set and must not reach listeners.
1424
+ private _versions: number[] = []
1425
+
1426
+ private _interceptors: Set<(entry: HistoryEntry<T>, version: number) => void> = new Set()
1369
1427
 
1370
1428
  /**
1371
- * Add an interceptor that will be called for each history entry.
1372
- * Returns a function to remove the interceptor.
1429
+ * Add an interceptor that will be called for each history entry, with the version it was
1430
+ * recorded at (see `add`). Returns a function to remove the interceptor.
1373
1431
  */
1374
- addInterceptor(fn: (entry: HistoryEntry<T>) => void) {
1432
+ addInterceptor(fn: (entry: HistoryEntry<T>, version: number) => void) {
1375
1433
  this._interceptors.add(fn)
1376
1434
  return () => {
1377
1435
  this._interceptors.delete(fn)
@@ -1379,13 +1437,15 @@ class HistoryAccumulator<T extends UnknownRecord> {
1379
1437
  }
1380
1438
 
1381
1439
  /**
1382
- * Add a history entry to the accumulator.
1440
+ * Add a history entry recorded at `version` (the `history` atom value it sets).
1383
1441
  * Calls all registered interceptors with the entry.
1384
1442
  */
1385
- add(entry: HistoryEntry<T>) {
1443
+ add(entry: HistoryEntry<T>, version: number) {
1444
+ this.discardFrom(version)
1386
1445
  this._history.push(entry)
1446
+ this._versions.push(version)
1387
1447
  for (const interceptor of this._interceptors) {
1388
- interceptor(entry)
1448
+ interceptor(entry, version)
1389
1449
  }
1390
1450
  }
1391
1451
 
@@ -1393,9 +1453,10 @@ class HistoryAccumulator<T extends UnknownRecord> {
1393
1453
  * Flush all accumulated history entries, squashing adjacent entries from the same source.
1394
1454
  * Clears the internal history buffer.
1395
1455
  */
1396
- flush() {
1456
+ flush(currentVersion: number) {
1457
+ this.discardFrom(currentVersion + 1)
1397
1458
  const history = squashHistoryEntries(this._history)
1398
- this._history = []
1459
+ this.clear()
1399
1460
  return history
1400
1461
  }
1401
1462
 
@@ -1404,13 +1465,16 @@ class HistoryAccumulator<T extends UnknownRecord> {
1404
1465
  */
1405
1466
  clear() {
1406
1467
  this._history = []
1468
+ this._versions = []
1407
1469
  }
1408
1470
 
1409
- /**
1410
- * Check if there are any accumulated history entries.
1411
- */
1412
- hasChanges() {
1413
- return this._history.length > 0
1471
+ // Drops the entries recorded at or after `version`. Entries are in ascending version order: the
1472
+ // atom increments by one per entry, and a rollback is followed by exactly this discard.
1473
+ private discardFrom(version: number) {
1474
+ while (this._versions.length && this._versions[this._versions.length - 1] >= version) {
1475
+ this._versions.pop()
1476
+ this._history.pop()
1477
+ }
1414
1478
  }
1415
1479
  }
1416
1480
 
@@ -1,4 +1,4 @@
1
- import { react, RESET_VALUE, transact } from '@tldraw/state'
1
+ import { react, RESET_VALUE, transact, transaction } from '@tldraw/state'
2
2
  import { beforeEach, describe, expect, it, vi } from 'vitest'
3
3
  import { BaseRecord, RecordId } from './BaseRecord'
4
4
  import { RecordsDiff, reverseRecordsDiff } from './RecordsDiff'
@@ -461,6 +461,93 @@ describe('applying diffs (H)', () => {
461
461
  })
462
462
  })
463
463
 
464
+ describe('listeners: rollback (H)', () => {
465
+ it('[H11] listeners never see change-sets from a transaction that rolled back', async () => {
466
+ const listener = vi.fn()
467
+ store.listen(listener)
468
+ const author = tolkein()
469
+
470
+ expect(() =>
471
+ store.atomic(() => {
472
+ store.put([author])
473
+ throw new Error('abort')
474
+ })
475
+ ).toThrow('abort')
476
+ expect(store.get(author.id)).toBeUndefined()
477
+
478
+ // a later, committed change flushes whatever the accumulator still holds
479
+ store.put([Author.create({ name: 'Ursula K. Le Guin' })])
480
+ await new Promise((resolve) => requestAnimationFrame(resolve))
481
+
482
+ const addedIds = listener.mock.calls.flatMap(([entry]) => Object.keys(entry.changes.added))
483
+ expect(addedIds).not.toContain(author.id)
484
+ expect(addedIds).toHaveLength(1)
485
+ })
486
+
487
+ it('[H11] a rolled-back nested transaction only discards its own change-sets', async () => {
488
+ const listener = vi.fn()
489
+ store.listen(listener)
490
+ const kept = tolkein()
491
+ const dropped = Author.create({ name: 'Dropped' })
492
+
493
+ transact(() => {
494
+ store.put([kept])
495
+ try {
496
+ transaction(() => {
497
+ store.put([dropped])
498
+ throw new Error('inner')
499
+ })
500
+ } catch {
501
+ // the outer transaction commits
502
+ }
503
+ })
504
+ await new Promise((resolve) => requestAnimationFrame(resolve))
505
+
506
+ expect(store.get(kept.id)).toBeDefined()
507
+ expect(store.get(dropped.id)).toBeUndefined()
508
+ const addedIds = listener.mock.calls.flatMap(([entry]) => Object.keys(entry.changes.added))
509
+ expect(addedIds).toEqual([kept.id])
510
+ })
511
+ })
512
+
513
+ describe('extracting changes across rolled-back nested transactions (H)', () => {
514
+ it('[H7] extractingChanges excludes changes from a nested transaction that rolled back', () => {
515
+ const kept = tolkein()
516
+ const dropped = Author.create({ name: 'Dropped' })
517
+ const changes = store.extractingChanges(() => {
518
+ store.put([kept])
519
+ try {
520
+ transaction(() => {
521
+ store.put([dropped])
522
+ throw new Error('inner')
523
+ })
524
+ } catch {
525
+ // swallowed: the outer work continues
526
+ }
527
+ })
528
+ expect(Object.keys(changes.added)).toEqual([kept.id])
529
+ expect(store.get(dropped.id)).toBeUndefined()
530
+ })
531
+ })
532
+
533
+ describe('listeners added from an interceptor (H)', () => {
534
+ it('[H8] a flush triggered from inside an interceptor still delivers the entry being recorded', () => {
535
+ const first = vi.fn()
536
+ store.listen(first)
537
+ let added = false
538
+ store.addHistoryInterceptor(() => {
539
+ if (added) return
540
+ added = true
541
+ // listen() flushes synchronously while the entry is still being recorded
542
+ store.listen(() => {})
543
+ })
544
+
545
+ store.put([tolkein()])
546
+ store._flushHistory()
547
+ expect(first).toHaveBeenCalledTimes(1)
548
+ })
549
+ })
550
+
464
551
  describe('applying diffs with ignoreEphemeralKeys (H)', () => {
465
552
  it('[H10] ignoreEphemeralKeys also applies the removal of a non-ephemeral key', () => {
466
553
  const visitId = Visit.createId('jane')