@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/dist-cjs/index.d.ts +6 -0
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/Store.js +64 -26
- package/dist-cjs/lib/Store.js.map +2 -2
- package/dist-esm/index.d.mts +6 -0
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/Store.mjs +71 -27
- package/dist-esm/lib/Store.mjs.map +2 -2
- package/package.json +3 -3
- package/src/lib/Store.test.ts +61 -0
- package/src/lib/Store.ts +94 -30
- package/src/lib/StoreListeners.test.ts +88 -1
package/src/lib/Store.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
1080
|
-
const dispose = this.historyAccumulator.addInterceptor((entry) =>
|
|
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
|
-
|
|
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 =
|
|
1172
|
-
? computed(`${name}:${id}:isEqual`, () => record.get(), {
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
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')
|