@tldraw/state 5.5.0-canary.efd2cd7e73e0 → 5.5.0-canary.f35842e3ba7e
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/EffectScheduler.js +29 -1
- package/dist-cjs/lib/EffectScheduler.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/EffectScheduler.mjs +29 -1
- package/dist-esm/lib/EffectScheduler.mjs.map +2 -2
- package/package.json +2 -2
- package/src/lib/EffectScheduler.ts +39 -1
- package/src/lib/__tests__/propagation.test.ts +96 -1
package/dist-cjs/index.js
CHANGED
|
@@ -54,6 +54,10 @@ class __EffectScheduler__ {
|
|
|
54
54
|
/** @internal */
|
|
55
55
|
_scheduleCount = 0;
|
|
56
56
|
/** @internal */
|
|
57
|
+
_executeDepth = 0;
|
|
58
|
+
/** @internal */
|
|
59
|
+
_wasScheduledWhileExecuting = false;
|
|
60
|
+
/** @internal */
|
|
57
61
|
__debug_ancestor_epochs__ = null;
|
|
58
62
|
/**
|
|
59
63
|
* The number of times this effect has been scheduled.
|
|
@@ -87,13 +91,17 @@ class __EffectScheduler__ {
|
|
|
87
91
|
if (this._scheduleEffect) {
|
|
88
92
|
this._scheduleEffect(this.maybeExecute);
|
|
89
93
|
} else {
|
|
90
|
-
this.
|
|
94
|
+
this.maybeExecute();
|
|
91
95
|
}
|
|
92
96
|
}
|
|
93
97
|
/** @internal */
|
|
94
98
|
// eslint-disable-next-line tldraw/prefer-class-methods
|
|
95
99
|
maybeExecute = () => {
|
|
96
100
|
if (!this._isActivelyListening) return;
|
|
101
|
+
if (this._executeDepth > 0) {
|
|
102
|
+
this._wasScheduledWhileExecuting = true;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
97
105
|
this.execute();
|
|
98
106
|
};
|
|
99
107
|
/**
|
|
@@ -127,6 +135,25 @@ class __EffectScheduler__ {
|
|
|
127
135
|
* @public
|
|
128
136
|
*/
|
|
129
137
|
execute() {
|
|
138
|
+
if (this._executeDepth > 0) return this.executeOnce();
|
|
139
|
+
this._wasScheduledWhileExecuting = false;
|
|
140
|
+
let result = this.executeOnce();
|
|
141
|
+
for (let depth = 0; this._wasScheduledWhileExecuting; depth++) {
|
|
142
|
+
this._wasScheduledWhileExecuting = false;
|
|
143
|
+
if (depth >= 1e3) {
|
|
144
|
+
throw new Error("Reaction update depth limit exceeded");
|
|
145
|
+
}
|
|
146
|
+
if (!this._isActivelyListening) break;
|
|
147
|
+
if (!(0, import_helpers.haveParentsChanged)(this)) {
|
|
148
|
+
this.lastReactedEpoch = (0, import_transactions.getGlobalEpoch)();
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
result = this.executeOnce();
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
executeOnce() {
|
|
156
|
+
this._executeDepth++;
|
|
130
157
|
try {
|
|
131
158
|
(0, import_capture.startCapturingParents)(this);
|
|
132
159
|
const currentEpoch = (0, import_transactions.getGlobalEpoch)();
|
|
@@ -135,6 +162,7 @@ class __EffectScheduler__ {
|
|
|
135
162
|
return result;
|
|
136
163
|
} finally {
|
|
137
164
|
(0, import_capture.stopCapturingParents)();
|
|
165
|
+
this._executeDepth--;
|
|
138
166
|
}
|
|
139
167
|
}
|
|
140
168
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/EffectScheduler.ts"],
|
|
4
|
-
"sourcesContent": ["import { ArraySet } from './ArraySet'\nimport { startCapturingParents, stopCapturingParents } from './capture'\nimport { GLOBAL_START_EPOCH } from './constants'\nimport { attach, detach, haveParentsChanged, singleton } from './helpers'\nimport { getGlobalEpoch } from './transactions'\nimport { Signal } from './types'\n\n/** @public */\nexport interface EffectSchedulerOptions {\n\t/**\n\t * scheduleEffect is a function that will be called when the effect is scheduled.\n\t *\n\t * It can be used to defer running effects until a later time, for example to batch them together with requestAnimationFrame.\n\t *\n\t *\n\t * @example\n\t * ```ts\n\t * let isRafScheduled = false\n\t * const scheduledEffects: Array<() => void> = []\n\t * const scheduleEffect = (runEffect: () => void) => {\n\t * \tscheduledEffects.push(runEffect)\n\t * \tif (!isRafScheduled) {\n\t * \t\tisRafScheduled = true\n\t * \t\trequestAnimationFrame(() => {\n\t * \t\t\tisRafScheduled = false\n\t * \t\t\tscheduledEffects.forEach((runEffect) => runEffect())\n\t * \t\t\tscheduledEffects.length = 0\n\t * \t\t})\n\t * \t}\n\t * }\n\t * const stop = react('set page title', () => {\n\t * \tdocument.title = doc.title\n\t * }, { scheduleEffect })\n\t * ```\n\t */\n\t// eslint-disable-next-line tldraw/method-signature-style\n\tscheduleEffect?: (execute: () => void) => void\n}\n\nclass __EffectScheduler__<Result> implements EffectScheduler<Result> {\n\treadonly __isEffectScheduler = true as const\n\t/** @internal */\n\tprivate _isActivelyListening = false\n\t/**\n\t * Whether this scheduler is attached and actively listening to its parents.\n\t * @public\n\t */\n\t// eslint-disable-next-line tldraw/no-setter-getter\n\tget isActivelyListening() {\n\t\treturn this._isActivelyListening\n\t}\n\t/** @internal */\n\tlastTraversedEpoch = GLOBAL_START_EPOCH\n\n\t/** @internal */\n\tprivate lastReactedEpoch = GLOBAL_START_EPOCH\n\n\t/** @internal */\n\tprivate _scheduleCount = 0\n\t/** @internal */\n\t__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null = null\n\n\t/**\n\t * The number of times this effect has been scheduled.\n\t * @public\n\t */\n\t// eslint-disable-next-line tldraw/no-setter-getter\n\tget scheduleCount() {\n\t\treturn this._scheduleCount\n\t}\n\n\t/** @internal */\n\treadonly parentSet = new ArraySet<Signal<any, any>>()\n\t/** @internal */\n\treadonly parentEpochs: number[] = []\n\t/** @internal */\n\treadonly parents: Signal<any, any>[] = []\n\t/** @internal */\n\tprivate readonly _scheduleEffect?: (execute: () => void) => void\n\tconstructor(\n\t\tpublic readonly name: string,\n\t\tprivate readonly runEffect: (lastReactedEpoch: number) => Result,\n\t\toptions?: EffectSchedulerOptions\n\t) {\n\t\tthis._scheduleEffect = options?.scheduleEffect\n\t}\n\n\t/** @internal */\n\tmaybeScheduleEffect() {\n\t\t// bail out if we have been cancelled by another effect\n\t\tif (!this._isActivelyListening) return\n\t\t// bail out if no atoms have changed since the last time we ran this effect\n\t\tif (this.lastReactedEpoch === getGlobalEpoch()) return\n\n\t\t// An effect that has run before (or captured parents before throwing) only needs to run\n\t\t// again if one of those parents changed; that includes an effect that captured no parents at\n\t\t// all. An effect that has never run always runs.\n\t\tif (\n\t\t\t(this.lastReactedEpoch !== GLOBAL_START_EPOCH || this.parents.length > 0) &&\n\t\t\t!haveParentsChanged(this)\n\t\t) {\n\t\t\tthis.lastReactedEpoch = getGlobalEpoch()\n\t\t\treturn\n\t\t}\n\t\tthis.scheduleEffect()\n\t}\n\n\t/** @internal */\n\tscheduleEffect() {\n\t\tthis._scheduleCount++\n\t\tif (this._scheduleEffect) {\n\t\t\t// if the effect should be deferred (e.g. until a react render), do so\n\t\t\tthis._scheduleEffect(this.maybeExecute)\n\t\t} else {\n\t\t\t// otherwise execute right now!\n\t\t\tthis.execute()\n\t\t}\n\t}\n\n\t/** @internal */\n\t// eslint-disable-next-line tldraw/prefer-class-methods\n\treadonly maybeExecute = () => {\n\t\t// bail out if we have been detached before this runs\n\t\tif (!this._isActivelyListening) return\n\t\tthis.execute()\n\t}\n\n\t/**\n\t * Makes this scheduler become 'actively listening' to its parents.\n\t * If it has been executed before it will immediately become eligible to receive 'maybeScheduleEffect' calls.\n\t * If it has not executed before it will need to be manually executed once to become eligible for scheduling, i.e. by calling `EffectScheduler.execute`.\n\t * @public\n\t */\n\tattach() {\n\t\tthis._isActivelyListening = true\n\t\tfor (let i = 0, n = this.parents.length; i < n; i++) {\n\t\t\tconst parent = this.parents[i]\n\t\t\t// a computed parent may have gone stale while nothing listened; see `attach` in helpers.ts\n\t\t\tparent.__unsafe__getWithoutCapture(true)\n\t\t\tattach(parent, this)\n\t\t}\n\t}\n\n\t/**\n\t * Makes this scheduler stop 'actively listening' to its parents.\n\t * It will no longer be eligible to receive 'maybeScheduleEffect' calls until `EffectScheduler.attach` is called again.\n\t * @public\n\t */\n\tdetach() {\n\t\tthis._isActivelyListening = false\n\t\tfor (let i = 0, n = this.parents.length; i < n; i++) {\n\t\t\tdetach(this.parents[i], this)\n\t\t}\n\t}\n\n\t/**\n\t * Executes the effect immediately and returns the result.\n\t * @returns The result of the effect.\n\t * @public\n\t */\n\texecute(): Result {\n\t\ttry {\n\t\t\tstartCapturingParents(this)\n\t\t\t// Important! We have to make a note of the current epoch before running the effect.\n\t\t\t// We allow atoms to be updated during effects, which increments the global epoch,\n\t\t\t// so if we were to wait until after the effect runs, the this.lastReactedEpoch value might get ahead of itself.\n\t\t\tconst currentEpoch = getGlobalEpoch()\n\t\t\tconst result = this.runEffect(this.lastReactedEpoch)\n\t\t\tthis.lastReactedEpoch = currentEpoch\n\t\t\treturn result\n\t\t} finally {\n\t\t\tstopCapturingParents()\n\t\t}\n\t}\n}\n\n/**\n * An EffectScheduler is responsible for executing side effects in response to changes in state.\n *\n * You probably don't need to use this directly unless you're integrating this library with a framework of some kind.\n *\n * Instead, use the {@link react} and {@link reactor} functions.\n *\n * @example\n * ```ts\n * const render = new EffectScheduler('render', drawToCanvas)\n *\n * render.attach()\n * render.execute()\n * ```\n *\n * @public\n */\nexport const EffectScheduler = singleton(\n\t'EffectScheduler',\n\t(): {\n\t\tnew <Result>(\n\t\t\tname: string,\n\t\t\trunEffect: (lastReactedEpoch: number) => Result,\n\t\t\toptions?: EffectSchedulerOptions\n\t\t): EffectScheduler<Result>\n\t} => __EffectScheduler__\n)\n/** @public */\nexport interface EffectScheduler<Result> {\n\t/**\n\t * Whether this scheduler is attached and actively listening to its parents.\n\t * @public\n\t */\n\treadonly isActivelyListening: boolean\n\n\t/** @internal */\n\treadonly lastTraversedEpoch: number\n\n\t/** @public */\n\treadonly name: string\n\n\t/** @internal */\n\t__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null\n\n\t/**\n\t * The number of times this effect has been scheduled.\n\t * @public\n\t */\n\treadonly scheduleCount: number\n\n\t/** @internal */\n\treadonly parentSet: ArraySet<Signal<any, any>>\n\n\t/** @internal */\n\treadonly parentEpochs: number[]\n\n\t/** @internal */\n\treadonly parents: Signal<any, any>[]\n\n\t/** @internal */\n\tmaybeScheduleEffect(): void\n\n\t/** @internal */\n\tscheduleEffect(): void\n\n\t/** @internal */\n\tmaybeExecute(): void\n\n\t/**\n\t * Makes this scheduler become 'actively listening' to its parents.\n\t * If it has been executed before it will immediately become eligible to receive 'maybeScheduleEffect' calls.\n\t * If it has not executed before it will need to be manually executed once to become eligible for scheduling, i.e. by calling `EffectScheduler.execute`.\n\t * @public\n\t */\n\tattach(): void\n\n\t/**\n\t * Makes this scheduler stop 'actively listening' to its parents.\n\t * It will no longer be eligible to receive 'maybeScheduleEffect' calls until `EffectScheduler.attach` is called again.\n\t * @public\n\t */\n\tdetach(): void\n\n\t/**\n\t * Executes the effect immediately and returns the result.\n\t * @returns The result of the effect.\n\t * @public\n\t */\n\texecute(): Result\n}\n\n/**\n * Starts a new effect scheduler, scheduling the effect immediately.\n *\n * Returns a function that can be called to stop the scheduler.\n *\n * @example\n * ```ts\n * const color = atom('color', 'red')\n * const stop = react('set style', () => {\n * divElem.style.color = color.get()\n * })\n * color.set('blue')\n * // divElem.style.color === 'blue'\n * stop()\n * color.set('green')\n * // divElem.style.color === 'blue'\n * ```\n *\n *\n * Also useful in React applications for running effects outside of the render cycle.\n *\n * @example\n * ```ts\n * useEffect(() => react('set style', () => {\n * divRef.current.style.color = color.get()\n * }), [])\n * ```\n *\n * @public\n */\nexport function react(\n\tname: string,\n\tfn: (lastReactedEpoch: number) => any,\n\toptions?: EffectSchedulerOptions\n) {\n\tconst scheduler = new EffectScheduler(name, fn, options)\n\tscheduler.attach()\n\tscheduler.scheduleEffect()\n\treturn () => {\n\t\tscheduler.detach()\n\t}\n}\n\n/**\n * The reactor is a user-friendly interface for starting and stopping an `EffectScheduler`.\n *\n * Calling `.start()` will attach the scheduler and execute the effect immediately the first time it is called.\n *\n * If the reactor is stopped, calling `.start()` will re-attach the scheduler but will only execute the effect if any of its parents have changed since it was stopped.\n *\n * You can create a reactor with {@link reactor}.\n * @public\n */\nexport interface Reactor<T = unknown> {\n\t/**\n\t * The underlying effect scheduler.\n\t * @public\n\t */\n\tscheduler: EffectScheduler<T>\n\t/**\n\t * Start the scheduler. The first time this is called the effect will be scheduled immediately.\n\t *\n\t * If the reactor is stopped, calling this will start the scheduler again but will only execute the effect if any of its parents have changed since it was stopped.\n\t *\n\t * If you need to force re-execution of the effect, pass `{ force: true }`.\n\t * @public\n\t */\n\tstart(options?: { force?: boolean }): void\n\t/**\n\t * Stop the scheduler.\n\t * @public\n\t */\n\tstop(): void\n}\n\n/**\n * Creates a {@link Reactor}, which is a thin wrapper around an `EffectScheduler`.\n *\n * @public\n */\nexport function reactor<Result>(\n\tname: string,\n\tfn: (lastReactedEpoch: number) => Result,\n\toptions?: EffectSchedulerOptions\n): Reactor<Result> {\n\tconst scheduler = new EffectScheduler<Result>(name, fn, options)\n\treturn {\n\t\tscheduler,\n\t\tstart: (options?: { force?: boolean }) => {\n\t\t\tconst force = options?.force ?? false\n\t\t\tscheduler.attach()\n\t\t\tif (force) {\n\t\t\t\tscheduler.scheduleEffect()\n\t\t\t} else {\n\t\t\t\tscheduler.maybeScheduleEffect()\n\t\t\t}\n\t\t},\n\t\tstop: () => {\n\t\t\tscheduler.detach()\n\t\t},\n\t}\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,sBAAyB;AACzB,qBAA4D;AAC5D,uBAAmC;AACnC,qBAA8D;AAC9D,0BAA+B;AAmC/B,MAAM,oBAA+D;AAAA,
|
|
4
|
+
"sourcesContent": ["import { ArraySet } from './ArraySet'\nimport { startCapturingParents, stopCapturingParents } from './capture'\nimport { GLOBAL_START_EPOCH } from './constants'\nimport { attach, detach, haveParentsChanged, singleton } from './helpers'\nimport { getGlobalEpoch } from './transactions'\nimport { Signal } from './types'\n\n/** @public */\nexport interface EffectSchedulerOptions {\n\t/**\n\t * scheduleEffect is a function that will be called when the effect is scheduled.\n\t *\n\t * It can be used to defer running effects until a later time, for example to batch them together with requestAnimationFrame.\n\t *\n\t *\n\t * @example\n\t * ```ts\n\t * let isRafScheduled = false\n\t * const scheduledEffects: Array<() => void> = []\n\t * const scheduleEffect = (runEffect: () => void) => {\n\t * \tscheduledEffects.push(runEffect)\n\t * \tif (!isRafScheduled) {\n\t * \t\tisRafScheduled = true\n\t * \t\trequestAnimationFrame(() => {\n\t * \t\t\tisRafScheduled = false\n\t * \t\t\tscheduledEffects.forEach((runEffect) => runEffect())\n\t * \t\t\tscheduledEffects.length = 0\n\t * \t\t})\n\t * \t}\n\t * }\n\t * const stop = react('set page title', () => {\n\t * \tdocument.title = doc.title\n\t * }, { scheduleEffect })\n\t * ```\n\t */\n\t// eslint-disable-next-line tldraw/method-signature-style\n\tscheduleEffect?: (execute: () => void) => void\n}\n\nclass __EffectScheduler__<Result> implements EffectScheduler<Result> {\n\treadonly __isEffectScheduler = true as const\n\t/** @internal */\n\tprivate _isActivelyListening = false\n\t/**\n\t * Whether this scheduler is attached and actively listening to its parents.\n\t * @public\n\t */\n\t// eslint-disable-next-line tldraw/no-setter-getter\n\tget isActivelyListening() {\n\t\treturn this._isActivelyListening\n\t}\n\t/** @internal */\n\tlastTraversedEpoch = GLOBAL_START_EPOCH\n\n\t/** @internal */\n\tprivate lastReactedEpoch = GLOBAL_START_EPOCH\n\n\t/** @internal */\n\tprivate _scheduleCount = 0\n\t/** @internal */\n\tprivate _executeDepth = 0\n\t/** @internal */\n\tprivate _wasScheduledWhileExecuting = false\n\t/** @internal */\n\t__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null = null\n\n\t/**\n\t * The number of times this effect has been scheduled.\n\t * @public\n\t */\n\t// eslint-disable-next-line tldraw/no-setter-getter\n\tget scheduleCount() {\n\t\treturn this._scheduleCount\n\t}\n\n\t/** @internal */\n\treadonly parentSet = new ArraySet<Signal<any, any>>()\n\t/** @internal */\n\treadonly parentEpochs: number[] = []\n\t/** @internal */\n\treadonly parents: Signal<any, any>[] = []\n\t/** @internal */\n\tprivate readonly _scheduleEffect?: (execute: () => void) => void\n\tconstructor(\n\t\tpublic readonly name: string,\n\t\tprivate readonly runEffect: (lastReactedEpoch: number) => Result,\n\t\toptions?: EffectSchedulerOptions\n\t) {\n\t\tthis._scheduleEffect = options?.scheduleEffect\n\t}\n\n\t/** @internal */\n\tmaybeScheduleEffect() {\n\t\t// bail out if we have been cancelled by another effect\n\t\tif (!this._isActivelyListening) return\n\t\t// bail out if no atoms have changed since the last time we ran this effect\n\t\tif (this.lastReactedEpoch === getGlobalEpoch()) return\n\n\t\t// An effect that has run before (or captured parents before throwing) only needs to run\n\t\t// again if one of those parents changed; that includes an effect that captured no parents at\n\t\t// all. An effect that has never run always runs.\n\t\tif (\n\t\t\t(this.lastReactedEpoch !== GLOBAL_START_EPOCH || this.parents.length > 0) &&\n\t\t\t!haveParentsChanged(this)\n\t\t) {\n\t\t\tthis.lastReactedEpoch = getGlobalEpoch()\n\t\t\treturn\n\t\t}\n\t\tthis.scheduleEffect()\n\t}\n\n\t/** @internal */\n\tscheduleEffect() {\n\t\tthis._scheduleCount++\n\t\tif (this._scheduleEffect) {\n\t\t\t// if the effect should be deferred (e.g. until a react render), do so\n\t\t\tthis._scheduleEffect(this.maybeExecute)\n\t\t} else {\n\t\t\t// otherwise execute right now!\n\t\t\tthis.maybeExecute()\n\t\t}\n\t}\n\n\t/** @internal */\n\t// eslint-disable-next-line tldraw/prefer-class-methods\n\treadonly maybeExecute = () => {\n\t\t// bail out if we have been detached before this runs\n\t\tif (!this._isActivelyListening) return\n\t\t// A set inside the running effect flushed synchronously back to this scheduler (only possible\n\t\t// outside the reaction phase, e.g. the first run of `react()`). Running now would open a second\n\t\t// capture frame inside the open one and corrupt `parents`, so re-check after the run instead.\n\t\tif (this._executeDepth > 0) {\n\t\t\tthis._wasScheduledWhileExecuting = true\n\t\t\treturn\n\t\t}\n\t\tthis.execute()\n\t}\n\n\t/**\n\t * Makes this scheduler become 'actively listening' to its parents.\n\t * If it has been executed before it will immediately become eligible to receive 'maybeScheduleEffect' calls.\n\t * If it has not executed before it will need to be manually executed once to become eligible for scheduling, i.e. by calling `EffectScheduler.execute`.\n\t * @public\n\t */\n\tattach() {\n\t\tthis._isActivelyListening = true\n\t\tfor (let i = 0, n = this.parents.length; i < n; i++) {\n\t\t\tconst parent = this.parents[i]\n\t\t\t// a computed parent may have gone stale while nothing listened; see `attach` in helpers.ts\n\t\t\tparent.__unsafe__getWithoutCapture(true)\n\t\t\tattach(parent, this)\n\t\t}\n\t}\n\n\t/**\n\t * Makes this scheduler stop 'actively listening' to its parents.\n\t * It will no longer be eligible to receive 'maybeScheduleEffect' calls until `EffectScheduler.attach` is called again.\n\t * @public\n\t */\n\tdetach() {\n\t\tthis._isActivelyListening = false\n\t\tfor (let i = 0, n = this.parents.length; i < n; i++) {\n\t\t\tdetach(this.parents[i], this)\n\t\t}\n\t}\n\n\t/**\n\t * Executes the effect immediately and returns the result.\n\t * @returns The result of the effect.\n\t * @public\n\t */\n\texecute(): Result {\n\t\t// A direct re-entrant `execute()` from inside the effect is unsupported (both runs share one\n\t\t// `parents`); it is left to run so the outer run can at least finish normally.\n\t\tif (this._executeDepth > 0) return this.executeOnce()\n\t\t// a run that threw may have left this set\n\t\tthis._wasScheduledWhileExecuting = false\n\t\tlet result = this.executeOnce()\n\t\t// If a set inside the run reached this scheduler (see `maybeExecute`), settle it now the way\n\t\t// the reaction phase's cleanup pass would: run again while the parents keep changing.\n\t\tfor (let depth = 0; this._wasScheduledWhileExecuting; depth++) {\n\t\t\tthis._wasScheduledWhileExecuting = false\n\t\t\tif (depth >= 1000) {\n\t\t\t\tthrow new Error('Reaction update depth limit exceeded')\n\t\t\t}\n\t\t\tif (!this._isActivelyListening) break\n\t\t\tif (!haveParentsChanged(this)) {\n\t\t\t\tthis.lastReactedEpoch = getGlobalEpoch()\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tresult = this.executeOnce()\n\t\t}\n\t\treturn result\n\t}\n\n\tprivate executeOnce(): Result {\n\t\t// A counter rather than a flag: a nested `execute()` must not mark the outer run as finished.\n\t\tthis._executeDepth++\n\t\ttry {\n\t\t\tstartCapturingParents(this)\n\t\t\t// Important! We have to make a note of the current epoch before running the effect.\n\t\t\t// We allow atoms to be updated during effects, which increments the global epoch,\n\t\t\t// so if we were to wait until after the effect runs, the this.lastReactedEpoch value might get ahead of itself.\n\t\t\tconst currentEpoch = getGlobalEpoch()\n\t\t\tconst result = this.runEffect(this.lastReactedEpoch)\n\t\t\tthis.lastReactedEpoch = currentEpoch\n\t\t\treturn result\n\t\t} finally {\n\t\t\tstopCapturingParents()\n\t\t\tthis._executeDepth--\n\t\t}\n\t}\n}\n\n/**\n * An EffectScheduler is responsible for executing side effects in response to changes in state.\n *\n * You probably don't need to use this directly unless you're integrating this library with a framework of some kind.\n *\n * Instead, use the {@link react} and {@link reactor} functions.\n *\n * @example\n * ```ts\n * const render = new EffectScheduler('render', drawToCanvas)\n *\n * render.attach()\n * render.execute()\n * ```\n *\n * @public\n */\nexport const EffectScheduler = singleton(\n\t'EffectScheduler',\n\t(): {\n\t\tnew <Result>(\n\t\t\tname: string,\n\t\t\trunEffect: (lastReactedEpoch: number) => Result,\n\t\t\toptions?: EffectSchedulerOptions\n\t\t): EffectScheduler<Result>\n\t} => __EffectScheduler__\n)\n/** @public */\nexport interface EffectScheduler<Result> {\n\t/**\n\t * Whether this scheduler is attached and actively listening to its parents.\n\t * @public\n\t */\n\treadonly isActivelyListening: boolean\n\n\t/** @internal */\n\treadonly lastTraversedEpoch: number\n\n\t/** @public */\n\treadonly name: string\n\n\t/** @internal */\n\t__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null\n\n\t/**\n\t * The number of times this effect has been scheduled.\n\t * @public\n\t */\n\treadonly scheduleCount: number\n\n\t/** @internal */\n\treadonly parentSet: ArraySet<Signal<any, any>>\n\n\t/** @internal */\n\treadonly parentEpochs: number[]\n\n\t/** @internal */\n\treadonly parents: Signal<any, any>[]\n\n\t/** @internal */\n\tmaybeScheduleEffect(): void\n\n\t/** @internal */\n\tscheduleEffect(): void\n\n\t/** @internal */\n\tmaybeExecute(): void\n\n\t/**\n\t * Makes this scheduler become 'actively listening' to its parents.\n\t * If it has been executed before it will immediately become eligible to receive 'maybeScheduleEffect' calls.\n\t * If it has not executed before it will need to be manually executed once to become eligible for scheduling, i.e. by calling `EffectScheduler.execute`.\n\t * @public\n\t */\n\tattach(): void\n\n\t/**\n\t * Makes this scheduler stop 'actively listening' to its parents.\n\t * It will no longer be eligible to receive 'maybeScheduleEffect' calls until `EffectScheduler.attach` is called again.\n\t * @public\n\t */\n\tdetach(): void\n\n\t/**\n\t * Executes the effect immediately and returns the result.\n\t * @returns The result of the effect.\n\t * @public\n\t */\n\texecute(): Result\n}\n\n/**\n * Starts a new effect scheduler, scheduling the effect immediately.\n *\n * Returns a function that can be called to stop the scheduler.\n *\n * @example\n * ```ts\n * const color = atom('color', 'red')\n * const stop = react('set style', () => {\n * divElem.style.color = color.get()\n * })\n * color.set('blue')\n * // divElem.style.color === 'blue'\n * stop()\n * color.set('green')\n * // divElem.style.color === 'blue'\n * ```\n *\n *\n * Also useful in React applications for running effects outside of the render cycle.\n *\n * @example\n * ```ts\n * useEffect(() => react('set style', () => {\n * divRef.current.style.color = color.get()\n * }), [])\n * ```\n *\n * @public\n */\nexport function react(\n\tname: string,\n\tfn: (lastReactedEpoch: number) => any,\n\toptions?: EffectSchedulerOptions\n) {\n\tconst scheduler = new EffectScheduler(name, fn, options)\n\tscheduler.attach()\n\tscheduler.scheduleEffect()\n\treturn () => {\n\t\tscheduler.detach()\n\t}\n}\n\n/**\n * The reactor is a user-friendly interface for starting and stopping an `EffectScheduler`.\n *\n * Calling `.start()` will attach the scheduler and execute the effect immediately the first time it is called.\n *\n * If the reactor is stopped, calling `.start()` will re-attach the scheduler but will only execute the effect if any of its parents have changed since it was stopped.\n *\n * You can create a reactor with {@link reactor}.\n * @public\n */\nexport interface Reactor<T = unknown> {\n\t/**\n\t * The underlying effect scheduler.\n\t * @public\n\t */\n\tscheduler: EffectScheduler<T>\n\t/**\n\t * Start the scheduler. The first time this is called the effect will be scheduled immediately.\n\t *\n\t * If the reactor is stopped, calling this will start the scheduler again but will only execute the effect if any of its parents have changed since it was stopped.\n\t *\n\t * If you need to force re-execution of the effect, pass `{ force: true }`.\n\t * @public\n\t */\n\tstart(options?: { force?: boolean }): void\n\t/**\n\t * Stop the scheduler.\n\t * @public\n\t */\n\tstop(): void\n}\n\n/**\n * Creates a {@link Reactor}, which is a thin wrapper around an `EffectScheduler`.\n *\n * @public\n */\nexport function reactor<Result>(\n\tname: string,\n\tfn: (lastReactedEpoch: number) => Result,\n\toptions?: EffectSchedulerOptions\n): Reactor<Result> {\n\tconst scheduler = new EffectScheduler<Result>(name, fn, options)\n\treturn {\n\t\tscheduler,\n\t\tstart: (options?: { force?: boolean }) => {\n\t\t\tconst force = options?.force ?? false\n\t\t\tscheduler.attach()\n\t\t\tif (force) {\n\t\t\t\tscheduler.scheduleEffect()\n\t\t\t} else {\n\t\t\t\tscheduler.maybeScheduleEffect()\n\t\t\t}\n\t\t},\n\t\tstop: () => {\n\t\t\tscheduler.detach()\n\t\t},\n\t}\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,sBAAyB;AACzB,qBAA4D;AAC5D,uBAAmC;AACnC,qBAA8D;AAC9D,0BAA+B;AAmC/B,MAAM,oBAA+D;AAAA,EA4CpE,YACiB,MACC,WACjB,SACC;AAHe;AACC;AAGjB,SAAK,kBAAkB,SAAS;AAAA,EACjC;AAAA,EALiB;AAAA,EACC;AAAA,EA7CT,sBAAsB;AAAA;AAAA,EAEvB,uBAAuB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAM/B,IAAI,sBAAsB;AACzB,WAAO,KAAK;AAAA,EACb;AAAA;AAAA,EAEA,qBAAqB;AAAA;AAAA,EAGb,mBAAmB;AAAA;AAAA,EAGnB,iBAAiB;AAAA;AAAA,EAEjB,gBAAgB;AAAA;AAAA,EAEhB,8BAA8B;AAAA;AAAA,EAEtC,4BAAkE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOlE,IAAI,gBAAgB;AACnB,WAAO,KAAK;AAAA,EACb;AAAA;AAAA,EAGS,YAAY,IAAI,yBAA2B;AAAA;AAAA,EAE3C,eAAyB,CAAC;AAAA;AAAA,EAE1B,UAA8B,CAAC;AAAA;AAAA,EAEvB;AAAA;AAAA,EAUjB,sBAAsB;AAErB,QAAI,CAAC,KAAK,qBAAsB;AAEhC,QAAI,KAAK,yBAAqB,oCAAe,EAAG;AAKhD,SACE,KAAK,qBAAqB,uCAAsB,KAAK,QAAQ,SAAS,MACvE,KAAC,mCAAmB,IAAI,GACvB;AACD,WAAK,uBAAmB,oCAAe;AACvC;AAAA,IACD;AACA,SAAK,eAAe;AAAA,EACrB;AAAA;AAAA,EAGA,iBAAiB;AAChB,SAAK;AACL,QAAI,KAAK,iBAAiB;AAEzB,WAAK,gBAAgB,KAAK,YAAY;AAAA,IACvC,OAAO;AAEN,WAAK,aAAa;AAAA,IACnB;AAAA,EACD;AAAA;AAAA;AAAA,EAIS,eAAe,MAAM;AAE7B,QAAI,CAAC,KAAK,qBAAsB;AAIhC,QAAI,KAAK,gBAAgB,GAAG;AAC3B,WAAK,8BAA8B;AACnC;AAAA,IACD;AACA,SAAK,QAAQ;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,SAAS;AACR,SAAK,uBAAuB;AAC5B,aAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,QAAQ,IAAI,GAAG,KAAK;AACpD,YAAM,SAAS,KAAK,QAAQ,CAAC;AAE7B,aAAO,4BAA4B,IAAI;AACvC,iCAAO,QAAQ,IAAI;AAAA,IACpB;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,SAAS;AACR,SAAK,uBAAuB;AAC5B,aAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,QAAQ,IAAI,GAAG,KAAK;AACpD,iCAAO,KAAK,QAAQ,CAAC,GAAG,IAAI;AAAA,IAC7B;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,UAAkB;AAGjB,QAAI,KAAK,gBAAgB,EAAG,QAAO,KAAK,YAAY;AAEpD,SAAK,8BAA8B;AACnC,QAAI,SAAS,KAAK,YAAY;AAG9B,aAAS,QAAQ,GAAG,KAAK,6BAA6B,SAAS;AAC9D,WAAK,8BAA8B;AACnC,UAAI,SAAS,KAAM;AAClB,cAAM,IAAI,MAAM,sCAAsC;AAAA,MACvD;AACA,UAAI,CAAC,KAAK,qBAAsB;AAChC,UAAI,KAAC,mCAAmB,IAAI,GAAG;AAC9B,aAAK,uBAAmB,oCAAe;AACvC;AAAA,MACD;AACA,eAAS,KAAK,YAAY;AAAA,IAC3B;AACA,WAAO;AAAA,EACR;AAAA,EAEQ,cAAsB;AAE7B,SAAK;AACL,QAAI;AACH,gDAAsB,IAAI;AAI1B,YAAM,mBAAe,oCAAe;AACpC,YAAM,SAAS,KAAK,UAAU,KAAK,gBAAgB;AACnD,WAAK,mBAAmB;AACxB,aAAO;AAAA,IACR,UAAE;AACD,+CAAqB;AACrB,WAAK;AAAA,IACN;AAAA,EACD;AACD;AAmBO,MAAM,sBAAkB;AAAA,EAC9B;AAAA,EACA,MAMK;AACN;AA+FO,SAAS,MACf,MACA,IACA,SACC;AACD,QAAM,YAAY,IAAI,gBAAgB,MAAM,IAAI,OAAO;AACvD,YAAU,OAAO;AACjB,YAAU,eAAe;AACzB,SAAO,MAAM;AACZ,cAAU,OAAO;AAAA,EAClB;AACD;AAuCO,SAAS,QACf,MACA,IACA,SACkB;AAClB,QAAM,YAAY,IAAI,gBAAwB,MAAM,IAAI,OAAO;AAC/D,SAAO;AAAA,IACN;AAAA,IACA,OAAO,CAACA,aAAkC;AACzC,YAAM,QAAQA,UAAS,SAAS;AAChC,gBAAU,OAAO;AACjB,UAAI,OAAO;AACV,kBAAU,eAAe;AAAA,MAC1B,OAAO;AACN,kBAAU,oBAAoB;AAAA,MAC/B;AAAA,IACD;AAAA,IACA,MAAM,MAAM;AACX,gBAAU,OAAO;AAAA,IAClB;AAAA,EACD;AACD;",
|
|
6
6
|
"names": ["options"]
|
|
7
7
|
}
|
package/dist-esm/index.mjs
CHANGED
|
@@ -29,6 +29,10 @@ class __EffectScheduler__ {
|
|
|
29
29
|
/** @internal */
|
|
30
30
|
_scheduleCount = 0;
|
|
31
31
|
/** @internal */
|
|
32
|
+
_executeDepth = 0;
|
|
33
|
+
/** @internal */
|
|
34
|
+
_wasScheduledWhileExecuting = false;
|
|
35
|
+
/** @internal */
|
|
32
36
|
__debug_ancestor_epochs__ = null;
|
|
33
37
|
/**
|
|
34
38
|
* The number of times this effect has been scheduled.
|
|
@@ -62,13 +66,17 @@ class __EffectScheduler__ {
|
|
|
62
66
|
if (this._scheduleEffect) {
|
|
63
67
|
this._scheduleEffect(this.maybeExecute);
|
|
64
68
|
} else {
|
|
65
|
-
this.
|
|
69
|
+
this.maybeExecute();
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
/** @internal */
|
|
69
73
|
// eslint-disable-next-line tldraw/prefer-class-methods
|
|
70
74
|
maybeExecute = () => {
|
|
71
75
|
if (!this._isActivelyListening) return;
|
|
76
|
+
if (this._executeDepth > 0) {
|
|
77
|
+
this._wasScheduledWhileExecuting = true;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
72
80
|
this.execute();
|
|
73
81
|
};
|
|
74
82
|
/**
|
|
@@ -102,6 +110,25 @@ class __EffectScheduler__ {
|
|
|
102
110
|
* @public
|
|
103
111
|
*/
|
|
104
112
|
execute() {
|
|
113
|
+
if (this._executeDepth > 0) return this.executeOnce();
|
|
114
|
+
this._wasScheduledWhileExecuting = false;
|
|
115
|
+
let result = this.executeOnce();
|
|
116
|
+
for (let depth = 0; this._wasScheduledWhileExecuting; depth++) {
|
|
117
|
+
this._wasScheduledWhileExecuting = false;
|
|
118
|
+
if (depth >= 1e3) {
|
|
119
|
+
throw new Error("Reaction update depth limit exceeded");
|
|
120
|
+
}
|
|
121
|
+
if (!this._isActivelyListening) break;
|
|
122
|
+
if (!haveParentsChanged(this)) {
|
|
123
|
+
this.lastReactedEpoch = getGlobalEpoch();
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
result = this.executeOnce();
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
executeOnce() {
|
|
131
|
+
this._executeDepth++;
|
|
105
132
|
try {
|
|
106
133
|
startCapturingParents(this);
|
|
107
134
|
const currentEpoch = getGlobalEpoch();
|
|
@@ -110,6 +137,7 @@ class __EffectScheduler__ {
|
|
|
110
137
|
return result;
|
|
111
138
|
} finally {
|
|
112
139
|
stopCapturingParents();
|
|
140
|
+
this._executeDepth--;
|
|
113
141
|
}
|
|
114
142
|
}
|
|
115
143
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/EffectScheduler.ts"],
|
|
4
|
-
"sourcesContent": ["import { ArraySet } from './ArraySet'\nimport { startCapturingParents, stopCapturingParents } from './capture'\nimport { GLOBAL_START_EPOCH } from './constants'\nimport { attach, detach, haveParentsChanged, singleton } from './helpers'\nimport { getGlobalEpoch } from './transactions'\nimport { Signal } from './types'\n\n/** @public */\nexport interface EffectSchedulerOptions {\n\t/**\n\t * scheduleEffect is a function that will be called when the effect is scheduled.\n\t *\n\t * It can be used to defer running effects until a later time, for example to batch them together with requestAnimationFrame.\n\t *\n\t *\n\t * @example\n\t * ```ts\n\t * let isRafScheduled = false\n\t * const scheduledEffects: Array<() => void> = []\n\t * const scheduleEffect = (runEffect: () => void) => {\n\t * \tscheduledEffects.push(runEffect)\n\t * \tif (!isRafScheduled) {\n\t * \t\tisRafScheduled = true\n\t * \t\trequestAnimationFrame(() => {\n\t * \t\t\tisRafScheduled = false\n\t * \t\t\tscheduledEffects.forEach((runEffect) => runEffect())\n\t * \t\t\tscheduledEffects.length = 0\n\t * \t\t})\n\t * \t}\n\t * }\n\t * const stop = react('set page title', () => {\n\t * \tdocument.title = doc.title\n\t * }, { scheduleEffect })\n\t * ```\n\t */\n\t// eslint-disable-next-line tldraw/method-signature-style\n\tscheduleEffect?: (execute: () => void) => void\n}\n\nclass __EffectScheduler__<Result> implements EffectScheduler<Result> {\n\treadonly __isEffectScheduler = true as const\n\t/** @internal */\n\tprivate _isActivelyListening = false\n\t/**\n\t * Whether this scheduler is attached and actively listening to its parents.\n\t * @public\n\t */\n\t// eslint-disable-next-line tldraw/no-setter-getter\n\tget isActivelyListening() {\n\t\treturn this._isActivelyListening\n\t}\n\t/** @internal */\n\tlastTraversedEpoch = GLOBAL_START_EPOCH\n\n\t/** @internal */\n\tprivate lastReactedEpoch = GLOBAL_START_EPOCH\n\n\t/** @internal */\n\tprivate _scheduleCount = 0\n\t/** @internal */\n\t__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null = null\n\n\t/**\n\t * The number of times this effect has been scheduled.\n\t * @public\n\t */\n\t// eslint-disable-next-line tldraw/no-setter-getter\n\tget scheduleCount() {\n\t\treturn this._scheduleCount\n\t}\n\n\t/** @internal */\n\treadonly parentSet = new ArraySet<Signal<any, any>>()\n\t/** @internal */\n\treadonly parentEpochs: number[] = []\n\t/** @internal */\n\treadonly parents: Signal<any, any>[] = []\n\t/** @internal */\n\tprivate readonly _scheduleEffect?: (execute: () => void) => void\n\tconstructor(\n\t\tpublic readonly name: string,\n\t\tprivate readonly runEffect: (lastReactedEpoch: number) => Result,\n\t\toptions?: EffectSchedulerOptions\n\t) {\n\t\tthis._scheduleEffect = options?.scheduleEffect\n\t}\n\n\t/** @internal */\n\tmaybeScheduleEffect() {\n\t\t// bail out if we have been cancelled by another effect\n\t\tif (!this._isActivelyListening) return\n\t\t// bail out if no atoms have changed since the last time we ran this effect\n\t\tif (this.lastReactedEpoch === getGlobalEpoch()) return\n\n\t\t// An effect that has run before (or captured parents before throwing) only needs to run\n\t\t// again if one of those parents changed; that includes an effect that captured no parents at\n\t\t// all. An effect that has never run always runs.\n\t\tif (\n\t\t\t(this.lastReactedEpoch !== GLOBAL_START_EPOCH || this.parents.length > 0) &&\n\t\t\t!haveParentsChanged(this)\n\t\t) {\n\t\t\tthis.lastReactedEpoch = getGlobalEpoch()\n\t\t\treturn\n\t\t}\n\t\tthis.scheduleEffect()\n\t}\n\n\t/** @internal */\n\tscheduleEffect() {\n\t\tthis._scheduleCount++\n\t\tif (this._scheduleEffect) {\n\t\t\t// if the effect should be deferred (e.g. until a react render), do so\n\t\t\tthis._scheduleEffect(this.maybeExecute)\n\t\t} else {\n\t\t\t// otherwise execute right now!\n\t\t\tthis.execute()\n\t\t}\n\t}\n\n\t/** @internal */\n\t// eslint-disable-next-line tldraw/prefer-class-methods\n\treadonly maybeExecute = () => {\n\t\t// bail out if we have been detached before this runs\n\t\tif (!this._isActivelyListening) return\n\t\tthis.execute()\n\t}\n\n\t/**\n\t * Makes this scheduler become 'actively listening' to its parents.\n\t * If it has been executed before it will immediately become eligible to receive 'maybeScheduleEffect' calls.\n\t * If it has not executed before it will need to be manually executed once to become eligible for scheduling, i.e. by calling `EffectScheduler.execute`.\n\t * @public\n\t */\n\tattach() {\n\t\tthis._isActivelyListening = true\n\t\tfor (let i = 0, n = this.parents.length; i < n; i++) {\n\t\t\tconst parent = this.parents[i]\n\t\t\t// a computed parent may have gone stale while nothing listened; see `attach` in helpers.ts\n\t\t\tparent.__unsafe__getWithoutCapture(true)\n\t\t\tattach(parent, this)\n\t\t}\n\t}\n\n\t/**\n\t * Makes this scheduler stop 'actively listening' to its parents.\n\t * It will no longer be eligible to receive 'maybeScheduleEffect' calls until `EffectScheduler.attach` is called again.\n\t * @public\n\t */\n\tdetach() {\n\t\tthis._isActivelyListening = false\n\t\tfor (let i = 0, n = this.parents.length; i < n; i++) {\n\t\t\tdetach(this.parents[i], this)\n\t\t}\n\t}\n\n\t/**\n\t * Executes the effect immediately and returns the result.\n\t * @returns The result of the effect.\n\t * @public\n\t */\n\texecute(): Result {\n\t\ttry {\n\t\t\tstartCapturingParents(this)\n\t\t\t// Important! We have to make a note of the current epoch before running the effect.\n\t\t\t// We allow atoms to be updated during effects, which increments the global epoch,\n\t\t\t// so if we were to wait until after the effect runs, the this.lastReactedEpoch value might get ahead of itself.\n\t\t\tconst currentEpoch = getGlobalEpoch()\n\t\t\tconst result = this.runEffect(this.lastReactedEpoch)\n\t\t\tthis.lastReactedEpoch = currentEpoch\n\t\t\treturn result\n\t\t} finally {\n\t\t\tstopCapturingParents()\n\t\t}\n\t}\n}\n\n/**\n * An EffectScheduler is responsible for executing side effects in response to changes in state.\n *\n * You probably don't need to use this directly unless you're integrating this library with a framework of some kind.\n *\n * Instead, use the {@link react} and {@link reactor} functions.\n *\n * @example\n * ```ts\n * const render = new EffectScheduler('render', drawToCanvas)\n *\n * render.attach()\n * render.execute()\n * ```\n *\n * @public\n */\nexport const EffectScheduler = singleton(\n\t'EffectScheduler',\n\t(): {\n\t\tnew <Result>(\n\t\t\tname: string,\n\t\t\trunEffect: (lastReactedEpoch: number) => Result,\n\t\t\toptions?: EffectSchedulerOptions\n\t\t): EffectScheduler<Result>\n\t} => __EffectScheduler__\n)\n/** @public */\nexport interface EffectScheduler<Result> {\n\t/**\n\t * Whether this scheduler is attached and actively listening to its parents.\n\t * @public\n\t */\n\treadonly isActivelyListening: boolean\n\n\t/** @internal */\n\treadonly lastTraversedEpoch: number\n\n\t/** @public */\n\treadonly name: string\n\n\t/** @internal */\n\t__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null\n\n\t/**\n\t * The number of times this effect has been scheduled.\n\t * @public\n\t */\n\treadonly scheduleCount: number\n\n\t/** @internal */\n\treadonly parentSet: ArraySet<Signal<any, any>>\n\n\t/** @internal */\n\treadonly parentEpochs: number[]\n\n\t/** @internal */\n\treadonly parents: Signal<any, any>[]\n\n\t/** @internal */\n\tmaybeScheduleEffect(): void\n\n\t/** @internal */\n\tscheduleEffect(): void\n\n\t/** @internal */\n\tmaybeExecute(): void\n\n\t/**\n\t * Makes this scheduler become 'actively listening' to its parents.\n\t * If it has been executed before it will immediately become eligible to receive 'maybeScheduleEffect' calls.\n\t * If it has not executed before it will need to be manually executed once to become eligible for scheduling, i.e. by calling `EffectScheduler.execute`.\n\t * @public\n\t */\n\tattach(): void\n\n\t/**\n\t * Makes this scheduler stop 'actively listening' to its parents.\n\t * It will no longer be eligible to receive 'maybeScheduleEffect' calls until `EffectScheduler.attach` is called again.\n\t * @public\n\t */\n\tdetach(): void\n\n\t/**\n\t * Executes the effect immediately and returns the result.\n\t * @returns The result of the effect.\n\t * @public\n\t */\n\texecute(): Result\n}\n\n/**\n * Starts a new effect scheduler, scheduling the effect immediately.\n *\n * Returns a function that can be called to stop the scheduler.\n *\n * @example\n * ```ts\n * const color = atom('color', 'red')\n * const stop = react('set style', () => {\n * divElem.style.color = color.get()\n * })\n * color.set('blue')\n * // divElem.style.color === 'blue'\n * stop()\n * color.set('green')\n * // divElem.style.color === 'blue'\n * ```\n *\n *\n * Also useful in React applications for running effects outside of the render cycle.\n *\n * @example\n * ```ts\n * useEffect(() => react('set style', () => {\n * divRef.current.style.color = color.get()\n * }), [])\n * ```\n *\n * @public\n */\nexport function react(\n\tname: string,\n\tfn: (lastReactedEpoch: number) => any,\n\toptions?: EffectSchedulerOptions\n) {\n\tconst scheduler = new EffectScheduler(name, fn, options)\n\tscheduler.attach()\n\tscheduler.scheduleEffect()\n\treturn () => {\n\t\tscheduler.detach()\n\t}\n}\n\n/**\n * The reactor is a user-friendly interface for starting and stopping an `EffectScheduler`.\n *\n * Calling `.start()` will attach the scheduler and execute the effect immediately the first time it is called.\n *\n * If the reactor is stopped, calling `.start()` will re-attach the scheduler but will only execute the effect if any of its parents have changed since it was stopped.\n *\n * You can create a reactor with {@link reactor}.\n * @public\n */\nexport interface Reactor<T = unknown> {\n\t/**\n\t * The underlying effect scheduler.\n\t * @public\n\t */\n\tscheduler: EffectScheduler<T>\n\t/**\n\t * Start the scheduler. The first time this is called the effect will be scheduled immediately.\n\t *\n\t * If the reactor is stopped, calling this will start the scheduler again but will only execute the effect if any of its parents have changed since it was stopped.\n\t *\n\t * If you need to force re-execution of the effect, pass `{ force: true }`.\n\t * @public\n\t */\n\tstart(options?: { force?: boolean }): void\n\t/**\n\t * Stop the scheduler.\n\t * @public\n\t */\n\tstop(): void\n}\n\n/**\n * Creates a {@link Reactor}, which is a thin wrapper around an `EffectScheduler`.\n *\n * @public\n */\nexport function reactor<Result>(\n\tname: string,\n\tfn: (lastReactedEpoch: number) => Result,\n\toptions?: EffectSchedulerOptions\n): Reactor<Result> {\n\tconst scheduler = new EffectScheduler<Result>(name, fn, options)\n\treturn {\n\t\tscheduler,\n\t\tstart: (options?: { force?: boolean }) => {\n\t\t\tconst force = options?.force ?? false\n\t\t\tscheduler.attach()\n\t\t\tif (force) {\n\t\t\t\tscheduler.scheduleEffect()\n\t\t\t} else {\n\t\t\t\tscheduler.maybeScheduleEffect()\n\t\t\t}\n\t\t},\n\t\tstop: () => {\n\t\t\tscheduler.detach()\n\t\t},\n\t}\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,gBAAgB;AACzB,SAAS,uBAAuB,4BAA4B;AAC5D,SAAS,0BAA0B;AACnC,SAAS,QAAQ,QAAQ,oBAAoB,iBAAiB;AAC9D,SAAS,sBAAsB;AAmC/B,MAAM,oBAA+D;AAAA,
|
|
4
|
+
"sourcesContent": ["import { ArraySet } from './ArraySet'\nimport { startCapturingParents, stopCapturingParents } from './capture'\nimport { GLOBAL_START_EPOCH } from './constants'\nimport { attach, detach, haveParentsChanged, singleton } from './helpers'\nimport { getGlobalEpoch } from './transactions'\nimport { Signal } from './types'\n\n/** @public */\nexport interface EffectSchedulerOptions {\n\t/**\n\t * scheduleEffect is a function that will be called when the effect is scheduled.\n\t *\n\t * It can be used to defer running effects until a later time, for example to batch them together with requestAnimationFrame.\n\t *\n\t *\n\t * @example\n\t * ```ts\n\t * let isRafScheduled = false\n\t * const scheduledEffects: Array<() => void> = []\n\t * const scheduleEffect = (runEffect: () => void) => {\n\t * \tscheduledEffects.push(runEffect)\n\t * \tif (!isRafScheduled) {\n\t * \t\tisRafScheduled = true\n\t * \t\trequestAnimationFrame(() => {\n\t * \t\t\tisRafScheduled = false\n\t * \t\t\tscheduledEffects.forEach((runEffect) => runEffect())\n\t * \t\t\tscheduledEffects.length = 0\n\t * \t\t})\n\t * \t}\n\t * }\n\t * const stop = react('set page title', () => {\n\t * \tdocument.title = doc.title\n\t * }, { scheduleEffect })\n\t * ```\n\t */\n\t// eslint-disable-next-line tldraw/method-signature-style\n\tscheduleEffect?: (execute: () => void) => void\n}\n\nclass __EffectScheduler__<Result> implements EffectScheduler<Result> {\n\treadonly __isEffectScheduler = true as const\n\t/** @internal */\n\tprivate _isActivelyListening = false\n\t/**\n\t * Whether this scheduler is attached and actively listening to its parents.\n\t * @public\n\t */\n\t// eslint-disable-next-line tldraw/no-setter-getter\n\tget isActivelyListening() {\n\t\treturn this._isActivelyListening\n\t}\n\t/** @internal */\n\tlastTraversedEpoch = GLOBAL_START_EPOCH\n\n\t/** @internal */\n\tprivate lastReactedEpoch = GLOBAL_START_EPOCH\n\n\t/** @internal */\n\tprivate _scheduleCount = 0\n\t/** @internal */\n\tprivate _executeDepth = 0\n\t/** @internal */\n\tprivate _wasScheduledWhileExecuting = false\n\t/** @internal */\n\t__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null = null\n\n\t/**\n\t * The number of times this effect has been scheduled.\n\t * @public\n\t */\n\t// eslint-disable-next-line tldraw/no-setter-getter\n\tget scheduleCount() {\n\t\treturn this._scheduleCount\n\t}\n\n\t/** @internal */\n\treadonly parentSet = new ArraySet<Signal<any, any>>()\n\t/** @internal */\n\treadonly parentEpochs: number[] = []\n\t/** @internal */\n\treadonly parents: Signal<any, any>[] = []\n\t/** @internal */\n\tprivate readonly _scheduleEffect?: (execute: () => void) => void\n\tconstructor(\n\t\tpublic readonly name: string,\n\t\tprivate readonly runEffect: (lastReactedEpoch: number) => Result,\n\t\toptions?: EffectSchedulerOptions\n\t) {\n\t\tthis._scheduleEffect = options?.scheduleEffect\n\t}\n\n\t/** @internal */\n\tmaybeScheduleEffect() {\n\t\t// bail out if we have been cancelled by another effect\n\t\tif (!this._isActivelyListening) return\n\t\t// bail out if no atoms have changed since the last time we ran this effect\n\t\tif (this.lastReactedEpoch === getGlobalEpoch()) return\n\n\t\t// An effect that has run before (or captured parents before throwing) only needs to run\n\t\t// again if one of those parents changed; that includes an effect that captured no parents at\n\t\t// all. An effect that has never run always runs.\n\t\tif (\n\t\t\t(this.lastReactedEpoch !== GLOBAL_START_EPOCH || this.parents.length > 0) &&\n\t\t\t!haveParentsChanged(this)\n\t\t) {\n\t\t\tthis.lastReactedEpoch = getGlobalEpoch()\n\t\t\treturn\n\t\t}\n\t\tthis.scheduleEffect()\n\t}\n\n\t/** @internal */\n\tscheduleEffect() {\n\t\tthis._scheduleCount++\n\t\tif (this._scheduleEffect) {\n\t\t\t// if the effect should be deferred (e.g. until a react render), do so\n\t\t\tthis._scheduleEffect(this.maybeExecute)\n\t\t} else {\n\t\t\t// otherwise execute right now!\n\t\t\tthis.maybeExecute()\n\t\t}\n\t}\n\n\t/** @internal */\n\t// eslint-disable-next-line tldraw/prefer-class-methods\n\treadonly maybeExecute = () => {\n\t\t// bail out if we have been detached before this runs\n\t\tif (!this._isActivelyListening) return\n\t\t// A set inside the running effect flushed synchronously back to this scheduler (only possible\n\t\t// outside the reaction phase, e.g. the first run of `react()`). Running now would open a second\n\t\t// capture frame inside the open one and corrupt `parents`, so re-check after the run instead.\n\t\tif (this._executeDepth > 0) {\n\t\t\tthis._wasScheduledWhileExecuting = true\n\t\t\treturn\n\t\t}\n\t\tthis.execute()\n\t}\n\n\t/**\n\t * Makes this scheduler become 'actively listening' to its parents.\n\t * If it has been executed before it will immediately become eligible to receive 'maybeScheduleEffect' calls.\n\t * If it has not executed before it will need to be manually executed once to become eligible for scheduling, i.e. by calling `EffectScheduler.execute`.\n\t * @public\n\t */\n\tattach() {\n\t\tthis._isActivelyListening = true\n\t\tfor (let i = 0, n = this.parents.length; i < n; i++) {\n\t\t\tconst parent = this.parents[i]\n\t\t\t// a computed parent may have gone stale while nothing listened; see `attach` in helpers.ts\n\t\t\tparent.__unsafe__getWithoutCapture(true)\n\t\t\tattach(parent, this)\n\t\t}\n\t}\n\n\t/**\n\t * Makes this scheduler stop 'actively listening' to its parents.\n\t * It will no longer be eligible to receive 'maybeScheduleEffect' calls until `EffectScheduler.attach` is called again.\n\t * @public\n\t */\n\tdetach() {\n\t\tthis._isActivelyListening = false\n\t\tfor (let i = 0, n = this.parents.length; i < n; i++) {\n\t\t\tdetach(this.parents[i], this)\n\t\t}\n\t}\n\n\t/**\n\t * Executes the effect immediately and returns the result.\n\t * @returns The result of the effect.\n\t * @public\n\t */\n\texecute(): Result {\n\t\t// A direct re-entrant `execute()` from inside the effect is unsupported (both runs share one\n\t\t// `parents`); it is left to run so the outer run can at least finish normally.\n\t\tif (this._executeDepth > 0) return this.executeOnce()\n\t\t// a run that threw may have left this set\n\t\tthis._wasScheduledWhileExecuting = false\n\t\tlet result = this.executeOnce()\n\t\t// If a set inside the run reached this scheduler (see `maybeExecute`), settle it now the way\n\t\t// the reaction phase's cleanup pass would: run again while the parents keep changing.\n\t\tfor (let depth = 0; this._wasScheduledWhileExecuting; depth++) {\n\t\t\tthis._wasScheduledWhileExecuting = false\n\t\t\tif (depth >= 1000) {\n\t\t\t\tthrow new Error('Reaction update depth limit exceeded')\n\t\t\t}\n\t\t\tif (!this._isActivelyListening) break\n\t\t\tif (!haveParentsChanged(this)) {\n\t\t\t\tthis.lastReactedEpoch = getGlobalEpoch()\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tresult = this.executeOnce()\n\t\t}\n\t\treturn result\n\t}\n\n\tprivate executeOnce(): Result {\n\t\t// A counter rather than a flag: a nested `execute()` must not mark the outer run as finished.\n\t\tthis._executeDepth++\n\t\ttry {\n\t\t\tstartCapturingParents(this)\n\t\t\t// Important! We have to make a note of the current epoch before running the effect.\n\t\t\t// We allow atoms to be updated during effects, which increments the global epoch,\n\t\t\t// so if we were to wait until after the effect runs, the this.lastReactedEpoch value might get ahead of itself.\n\t\t\tconst currentEpoch = getGlobalEpoch()\n\t\t\tconst result = this.runEffect(this.lastReactedEpoch)\n\t\t\tthis.lastReactedEpoch = currentEpoch\n\t\t\treturn result\n\t\t} finally {\n\t\t\tstopCapturingParents()\n\t\t\tthis._executeDepth--\n\t\t}\n\t}\n}\n\n/**\n * An EffectScheduler is responsible for executing side effects in response to changes in state.\n *\n * You probably don't need to use this directly unless you're integrating this library with a framework of some kind.\n *\n * Instead, use the {@link react} and {@link reactor} functions.\n *\n * @example\n * ```ts\n * const render = new EffectScheduler('render', drawToCanvas)\n *\n * render.attach()\n * render.execute()\n * ```\n *\n * @public\n */\nexport const EffectScheduler = singleton(\n\t'EffectScheduler',\n\t(): {\n\t\tnew <Result>(\n\t\t\tname: string,\n\t\t\trunEffect: (lastReactedEpoch: number) => Result,\n\t\t\toptions?: EffectSchedulerOptions\n\t\t): EffectScheduler<Result>\n\t} => __EffectScheduler__\n)\n/** @public */\nexport interface EffectScheduler<Result> {\n\t/**\n\t * Whether this scheduler is attached and actively listening to its parents.\n\t * @public\n\t */\n\treadonly isActivelyListening: boolean\n\n\t/** @internal */\n\treadonly lastTraversedEpoch: number\n\n\t/** @public */\n\treadonly name: string\n\n\t/** @internal */\n\t__debug_ancestor_epochs__: Map<Signal<any, any>, number> | null\n\n\t/**\n\t * The number of times this effect has been scheduled.\n\t * @public\n\t */\n\treadonly scheduleCount: number\n\n\t/** @internal */\n\treadonly parentSet: ArraySet<Signal<any, any>>\n\n\t/** @internal */\n\treadonly parentEpochs: number[]\n\n\t/** @internal */\n\treadonly parents: Signal<any, any>[]\n\n\t/** @internal */\n\tmaybeScheduleEffect(): void\n\n\t/** @internal */\n\tscheduleEffect(): void\n\n\t/** @internal */\n\tmaybeExecute(): void\n\n\t/**\n\t * Makes this scheduler become 'actively listening' to its parents.\n\t * If it has been executed before it will immediately become eligible to receive 'maybeScheduleEffect' calls.\n\t * If it has not executed before it will need to be manually executed once to become eligible for scheduling, i.e. by calling `EffectScheduler.execute`.\n\t * @public\n\t */\n\tattach(): void\n\n\t/**\n\t * Makes this scheduler stop 'actively listening' to its parents.\n\t * It will no longer be eligible to receive 'maybeScheduleEffect' calls until `EffectScheduler.attach` is called again.\n\t * @public\n\t */\n\tdetach(): void\n\n\t/**\n\t * Executes the effect immediately and returns the result.\n\t * @returns The result of the effect.\n\t * @public\n\t */\n\texecute(): Result\n}\n\n/**\n * Starts a new effect scheduler, scheduling the effect immediately.\n *\n * Returns a function that can be called to stop the scheduler.\n *\n * @example\n * ```ts\n * const color = atom('color', 'red')\n * const stop = react('set style', () => {\n * divElem.style.color = color.get()\n * })\n * color.set('blue')\n * // divElem.style.color === 'blue'\n * stop()\n * color.set('green')\n * // divElem.style.color === 'blue'\n * ```\n *\n *\n * Also useful in React applications for running effects outside of the render cycle.\n *\n * @example\n * ```ts\n * useEffect(() => react('set style', () => {\n * divRef.current.style.color = color.get()\n * }), [])\n * ```\n *\n * @public\n */\nexport function react(\n\tname: string,\n\tfn: (lastReactedEpoch: number) => any,\n\toptions?: EffectSchedulerOptions\n) {\n\tconst scheduler = new EffectScheduler(name, fn, options)\n\tscheduler.attach()\n\tscheduler.scheduleEffect()\n\treturn () => {\n\t\tscheduler.detach()\n\t}\n}\n\n/**\n * The reactor is a user-friendly interface for starting and stopping an `EffectScheduler`.\n *\n * Calling `.start()` will attach the scheduler and execute the effect immediately the first time it is called.\n *\n * If the reactor is stopped, calling `.start()` will re-attach the scheduler but will only execute the effect if any of its parents have changed since it was stopped.\n *\n * You can create a reactor with {@link reactor}.\n * @public\n */\nexport interface Reactor<T = unknown> {\n\t/**\n\t * The underlying effect scheduler.\n\t * @public\n\t */\n\tscheduler: EffectScheduler<T>\n\t/**\n\t * Start the scheduler. The first time this is called the effect will be scheduled immediately.\n\t *\n\t * If the reactor is stopped, calling this will start the scheduler again but will only execute the effect if any of its parents have changed since it was stopped.\n\t *\n\t * If you need to force re-execution of the effect, pass `{ force: true }`.\n\t * @public\n\t */\n\tstart(options?: { force?: boolean }): void\n\t/**\n\t * Stop the scheduler.\n\t * @public\n\t */\n\tstop(): void\n}\n\n/**\n * Creates a {@link Reactor}, which is a thin wrapper around an `EffectScheduler`.\n *\n * @public\n */\nexport function reactor<Result>(\n\tname: string,\n\tfn: (lastReactedEpoch: number) => Result,\n\toptions?: EffectSchedulerOptions\n): Reactor<Result> {\n\tconst scheduler = new EffectScheduler<Result>(name, fn, options)\n\treturn {\n\t\tscheduler,\n\t\tstart: (options?: { force?: boolean }) => {\n\t\t\tconst force = options?.force ?? false\n\t\t\tscheduler.attach()\n\t\t\tif (force) {\n\t\t\t\tscheduler.scheduleEffect()\n\t\t\t} else {\n\t\t\t\tscheduler.maybeScheduleEffect()\n\t\t\t}\n\t\t},\n\t\tstop: () => {\n\t\t\tscheduler.detach()\n\t\t},\n\t}\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,gBAAgB;AACzB,SAAS,uBAAuB,4BAA4B;AAC5D,SAAS,0BAA0B;AACnC,SAAS,QAAQ,QAAQ,oBAAoB,iBAAiB;AAC9D,SAAS,sBAAsB;AAmC/B,MAAM,oBAA+D;AAAA,EA4CpE,YACiB,MACC,WACjB,SACC;AAHe;AACC;AAGjB,SAAK,kBAAkB,SAAS;AAAA,EACjC;AAAA,EALiB;AAAA,EACC;AAAA,EA7CT,sBAAsB;AAAA;AAAA,EAEvB,uBAAuB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAM/B,IAAI,sBAAsB;AACzB,WAAO,KAAK;AAAA,EACb;AAAA;AAAA,EAEA,qBAAqB;AAAA;AAAA,EAGb,mBAAmB;AAAA;AAAA,EAGnB,iBAAiB;AAAA;AAAA,EAEjB,gBAAgB;AAAA;AAAA,EAEhB,8BAA8B;AAAA;AAAA,EAEtC,4BAAkE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOlE,IAAI,gBAAgB;AACnB,WAAO,KAAK;AAAA,EACb;AAAA;AAAA,EAGS,YAAY,IAAI,SAA2B;AAAA;AAAA,EAE3C,eAAyB,CAAC;AAAA;AAAA,EAE1B,UAA8B,CAAC;AAAA;AAAA,EAEvB;AAAA;AAAA,EAUjB,sBAAsB;AAErB,QAAI,CAAC,KAAK,qBAAsB;AAEhC,QAAI,KAAK,qBAAqB,eAAe,EAAG;AAKhD,SACE,KAAK,qBAAqB,sBAAsB,KAAK,QAAQ,SAAS,MACvE,CAAC,mBAAmB,IAAI,GACvB;AACD,WAAK,mBAAmB,eAAe;AACvC;AAAA,IACD;AACA,SAAK,eAAe;AAAA,EACrB;AAAA;AAAA,EAGA,iBAAiB;AAChB,SAAK;AACL,QAAI,KAAK,iBAAiB;AAEzB,WAAK,gBAAgB,KAAK,YAAY;AAAA,IACvC,OAAO;AAEN,WAAK,aAAa;AAAA,IACnB;AAAA,EACD;AAAA;AAAA;AAAA,EAIS,eAAe,MAAM;AAE7B,QAAI,CAAC,KAAK,qBAAsB;AAIhC,QAAI,KAAK,gBAAgB,GAAG;AAC3B,WAAK,8BAA8B;AACnC;AAAA,IACD;AACA,SAAK,QAAQ;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,SAAS;AACR,SAAK,uBAAuB;AAC5B,aAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,QAAQ,IAAI,GAAG,KAAK;AACpD,YAAM,SAAS,KAAK,QAAQ,CAAC;AAE7B,aAAO,4BAA4B,IAAI;AACvC,aAAO,QAAQ,IAAI;AAAA,IACpB;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,SAAS;AACR,SAAK,uBAAuB;AAC5B,aAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,QAAQ,IAAI,GAAG,KAAK;AACpD,aAAO,KAAK,QAAQ,CAAC,GAAG,IAAI;AAAA,IAC7B;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,UAAkB;AAGjB,QAAI,KAAK,gBAAgB,EAAG,QAAO,KAAK,YAAY;AAEpD,SAAK,8BAA8B;AACnC,QAAI,SAAS,KAAK,YAAY;AAG9B,aAAS,QAAQ,GAAG,KAAK,6BAA6B,SAAS;AAC9D,WAAK,8BAA8B;AACnC,UAAI,SAAS,KAAM;AAClB,cAAM,IAAI,MAAM,sCAAsC;AAAA,MACvD;AACA,UAAI,CAAC,KAAK,qBAAsB;AAChC,UAAI,CAAC,mBAAmB,IAAI,GAAG;AAC9B,aAAK,mBAAmB,eAAe;AACvC;AAAA,MACD;AACA,eAAS,KAAK,YAAY;AAAA,IAC3B;AACA,WAAO;AAAA,EACR;AAAA,EAEQ,cAAsB;AAE7B,SAAK;AACL,QAAI;AACH,4BAAsB,IAAI;AAI1B,YAAM,eAAe,eAAe;AACpC,YAAM,SAAS,KAAK,UAAU,KAAK,gBAAgB;AACnD,WAAK,mBAAmB;AACxB,aAAO;AAAA,IACR,UAAE;AACD,2BAAqB;AACrB,WAAK;AAAA,IACN;AAAA,EACD;AACD;AAmBO,MAAM,kBAAkB;AAAA,EAC9B;AAAA,EACA,MAMK;AACN;AA+FO,SAAS,MACf,MACA,IACA,SACC;AACD,QAAM,YAAY,IAAI,gBAAgB,MAAM,IAAI,OAAO;AACvD,YAAU,OAAO;AACjB,YAAU,eAAe;AACzB,SAAO,MAAM;AACZ,cAAU,OAAO;AAAA,EAClB;AACD;AAuCO,SAAS,QACf,MACA,IACA,SACkB;AAClB,QAAM,YAAY,IAAI,gBAAwB,MAAM,IAAI,OAAO;AAC/D,SAAO;AAAA,IACN;AAAA,IACA,OAAO,CAACA,aAAkC;AACzC,YAAM,QAAQA,UAAS,SAAS;AAChC,gBAAU,OAAO;AACjB,UAAI,OAAO;AACV,kBAAU,eAAe;AAAA,MAC1B,OAAO;AACN,kBAAU,oBAAoB;AAAA,MAC/B;AAAA,IACD;AAAA,IACA,MAAM,MAAM;AACX,gBAAU,OAAO;AAAA,IAClB;AAAA,EACD;AACD;",
|
|
6
6
|
"names": ["options"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/state",
|
|
3
3
|
"description": "tldraw infinite canvas SDK (state).",
|
|
4
|
-
"version": "5.5.0-canary.
|
|
4
|
+
"version": "5.5.0-canary.f35842e3ba7e",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw Inc.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"vitest": "^4.1.7"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@tldraw/utils": "5.5.0-canary.
|
|
58
|
+
"@tldraw/utils": "5.5.0-canary.f35842e3ba7e"
|
|
59
59
|
},
|
|
60
60
|
"typedoc": {
|
|
61
61
|
"readmeFile": "none",
|
|
@@ -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
|
+
})
|