@tldraw/state 5.5.0-next.f9308d324e1c → 5.5.1
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.js +1 -1
- package/dist-cjs/lib/Atom.js +4 -5
- package/dist-cjs/lib/Atom.js.map +2 -2
- package/dist-cjs/lib/EffectScheduler.js +29 -1
- package/dist-cjs/lib/EffectScheduler.js.map +2 -2
- package/dist-cjs/lib/capture.js +36 -38
- package/dist-cjs/lib/capture.js.map +2 -2
- package/dist-cjs/lib/helpers.js +1 -2
- package/dist-cjs/lib/helpers.js.map +2 -2
- package/dist-cjs/lib/transactions.js +1 -5
- package/dist-cjs/lib/transactions.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/Atom.mjs +4 -5
- package/dist-esm/lib/Atom.mjs.map +2 -2
- package/dist-esm/lib/EffectScheduler.mjs +29 -1
- package/dist-esm/lib/EffectScheduler.mjs.map +2 -2
- package/dist-esm/lib/capture.mjs +36 -38
- package/dist-esm/lib/capture.mjs.map +2 -2
- package/dist-esm/lib/helpers.mjs +1 -2
- package/dist-esm/lib/helpers.mjs.map +2 -2
- package/dist-esm/lib/transactions.mjs +1 -5
- package/dist-esm/lib/transactions.mjs.map +2 -2
- package/package.json +3 -3
- package/src/lib/Atom.ts +5 -9
- package/src/lib/EffectScheduler.ts +39 -1
- package/src/lib/__tests__/propagation.test.ts +96 -1
- package/src/lib/capture.ts +44 -45
- package/src/lib/helpers.ts +2 -2
- package/src/lib/transactions.ts +1 -6
|
@@ -58,6 +58,10 @@ class __EffectScheduler__<Result> implements EffectScheduler<Result> {
|
|
|
58
58
|
/** @internal */
|
|
59
59
|
private _scheduleCount = 0
|
|
60
60
|
/** @internal */
|
|
61
|
+
private _executeDepth = 0
|
|
62
|
+
/** @internal */
|
|
63
|
+
private _wasScheduledWhileExecuting = false
|
|
64
|
+
/** @internal */
|
|
61
65
|
__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null = null
|
|
62
66
|
|
|
63
67
|
/**
|
|
@@ -113,7 +117,7 @@ class __EffectScheduler__<Result> implements EffectScheduler<Result> {
|
|
|
113
117
|
this._scheduleEffect(this.maybeExecute)
|
|
114
118
|
} else {
|
|
115
119
|
// otherwise execute right now!
|
|
116
|
-
this.
|
|
120
|
+
this.maybeExecute()
|
|
117
121
|
}
|
|
118
122
|
}
|
|
119
123
|
|
|
@@ -122,6 +126,13 @@ class __EffectScheduler__<Result> implements EffectScheduler<Result> {
|
|
|
122
126
|
readonly maybeExecute = () => {
|
|
123
127
|
// bail out if we have been detached before this runs
|
|
124
128
|
if (!this._isActivelyListening) return
|
|
129
|
+
// A set inside the running effect flushed synchronously back to this scheduler (only possible
|
|
130
|
+
// outside the reaction phase, e.g. the first run of `react()`). Running now would open a second
|
|
131
|
+
// capture frame inside the open one and corrupt `parents`, so re-check after the run instead.
|
|
132
|
+
if (this._executeDepth > 0) {
|
|
133
|
+
this._wasScheduledWhileExecuting = true
|
|
134
|
+
return
|
|
135
|
+
}
|
|
125
136
|
this.execute()
|
|
126
137
|
}
|
|
127
138
|
|
|
@@ -159,6 +170,32 @@ class __EffectScheduler__<Result> implements EffectScheduler<Result> {
|
|
|
159
170
|
* @public
|
|
160
171
|
*/
|
|
161
172
|
execute(): Result {
|
|
173
|
+
// A direct re-entrant `execute()` from inside the effect is unsupported (both runs share one
|
|
174
|
+
// `parents`); it is left to run so the outer run can at least finish normally.
|
|
175
|
+
if (this._executeDepth > 0) return this.executeOnce()
|
|
176
|
+
// a run that threw may have left this set
|
|
177
|
+
this._wasScheduledWhileExecuting = false
|
|
178
|
+
let result = this.executeOnce()
|
|
179
|
+
// If a set inside the run reached this scheduler (see `maybeExecute`), settle it now the way
|
|
180
|
+
// the reaction phase's cleanup pass would: run again while the parents keep changing.
|
|
181
|
+
for (let depth = 0; this._wasScheduledWhileExecuting; depth++) {
|
|
182
|
+
this._wasScheduledWhileExecuting = false
|
|
183
|
+
if (depth >= 1000) {
|
|
184
|
+
throw new Error('Reaction update depth limit exceeded')
|
|
185
|
+
}
|
|
186
|
+
if (!this._isActivelyListening) break
|
|
187
|
+
if (!haveParentsChanged(this)) {
|
|
188
|
+
this.lastReactedEpoch = getGlobalEpoch()
|
|
189
|
+
break
|
|
190
|
+
}
|
|
191
|
+
result = this.executeOnce()
|
|
192
|
+
}
|
|
193
|
+
return result
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private executeOnce(): Result {
|
|
197
|
+
// A counter rather than a flag: a nested `execute()` must not mark the outer run as finished.
|
|
198
|
+
this._executeDepth++
|
|
162
199
|
try {
|
|
163
200
|
startCapturingParents(this)
|
|
164
201
|
// Important! We have to make a note of the current epoch before running the effect.
|
|
@@ -170,6 +207,7 @@ class __EffectScheduler__<Result> implements EffectScheduler<Result> {
|
|
|
170
207
|
return result
|
|
171
208
|
} finally {
|
|
172
209
|
stopCapturingParents()
|
|
210
|
+
this._executeDepth--
|
|
173
211
|
}
|
|
174
212
|
}
|
|
175
213
|
}
|
|
@@ -76,7 +76,7 @@ describe('setting atoms during the reaction phase (P)', () => {
|
|
|
76
76
|
expect(b.get()).toBe(1)
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
-
it('[P5] throws an error if it gets into a loop', () => {
|
|
79
|
+
it('[P5][P8] throws an error if it gets into a loop', () => {
|
|
80
80
|
expect(() => {
|
|
81
81
|
const a = atom('', 0)
|
|
82
82
|
|
|
@@ -380,3 +380,98 @@ describe('actively-listening computeds stay fresh (C2)', () => {
|
|
|
380
380
|
expect(c.get()).toBe(1)
|
|
381
381
|
})
|
|
382
382
|
})
|
|
383
|
+
|
|
384
|
+
describe('effects that set atoms outside the reaction phase (P8)', () => {
|
|
385
|
+
// Regression: a first run happens outside a reaction phase, so a set inside it flushed
|
|
386
|
+
// synchronously and re-entered execute() for the same scheduler. The nested capture frame then
|
|
387
|
+
// truncated the parents captured by the outer run — `b` was dropped while `b.children` still
|
|
388
|
+
// held the effect, so `b.set()` never reached the effect again.
|
|
389
|
+
it.each([
|
|
390
|
+
['react()', (fn: () => void) => react('r', fn)],
|
|
391
|
+
['reactor.start()', (fn: () => void) => reactor('r', fn).start()],
|
|
392
|
+
[
|
|
393
|
+
'execute()',
|
|
394
|
+
(fn: () => void) => {
|
|
395
|
+
const scheduler = new EffectScheduler('r', fn)
|
|
396
|
+
scheduler.attach()
|
|
397
|
+
scheduler.execute()
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
])(
|
|
401
|
+
'[P8] a first run via %s that sets one of its own parents re-runs after it instead of nesting',
|
|
402
|
+
(_, start) => {
|
|
403
|
+
const initialized = atom('initialized', false)
|
|
404
|
+
const b = atom('b', 0)
|
|
405
|
+
const log: string[] = []
|
|
406
|
+
|
|
407
|
+
start(() => {
|
|
408
|
+
log.push('start')
|
|
409
|
+
if (!initialized.get()) initialized.set(true)
|
|
410
|
+
log.push(`b=${b.get()}`)
|
|
411
|
+
log.push('end')
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
// the set changed a parent read before it, so the effect runs once more, sequentially
|
|
415
|
+
expect(log).toEqual(['start', 'b=0', 'end', 'start', 'b=0', 'end'])
|
|
416
|
+
|
|
417
|
+
b.set(1)
|
|
418
|
+
expect(log.slice(6)).toEqual(['start', 'b=1', 'end'])
|
|
419
|
+
}
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
it('[P8][E4] skips the extra run when none of the parents it captured have changed', () => {
|
|
423
|
+
const a = atom('a', 0)
|
|
424
|
+
let readsA = true
|
|
425
|
+
let runs = 0
|
|
426
|
+
|
|
427
|
+
const scheduler = new EffectScheduler('r', () => {
|
|
428
|
+
runs++
|
|
429
|
+
if (readsA) {
|
|
430
|
+
a.get()
|
|
431
|
+
} else {
|
|
432
|
+
a.set(1)
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
scheduler.attach()
|
|
436
|
+
scheduler.execute()
|
|
437
|
+
|
|
438
|
+
// `a` is a parent from the first run, so this run's set schedules the effect. But the run
|
|
439
|
+
// drops `a` instead of reading it, so by the time it finishes nothing it depends on changed.
|
|
440
|
+
readsA = false
|
|
441
|
+
scheduler.execute()
|
|
442
|
+
expect(scheduler.scheduleCount).toBe(1)
|
|
443
|
+
expect(runs).toBe(2)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('[P8][E7] does not re-run an effect that was detached during its first run', () => {
|
|
447
|
+
const a = atom('a', 0)
|
|
448
|
+
let runs = 0
|
|
449
|
+
|
|
450
|
+
const scheduler = new EffectScheduler('r', () => {
|
|
451
|
+
runs++
|
|
452
|
+
a.set(a.get() + 1)
|
|
453
|
+
scheduler.detach()
|
|
454
|
+
})
|
|
455
|
+
scheduler.attach()
|
|
456
|
+
scheduler.execute()
|
|
457
|
+
|
|
458
|
+
expect(scheduler.scheduleCount).toBe(1)
|
|
459
|
+
expect(runs).toBe(1)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('[P8] a first run that throws is not re-entered by its own set', () => {
|
|
463
|
+
const a = atom('a', 0)
|
|
464
|
+
let runs = 0
|
|
465
|
+
|
|
466
|
+
const scheduler = new EffectScheduler('r', () => {
|
|
467
|
+
runs++
|
|
468
|
+
a.set(a.get() + 1)
|
|
469
|
+
throw new Error('boom')
|
|
470
|
+
})
|
|
471
|
+
scheduler.attach()
|
|
472
|
+
|
|
473
|
+
expect(() => scheduler.execute()).toThrow('boom')
|
|
474
|
+
expect(scheduler.scheduleCount).toBe(1)
|
|
475
|
+
expect(runs).toBe(1)
|
|
476
|
+
})
|
|
477
|
+
})
|
package/src/lib/capture.ts
CHANGED
|
@@ -100,30 +100,31 @@ export function startCapturingParents(child: Child) {
|
|
|
100
100
|
export function stopCapturingParents() {
|
|
101
101
|
const frame = inst.stack!
|
|
102
102
|
inst.stack = frame.below
|
|
103
|
+
const { child, offset, maybeRemoved } = frame
|
|
103
104
|
|
|
104
|
-
if (
|
|
105
|
-
for (let i =
|
|
106
|
-
const maybeRemovedParent =
|
|
107
|
-
if (!
|
|
108
|
-
detach(maybeRemovedParent,
|
|
105
|
+
if (offset < child.parents.length) {
|
|
106
|
+
for (let i = offset; i < child.parents.length; i++) {
|
|
107
|
+
const maybeRemovedParent = child.parents[i]
|
|
108
|
+
if (!child.parentSet.has(maybeRemovedParent)) {
|
|
109
|
+
detach(maybeRemovedParent, child)
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
child.parents.length = offset
|
|
114
|
+
child.parentEpochs.length = offset
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
if (
|
|
117
|
-
for (let i = 0; i <
|
|
118
|
-
const maybeRemovedParent =
|
|
119
|
-
if (!
|
|
120
|
-
detach(maybeRemovedParent,
|
|
117
|
+
if (maybeRemoved) {
|
|
118
|
+
for (let i = 0; i < maybeRemoved.length; i++) {
|
|
119
|
+
const maybeRemovedParent = maybeRemoved[i]
|
|
120
|
+
if (!child.parentSet.has(maybeRemovedParent)) {
|
|
121
|
+
detach(maybeRemovedParent, child)
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
if (
|
|
126
|
-
captureAncestorEpochs(
|
|
126
|
+
if (child.__debug_ancestor_epochs__) {
|
|
127
|
+
captureAncestorEpochs(child, child.__debug_ancestor_epochs__)
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
|
|
@@ -148,36 +149,38 @@ export function stopCapturingParents() {
|
|
|
148
149
|
* @internal
|
|
149
150
|
*/
|
|
150
151
|
export function maybeCaptureParent(p: Signal<any, any>) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// captured parent.
|
|
155
|
-
if (!inst.stack.child.parentSet.add(p)) {
|
|
156
|
-
return
|
|
157
|
-
}
|
|
152
|
+
const stack = inst.stack
|
|
153
|
+
if (!stack) return
|
|
154
|
+
const { child } = stack
|
|
158
155
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
156
|
+
// `add` returns false when the parent was already captured this run. In array mode both
|
|
157
|
+
// `has` and `add` scan with indexOf, so going straight to `add` halves the scans per
|
|
158
|
+
// captured parent.
|
|
159
|
+
if (!child.parentSet.add(p)) {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
163
|
+
if (child.isActivelyListening) {
|
|
164
|
+
attach(p, child)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Parents are recorded in deref order. If the slot at this offset held a different parent
|
|
168
|
+
// last run, that parent may have moved later in the order or been dropped; only
|
|
169
|
+
// stopCapturingParents can tell, so remember it for then.
|
|
170
|
+
if (stack.offset < child.parents.length) {
|
|
171
|
+
const maybeRemovedParent = child.parents[stack.offset]
|
|
172
|
+
if (maybeRemovedParent !== p) {
|
|
173
|
+
if (!stack.maybeRemoved) {
|
|
174
|
+
stack.maybeRemoved = [maybeRemovedParent]
|
|
175
|
+
} else {
|
|
176
|
+
stack.maybeRemoved.push(maybeRemovedParent)
|
|
174
177
|
}
|
|
175
178
|
}
|
|
176
|
-
|
|
177
|
-
inst.stack.child.parents[inst.stack.offset] = p
|
|
178
|
-
inst.stack.child.parentEpochs[inst.stack.offset] = p.lastChangedEpoch
|
|
179
|
-
inst.stack.offset++
|
|
180
179
|
}
|
|
180
|
+
|
|
181
|
+
child.parents[stack.offset] = p
|
|
182
|
+
child.parentEpochs[stack.offset] = p.lastChangedEpoch
|
|
183
|
+
stack.offset++
|
|
181
184
|
}
|
|
182
185
|
|
|
183
186
|
/**
|
|
@@ -212,13 +215,11 @@ export function whyAmIRunning() {
|
|
|
212
215
|
function captureAncestorEpochs(child: Child, ancestorEpochs: Map<Signal<any>, number>) {
|
|
213
216
|
for (let i = 0; i < child.parents.length; i++) {
|
|
214
217
|
const parent = child.parents[i]
|
|
215
|
-
|
|
216
|
-
ancestorEpochs.set(parent, epoch)
|
|
218
|
+
ancestorEpochs.set(parent, child.parentEpochs[i])
|
|
217
219
|
if (isComputed(parent)) {
|
|
218
220
|
captureAncestorEpochs(parent as any, ancestorEpochs)
|
|
219
221
|
}
|
|
220
222
|
}
|
|
221
|
-
return ancestorEpochs
|
|
222
223
|
}
|
|
223
224
|
|
|
224
225
|
type ChangeTree = { [signalName: string]: ChangeTree } | null
|
|
@@ -232,9 +233,7 @@ function collectChangedAncestors(
|
|
|
232
233
|
if (!ancestorEpochs.has(parent)) {
|
|
233
234
|
continue
|
|
234
235
|
}
|
|
235
|
-
|
|
236
|
-
const currentEpoch = parent.lastChangedEpoch
|
|
237
|
-
if (currentEpoch !== prevEpoch) {
|
|
236
|
+
if (parent.lastChangedEpoch !== ancestorEpochs.get(parent)) {
|
|
238
237
|
if (isComputed(parent)) {
|
|
239
238
|
changeTree[parent.name] = collectChangedAncestors(parent as any, ancestorEpochs)
|
|
240
239
|
} else {
|
package/src/lib/helpers.ts
CHANGED
|
@@ -75,9 +75,9 @@ export function attach(parent: Signal<any>, child: Child) {
|
|
|
75
75
|
* @internal
|
|
76
76
|
*/
|
|
77
77
|
export function equals(a: any, b: any): boolean {
|
|
78
|
-
|
|
78
|
+
return (
|
|
79
79
|
a === b || Object.is(a, b) || Boolean(a && b && typeof a.equals === 'function' && a.equals(b))
|
|
80
|
-
|
|
80
|
+
)
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
package/src/lib/transactions.ts
CHANGED
|
@@ -17,13 +17,8 @@ class Transaction {
|
|
|
17
17
|
|
|
18
18
|
initialAtomValues = new Map<_Atom, any>()
|
|
19
19
|
|
|
20
|
-
// eslint-disable-next-line tldraw/no-setter-getter
|
|
21
|
-
get isRoot() {
|
|
22
|
-
return this.parent === null
|
|
23
|
-
}
|
|
24
|
-
|
|
25
20
|
commit() {
|
|
26
|
-
if (this.
|
|
21
|
+
if (this.parent === null) {
|
|
27
22
|
// For root transactions, flush changed atoms
|
|
28
23
|
flushChanges(this.initialAtomValues.keys())
|
|
29
24
|
return
|