diff --git a/package.json b/package.json index 8f1f8c9..5baacee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "alien-signals", - "version": "1.0.1", + "version": "1.1.0-alpha.2", "sideEffects": false, "license": "MIT", "description": "The lightest signal library.", diff --git a/src/index.ts b/src/index.ts index 0dd706a..7410968 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * as pullmodel from './pullmodel/index.js'; export * from './system.js'; import { createReactiveSystem, Dependency, Subscriber, SubscriberFlags } from './system.js'; diff --git a/src/pullmodel/index.ts b/src/pullmodel/index.ts new file mode 100644 index 0000000..5f38e6b --- /dev/null +++ b/src/pullmodel/index.ts @@ -0,0 +1,257 @@ +export * from './system.js'; + +import { Dependency, Subscriber, SubscriberFlags } from '../system.js'; +import { createReactiveSystem } from './system.js'; + +interface EffectScope extends Subscriber { + isScope: true; +} + +interface Effect extends Subscriber, Dependency { + fn(): void; +} + +interface Computed extends Signal, Subscriber { + version: number; + globalVersion: number; + getter: (cachedValue?: T) => T; +} + +interface Signal extends Dependency { + version: number; + currentValue: T; +} + +interface WriteableSignal { + (): T; + (value: T): void; +}; + +const { + link, + propagate, + updateDirtyFlag, + startTracking, + endTracking, + processEffectNotifications, + processComputedUpdate, + processPendingInnerEffects, + checkDirty, +} = createReactiveSystem({ + shouldCheckDirty(computed: Computed): boolean { + if (computed.globalVersion !== globalVersion) { + computed.globalVersion = globalVersion; + return true; + } + return false; + }, + updateComputed, + notifyEffect(e: Effect | EffectScope) { + if ('isScope' in e) { + return notifyEffectScope(e); + } else { + return notifyEffect(e); + } + }, + onWatched() { }, + onUnwatched() { }, +}); +const pauseStack: (Subscriber | undefined)[] = []; + +let globalVersion = 0; +let batchDepth = 0; +let activeSub: Subscriber | undefined; +let activeScope: EffectScope | undefined; + +//#region Public functions +export function startBatch() { + ++batchDepth; +} + +export function endBatch() { + if (!--batchDepth) { + processEffectNotifications(); + } +} + +export function pauseTracking() { + pauseStack.push(activeSub); + activeSub = undefined; +} + +export function resumeTracking() { + activeSub = pauseStack.pop(); +} + +export function signal(): WriteableSignal; +export function signal(oldValue: T): WriteableSignal; +export function signal(oldValue?: T): WriteableSignal { + return signalGetterSetter.bind({ + currentValue: oldValue, + version: 0, + subs: undefined, + subsTail: undefined, + }) as WriteableSignal; +} + +export function computed(getter: (cachedValue?: T) => T): () => T { + return computedGetter.bind({ + globalVersion: -1, + currentValue: undefined, + version: 0, + subs: undefined, + subsTail: undefined, + deps: undefined, + depsTail: undefined, + flags: SubscriberFlags.Computed | SubscriberFlags.Dirty, + getter: getter as (cachedValue?: unknown) => unknown, + }) as () => T; +} + +export function effect(fn: () => T): () => void { + const e: Effect = { + fn, + subs: undefined, + subsTail: undefined, + deps: undefined, + depsTail: undefined, + flags: SubscriberFlags.Effect, + }; + if (activeSub !== undefined) { + link(e, activeSub); + } else if (activeScope !== undefined) { + link(e, activeScope); + } + runEffect(e); + return effectStop.bind(e); +} + +export function effectScope(fn: () => T): () => void { + const e: EffectScope = { + deps: undefined, + depsTail: undefined, + flags: SubscriberFlags.Effect, + isScope: true, + }; + runEffectScope(e, fn); + return effectStop.bind(e); +} +//#endregion + +//#region Internal functions +function updateComputed(computed: Computed): boolean { + const prevSub = activeSub; + activeSub = computed; + startTracking(computed); + try { + const oldValue = computed.currentValue; + const newValue = computed.getter(oldValue); + if (oldValue !== newValue) { + computed.version++; + computed.currentValue = newValue; + return true; + } + return false; + } finally { + activeSub = prevSub; + endTracking(computed); + } +} + +function runEffect(e: Effect): void { + const prevSub = activeSub; + activeSub = e; + startTracking(e); + try { + e.fn(); + } finally { + activeSub = prevSub; + endTracking(e); + } +} + +function runEffectScope(e: EffectScope, fn: () => void): void { + const prevSub = activeScope; + activeScope = e; + startTracking(e); + try { + fn(); + } finally { + activeScope = prevSub; + endTracking(e); + } +} + +function notifyEffect(e: Effect): boolean { + const flags = e.flags; + if ( + flags & SubscriberFlags.Dirty + || (flags & SubscriberFlags.PendingComputed && updateDirtyFlag(e, flags)) + ) { + runEffect(e); + } else { + processPendingInnerEffects(e, e.flags); + } + return true; +} + +function notifyEffectScope(e: EffectScope): boolean { + const flags = e.flags; + if (flags & SubscriberFlags.PendingEffect) { + processPendingInnerEffects(e, e.flags); + return true; + } + return false; +} +//#endregion + +//#region Bound functions +function computedGetter(this: Computed): T { + const flags = this.flags; + if (flags & SubscriberFlags.Dirty) { + processComputedUpdate(this, flags); + } else if (this.subs === undefined) { + if (this.globalVersion !== globalVersion) { + this.globalVersion = globalVersion; + const deps = this.deps; + if (deps !== undefined && checkDirty(deps)) { + updateComputed(this); + } + } + } else if (flags & SubscriberFlags.PendingComputed) { + processComputedUpdate(this, flags); + } + if (activeSub !== undefined) { + link(this, activeSub); + } else if (activeScope !== undefined) { + link(this, activeScope); + } + return this.currentValue!; +} + +function signalGetterSetter(this: Signal, ...value: [T]): T | void { + if (value.length) { + if (this.currentValue !== (this.currentValue = value[0])) { + globalVersion++; + this.version++; + const subs = this.subs; + if (subs !== undefined) { + propagate(subs); + if (!batchDepth) { + processEffectNotifications(); + } + } + } + } else { + if (activeSub !== undefined) { + link(this, activeSub); + } + return this.currentValue; + } +} + +function effectStop(this: Subscriber): void { + startTracking(this); + endTracking(this); +} +//#endregion diff --git a/src/pullmodel/system.ts b/src/pullmodel/system.ts new file mode 100644 index 0000000..261f143 --- /dev/null +++ b/src/pullmodel/system.ts @@ -0,0 +1,291 @@ +import { Dependency, Link, Subscriber, SubscriberFlags, createReactiveSystem as _createReactiveSystem } from '../system'; + +export function createReactiveSystem({ + shouldCheckDirty, + updateComputed, + notifyEffect, + onWatched, + onUnwatched, +}: { + shouldCheckDirty(computed: Dependency & Subscriber): boolean; + onWatched(dep: Dependency): void; + onUnwatched(dep: Dependency): void; +} & Parameters[0]) { + const system = _createReactiveSystem({ + updateComputed, + notifyEffect, + checkDirty(link) { + let stack = 0; + let dirty: boolean; + + top: do { + dirty = false; + const dep = link.dep; + if (link.version !== dep.version) { + dirty = true; + } else if ('flags' in dep) { + const depFlags = dep.flags; + if (depFlags & SubscriberFlags.Computed) { + if (depFlags & SubscriberFlags.Dirty) { + if (updateComputed(dep)) { + const subs = dep.subs!; + if (subs.nextSub !== undefined) { + shallowPropagate(subs); + } + dirty = true; + } + } else if (dep.subs === undefined) { + if (shouldCheckDirty(dep)) { + dep.subs = link; + link = dep.deps!; + ++stack; + continue; + } + } else if (depFlags & SubscriberFlags.PendingComputed) { + const depSubs = dep.subs!; + if (depSubs.nextSub !== undefined) { + depSubs.prevSub = link; + } + link = dep.deps!; + ++stack; + continue; + } + } + } + + if (!dirty && link.nextDep !== undefined) { + link = link.nextDep; + continue; + } + + if (stack) { + let sub = link.sub as Dependency & Subscriber; + do { + --stack; + const subSubs = sub.subs!; + + if (sub.subsTail !== undefined) { + if (dirty) { + if (updateComputed(sub)) { + if ((link = subSubs.prevSub!) !== undefined) { + subSubs.prevSub = undefined; + shallowPropagate(sub.subs!); + sub = link.sub as Dependency & Subscriber; + } else { + sub = subSubs.sub as Dependency & Subscriber; + } + continue; + } + } else { + sub.flags &= ~SubscriberFlags.PendingComputed; + } + + if ((link = subSubs.prevSub!) !== undefined) { + subSubs.prevSub = undefined; + if (link.nextDep !== undefined) { + link = link.nextDep; + continue top; + } + sub = link.sub as Dependency & Subscriber; + } else { + if ((link = subSubs.nextDep!) !== undefined) { + continue top; + } + sub = subSubs.sub as Dependency & Subscriber; + } + } else { + sub.subs = undefined; + if (dirty) { + if (updateComputed(sub)) { + link = subSubs; + sub = subSubs.sub as Dependency & Subscriber; + continue; + } + } + link = subSubs; + if (link.nextDep !== undefined) { + link = link.nextDep; + continue top; + } + sub = subSubs.sub as Dependency & Subscriber; + } + + dirty = false; + } while (stack); + } + + return dirty; + } while (true); + }, + }); + const { shallowPropagate, isValidLink } = system; + + return { + ...system, + link(dep: Dependency, sub: Subscriber) { + const currentDep = sub.depsTail; + if ( + currentDep !== undefined + && currentDep.dep === dep + ) { + return; + } + const nextDep = currentDep !== undefined + ? currentDep.nextDep + : sub.deps; + if ( + nextDep !== undefined + && nextDep.dep === dep + ) { + nextDep.version = dep.version; + sub.depsTail = nextDep; + return; + } + const depLastSub = dep.subsTail; + if ( + depLastSub !== undefined + && depLastSub.sub === sub + && isValidLink(depLastSub, sub) + ) { + return; + } + const newLink: Link = { + version: dep.version, + dep, + sub, + nextDep, + prevSub: undefined, + nextSub: undefined, + }; + if (currentDep === undefined) { + sub.deps = newLink; + } else { + currentDep.nextDep = newLink; + } + sub.depsTail = newLink; + if ( + sub.flags & SubscriberFlags.Effect + || ( + sub.flags & SubscriberFlags.Computed + && (sub as Subscriber & Dependency).subs !== undefined + ) + ) { + if (dep.subs === undefined) { + dep.subs = newLink; + } else { + const oldTail = dep.subsTail!; + newLink.prevSub = oldTail; + oldTail.nextSub = newLink; + } + dep.subsTail = newLink; + if (dep.subs === dep.subsTail) { + if ('flags' in dep) { + if ((dep as Dependency & Subscriber).flags & SubscriberFlags.Computed) { + onWatch(dep as Dependency & Subscriber); + } + } else { + onWatched(dep); + } + } + } + }, + endTracking(sub: Subscriber): void { + const flags = sub.flags; + if ( + flags & SubscriberFlags.Effect + || ( + flags & SubscriberFlags.Computed + && (sub as Subscriber & Dependency).subs !== undefined + ) + ) { + const depsTail = sub.depsTail; + if (depsTail !== undefined) { + const nextDep = depsTail.nextDep; + if (nextDep !== undefined) { + onUnwatch(nextDep); + depsTail.nextDep = undefined; + } + } else if (sub.deps !== undefined) { + onUnwatch(sub.deps); + sub.deps = undefined; + } + } else { + const depsTail = sub.depsTail; + if (depsTail !== undefined) { + const nextDep = depsTail.nextDep; + if (nextDep !== undefined) { + depsTail.nextDep = undefined; + } + } else if (sub.deps !== undefined) { + sub.deps = undefined; + } + } + sub.flags &= ~SubscriberFlags.Tracking; + }, + }; + + function onUnwatch(link: Link): void { + do { + const dep = link.dep; + const nextSub = link.nextSub; + const prevSub = link.prevSub; + + link.prevSub = undefined; + link.nextSub = undefined; + + if (nextSub !== undefined) { + nextSub.prevSub = prevSub; + } else { + dep.subsTail = prevSub; + } + + if (prevSub !== undefined) { + prevSub.nextSub = nextSub; + } else { + dep.subs = nextSub; + } + + if (dep.subs === undefined) { + if ('flags' in dep) { + const depLink = dep.deps; + if (depLink !== undefined) { + onUnwatch(depLink); + } + if ((dep as Dependency & Subscriber).flags & SubscriberFlags.Computed) { + onUnwatched(dep); + } + } else { + onUnwatched(dep); + } + } + link = link.nextDep!; + } while (link !== undefined); + } + + function onWatch(sub: Dependency & Subscriber): void { + let link = sub.deps; + while (link !== undefined) { + const dep = link.dep as Dependency | Dependency & Subscriber; + const unwatched = dep.subs === undefined; + if (dep.subs === undefined) { + dep.subs = link; + } else { + const oldTail = dep.subsTail!; + link.prevSub = oldTail; + oldTail.nextSub = link; + } + dep.subsTail = link; + if (unwatched) { + if ('flags' in dep) { + if ((dep as Dependency & Subscriber).flags & SubscriberFlags.Computed) { + onWatch(dep); + } + } else { + onWatched(dep); + } + } + link = link.nextDep; + } + onWatched(sub); + } +} diff --git a/src/system.ts b/src/system.ts index 4562666..05227fd 100644 --- a/src/system.ts +++ b/src/system.ts @@ -1,4 +1,5 @@ export interface Dependency { + version?: number; subs: Link | undefined; subsTail: Link | undefined; } @@ -10,6 +11,7 @@ export interface Subscriber { } export interface Link { + version?: number; dep: Dependency | (Dependency & Subscriber); sub: Subscriber | (Dependency & Subscriber); // Reused to link the previous stack in updateDirtyFlag @@ -35,6 +37,7 @@ export const enum SubscriberFlags { export function createReactiveSystem({ updateComputed, notifyEffect, + checkDirty, }: { /** * Updates the computed subscriber's value and returns whether it changed. @@ -59,10 +62,13 @@ export function createReactiveSystem({ * until the method eventually returns `true`. */ notifyEffect(effect: Subscriber): boolean; + checkDirty?(link: Link): boolean; }) { let queuedEffects: Subscriber | undefined; let queuedEffectsTail: Subscriber | undefined; + checkDirty ??= _checkDirty; + return { /** * Links a given dependency and subscriber if they are not already linked. @@ -339,6 +345,10 @@ export function createReactiveSystem({ } } }, + + checkDirty, + shallowPropagate, + isValidLink, }; /** @@ -392,7 +402,7 @@ export function createReactiveSystem({ * @param link - The starting link representing a sequence of pending computeds. * @returns `true` if a computed was updated, otherwise `false`. */ - function checkDirty(link: Link): boolean { + function _checkDirty(link: Link): boolean { let stack = 0; let dirty: boolean;