UNPKG

@tldraw/state

Version:

tldraw infinite canvas SDK (state).

365 lines (334 loc) • 10.1 kB
import { ArraySet } from './ArraySet' import { startCapturingParents, stopCapturingParents } from './capture' import { GLOBAL_START_EPOCH } from './constants' import { attach, detach, haveParentsChanged, singleton } from './helpers' import { getGlobalEpoch } from './transactions' import { Signal } from './types' /** @public */ export interface EffectSchedulerOptions { /** * scheduleEffect is a function that will be called when the effect is scheduled. * * It can be used to defer running effects until a later time, for example to batch them together with requestAnimationFrame. * * * @example * ```ts * let isRafScheduled = false * const scheduledEffects: Array<() => void> = [] * const scheduleEffect = (runEffect: () => void) => { * scheduledEffects.push(runEffect) * if (!isRafScheduled) { * isRafScheduled = true * requestAnimationFrame(() => { * isRafScheduled = false * scheduledEffects.forEach((runEffect) => runEffect()) * scheduledEffects.length = 0 * }) * } * } * const stop = react('set page title', () => { * document.title = doc.title, * }, scheduleEffect) * ``` * * @param execute - A function that will execute the effect. * @returns void */ // eslint-disable-next-line @typescript-eslint/method-signature-style scheduleEffect?: (execute: () => void) => void } class __EffectScheduler__<Result> implements EffectScheduler<Result> { /** @internal */ private _isActivelyListening = false /** * Whether this scheduler is attached and actively listening to its parents. * @public */ // eslint-disable-next-line no-restricted-syntax get isActivelyListening() { return this._isActivelyListening } /** @internal */ lastTraversedEpoch = GLOBAL_START_EPOCH /** @internal */ private lastReactedEpoch = GLOBAL_START_EPOCH /** @internal */ private _scheduleCount = 0 /** @internal */ __debug_ancestor_epochs__: Map<Signal<any, any>, number> | null = null /** * The number of times this effect has been scheduled. * @public */ // eslint-disable-next-line no-restricted-syntax get scheduleCount() { return this._scheduleCount } /** @internal */ readonly parentSet = new ArraySet<Signal<any, any>>() /** @internal */ readonly parentEpochs: number[] = [] /** @internal */ readonly parents: Signal<any, any>[] = [] /** @internal */ private readonly _scheduleEffect?: (execute: () => void) => void constructor( public readonly name: string, private readonly runEffect: (lastReactedEpoch: number) => Result, options?: EffectSchedulerOptions ) { this._scheduleEffect = options?.scheduleEffect } /** @internal */ maybeScheduleEffect() { // bail out if we have been cancelled by another effect if (!this._isActivelyListening) return // bail out if no atoms have changed since the last time we ran this effect if (this.lastReactedEpoch === getGlobalEpoch()) return // bail out if we have parents and they have not changed since last time if (this.parents.length && !haveParentsChanged(this)) { this.lastReactedEpoch = getGlobalEpoch() return } // if we don't have parents it's probably the first time this is running. this.scheduleEffect() } /** @internal */ scheduleEffect() { this._scheduleCount++ if (this._scheduleEffect) { // if the effect should be deferred (e.g. until a react render), do so this._scheduleEffect(this.maybeExecute) } else { // otherwise execute right now! this.execute() } } /** @internal */ // eslint-disable-next-line local/prefer-class-methods readonly maybeExecute = () => { // bail out if we have been detached before this runs if (!this._isActivelyListening) return this.execute() } /** * Makes this scheduler become 'actively listening' to its parents. * If it has been executed before it will immediately become eligible to receive 'maybeScheduleEffect' calls. * 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`. * @public */ attach() { this._isActivelyListening = true for (let i = 0, n = this.parents.length; i < n; i++) { attach(this.parents[i], this) } } /** * Makes this scheduler stop 'actively listening' to its parents. * It will no longer be eligible to receive 'maybeScheduleEffect' calls until `EffectScheduler.attach` is called again. * @public */ detach() { this._isActivelyListening = false for (let i = 0, n = this.parents.length; i < n; i++) { detach(this.parents[i], this) } } /** * Executes the effect immediately and returns the result. * @returns The result of the effect. * @public */ execute(): Result { try { startCapturingParents(this) // Important! We have to make a note of the current epoch before running the effect. // We allow atoms to be updated during effects, which increments the global epoch, // so if we were to wait until after the effect runs, the this.lastReactedEpoch value might get ahead of itself. const currentEpoch = getGlobalEpoch() const result = this.runEffect(this.lastReactedEpoch) this.lastReactedEpoch = currentEpoch return result } finally { stopCapturingParents() } } } /** * An EffectScheduler is responsible for executing side effects in response to changes in state. * * You probably don't need to use this directly unless you're integrating this library with a framework of some kind. * * Instead, use the {@link react} and {@link reactor} functions. * * @example * ```ts * const render = new EffectScheduler('render', drawToCanvas) * * render.attach() * render.execute() * ``` * * @public */ export const EffectScheduler = singleton( 'EffectScheduler', (): { new <Result>( name: string, runEffect: (lastReactedEpoch: number) => Result, options?: EffectSchedulerOptions ): EffectScheduler<Result> } => __EffectScheduler__ ) /** @public */ export interface EffectScheduler<Result> { /** * Whether this scheduler is attached and actively listening to its parents. * @public */ readonly isActivelyListening: boolean /** @internal */ readonly lastTraversedEpoch: number /** @public */ readonly name: string /** @internal */ __debug_ancestor_epochs__: Map<Signal<any, any>, number> | null /** * The number of times this effect has been scheduled. * @public */ readonly scheduleCount: number /** @internal */ readonly parentSet: ArraySet<Signal<any, any>> /** @internal */ readonly parentEpochs: number[] /** @internal */ readonly parents: Signal<any, any>[] /** @internal */ maybeScheduleEffect(): void /** @internal */ scheduleEffect(): void /** @internal */ maybeExecute(): void /** * Makes this scheduler become 'actively listening' to its parents. * If it has been executed before it will immediately become eligible to receive 'maybeScheduleEffect' calls. * 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`. * @public */ attach(): void /** * Makes this scheduler stop 'actively listening' to its parents. * It will no longer be eligible to receive 'maybeScheduleEffect' calls until `EffectScheduler.attach` is called again. * @public */ detach(): void /** * Executes the effect immediately and returns the result. * @returns The result of the effect. * @public */ execute(): Result } /** * Starts a new effect scheduler, scheduling the effect immediately. * * Returns a function that can be called to stop the scheduler. * * @example * ```ts * const color = atom('color', 'red') * const stop = react('set style', () => { * divElem.style.color = color.get() * }) * color.set('blue') * // divElem.style.color === 'blue' * stop() * color.set('green') * // divElem.style.color === 'blue' * ``` * * * Also useful in React applications for running effects outside of the render cycle. * * @example * ```ts * useEffect(() => react('set style', () => { * divRef.current.style.color = color.get() * }), []) * ``` * * @public */ export function react( name: string, fn: (lastReactedEpoch: number) => any, options?: EffectSchedulerOptions ) { const scheduler = new EffectScheduler(name, fn, options) scheduler.attach() scheduler.scheduleEffect() return () => { scheduler.detach() } } /** * The reactor is a user-friendly interface for starting and stopping an `EffectScheduler`. * * Calling `.start()` will attach the scheduler and execute the effect immediately the first time it is called. * * 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. * * You can create a reactor with {@link reactor}. * @public */ export interface Reactor<T = unknown> { /** * The underlying effect scheduler. * @public */ scheduler: EffectScheduler<T> /** * Start the scheduler. The first time this is called the effect will be scheduled immediately. * * 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. * * If you need to force re-execution of the effect, pass `{ force: true }`. * @public */ start(options?: { force?: boolean }): void /** * Stop the scheduler. * @public */ stop(): void } /** * Creates a {@link Reactor}, which is a thin wrapper around an `EffectScheduler`. * * @public */ export function reactor<Result>( name: string, fn: (lastReactedEpoch: number) => Result, options?: EffectSchedulerOptions ): Reactor<Result> { const scheduler = new EffectScheduler<Result>(name, fn, options) return { scheduler, start: (options?: { force?: boolean }) => { const force = options?.force ?? false scheduler.attach() if (force) { scheduler.scheduleEffect() } else { scheduler.maybeScheduleEffect() } }, stop: () => { scheduler.detach() }, } }