wana
Version:
Easy observable state for React
181 lines (163 loc) • 4.71 kB
text/typescript
import { is } from '@alloc/is'
import { isDev } from '@alloc/is-dev'
import { batch } from './batch'
import { noop, rethrowError } from './common'
import { addDebugAction, getDebug } from './debug'
import { globals } from './globals'
import { noto } from './noto'
import { Change, ObservedSlot, Observer } from './observable'
import { $O } from './symbols'
/** Run an effect when its tracked values change. */
export function auto(effect: () => void, config?: AutoConfig) {
const auto = new Auto(config)
auto.run(effect)
return auto
}
export interface AutoConfig {
/** When true, react to changes immediately. By default, changes are delayed until the next microtask loop */
sync?: boolean
/** By default, rerun the last effect (after any delay) */
onDirty?: Auto['onDirty']
/** By default, errors are rethrown */
onError?: Auto['onError']
/** Run an effect after an `observer` is activated */
onCommit?: Auto['onCommit']
/** Run an effect after being disposed */
onDispose?: Auto['onDispose']
}
export class Auto {
sync: boolean
dirty = true
nonce = 0
observer: AutoObserver | null = null
onDirty: (this: Auto) => void
onError: (this: Auto, error: Error) => void
onCommit: (observer: AutoObserver) => void
onDispose: () => void
constructor(config: AutoConfig = {}) {
this.sync = !!config.sync
this.onDirty = config.onDirty || this._onDirty
this.onError = config.onError || rethrowError
this.onCommit = config.onCommit || noop
this.onDispose = config.onDispose || noop
}
run<T extends Function>(compute: T) {
const observer = this.start(compute)
try {
const result = compute()
this.stop().commit(observer)
this.nonce = observer.nonce
return result
} catch (error) {
this.stop().onError(error)
}
}
/** Rerun the last effect and commit its observer */
rerun() {
const { observer } = this
return observer && noto(() => this.run(observer.effect))
}
/**
* Replace the current `observer`.
*
* Pass a nonce to bail out if observed values have since changed.
*/
commit(observer: AutoObserver, nonce?: number) {
if (this.observer) {
this.observer.dispose()
}
// Stop observing when changes occur between run and commit.
if (nonce && nonce != observer.nonce) {
this.observer = null
return false
}
// Attach to the observed values.
observer.onChange = this._onChange.bind(this)
observer.observed.forEach(observable => observable.add(observer))
this.observer = observer
this.onCommit(observer)
return true
}
dispose() {
if (this.observer) {
// Stop observing.
this.observer.dispose()
// Cancel batched effects.
this.observer = null
// Run any side effects.
this.onDispose()
}
}
/**
* @internal
* Create an observer and start observing.
*
* The given `effect` is used by `rerun` calls made after
* the new observer is committed.
*/
start(effect: Function) {
if (globals.observe) {
throw Error('Nested tracking is forbidden')
}
const observer = new AutoObserver(effect)
globals.auto = this
globals.observe = (target, key) => {
observer.observed.add(target[$O]!.get(key))
}
return observer
}
/**
* @internal
* Stop observing and reset the `dirty` flag.
*/
stop() {
if (globals.auto == this) {
globals.auto = globals.observe = null
}
this.dirty = false
return this
}
/**
* @internal
* Replace the current `observer` when appropriate.
*/
clear() {
if (!this.dirty) {
this.dirty = true
this.onDirty()
}
}
protected _onChange(change: Change) {
if (isDev && getDebug(this)) {
addDebugAction(this, change)
}
// Derived values used by sync reactions are forced to rerun immediately
// so the reactions can know whether the value actually changed.
if (this.sync && change.op == 'clear' && is.function(change.target)) {
change.target()
}
this.clear()
}
protected _onDirty() {
const { observer, nonce } = this
const maybeRerun = () =>
// If the current nonce is not greater, no dependencies have changed.
observer!.nonce > nonce //
? this.rerun()
: (this.dirty = false)
if (this.sync) {
maybeRerun()
} else {
batch.run(
// The observer is replaced when the effect is rerun or when disposed.
() => observer == this.observer && maybeRerun()
)
}
}
}
export class AutoObserver extends Observer {
observed = new Set<ObservedSlot>()
constructor(readonly effect: Function) {
super()
}
}