@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.
@@ -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.execute()
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
+ })
@@ -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 (frame.offset < frame.child.parents.length) {
105
- for (let i = frame.offset; i < frame.child.parents.length; i++) {
106
- const maybeRemovedParent = frame.child.parents[i]
107
- if (!frame.child.parentSet.has(maybeRemovedParent)) {
108
- detach(maybeRemovedParent, frame.child)
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
- frame.child.parents.length = frame.offset
113
- frame.child.parentEpochs.length = frame.offset
113
+ child.parents.length = offset
114
+ child.parentEpochs.length = offset
114
115
  }
115
116
 
116
- if (frame.maybeRemoved) {
117
- for (let i = 0; i < frame.maybeRemoved.length; i++) {
118
- const maybeRemovedParent = frame.maybeRemoved[i]
119
- if (!frame.child.parentSet.has(maybeRemovedParent)) {
120
- detach(maybeRemovedParent, frame.child)
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 (frame.child.__debug_ancestor_epochs__) {
126
- captureAncestorEpochs(frame.child, frame.child.__debug_ancestor_epochs__)
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
- if (inst.stack) {
152
- // `add` returns false when the parent was already captured this run. In array mode both
153
- // `has` and `add` scan with indexOf, so going straight to `add` halves the scans per
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
- if (inst.stack.child.isActivelyListening) {
160
- attach(p, inst.stack.child)
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
- // Parents are recorded in deref order. If the slot at this offset held a different parent
164
- // last run, that parent may have moved later in the order or been dropped; only
165
- // stopCapturingParents can tell, so remember it for then.
166
- if (inst.stack.offset < inst.stack.child.parents.length) {
167
- const maybeRemovedParent = inst.stack.child.parents[inst.stack.offset]
168
- if (maybeRemovedParent !== p) {
169
- if (!inst.stack.maybeRemoved) {
170
- inst.stack.maybeRemoved = [maybeRemovedParent]
171
- } else {
172
- inst.stack.maybeRemoved.push(maybeRemovedParent)
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
- const epoch = child.parentEpochs[i]
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
- const prevEpoch = ancestorEpochs.get(parent)
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 {
@@ -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
- const shallowEquals =
78
+ return (
79
79
  a === b || Object.is(a, b) || Boolean(a && b && typeof a.equals === 'function' && a.equals(b))
80
- return shallowEquals
80
+ )
81
81
  }
82
82
 
83
83
  /**
@@ -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.isRoot) {
21
+ if (this.parent === null) {
27
22
  // For root transactions, flush changed atoms
28
23
  flushChanges(this.initialAtomValues.keys())
29
24
  return