From be52f34158e9e110be15f5e6c211a5bab6512055 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 28 Feb 2026 11:35:07 +1000 Subject: [PATCH 01/83] Starting signals implementation --- packages/event-reduce/src/signals/signals.ts | 317 +++++++++++++++++++ packages/event-reduce/src/utils.ts | 4 +- tsconfig.base.json | 4 +- 3 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 packages/event-reduce/src/signals/signals.ts diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts new file mode 100644 index 0000000..f5f901e --- /dev/null +++ b/packages/event-reduce/src/signals/signals.ts @@ -0,0 +1,317 @@ +import { dispose, identity, nameOfCallback } from "../utils" + +class SourceTracker { + private readonly _previous: SourceTracker | null + readonly accessed = new Set>() + + constructor(private readonly _observer: Observer) { + this._previous = globalTracker + globalTracker = this + } + + sourceAccessed(source: Source) { + let circularDependency = this.findCircularDependency(source) + if (circularDependency) + throw new CircularDependencyError([...circularDependency, source]) + + this.accessed.add(source) + } + + private findCircularDependency(source: Source): Source[] | null { + if (this._observer == source as any as Observer) + return [source] + let circularDependency = this._previous?.findCircularDependency(source) + return circularDependency + ? [...circularDependency, this._observer as any as Source] + : null + } + + [dispose]() { + globalTracker = this._previous + } +} + +let globalTracker: SourceTracker | null = null +let globalVersion = 0 +let reactingTo: Source | null = null + +interface Source { + readonly displayName: string + readonly lastUpdated: number + subscribe(observer: Observer): void + unsubscribe(observer: Observer): void +} + +interface Observer { + readonly displayName: string + readonly sources: ReadonlySet> + sourceUpdated(source: Source, value: S): void +} + +abstract class SourceBase implements Source { + protected readonly _observers = new Set>() + protected _value!: T + lastUpdated = -1 + + constructor( + public readonly displayName: string, + private _skipUpdate = Object.is + ) { } + + protected get value() { + globalTracker?.sourceAccessed(this) + return this._value + } + + protected setValue(value: T, newVersion = false) { + if (newVersion && reactingTo) + throw new SideEffectDuringReactionError(reactingTo, this) + + if (!this._skipUpdate(this._value, value)) { + this._value = value + this.lastUpdated = newVersion ? ++globalVersion : globalVersion + + if (newVersion) + reactingTo = this + + try { + for (let observer of this._observers) + observer.sourceUpdated(this, this._value) + } finally { + if (newVersion) + reactingTo = null + } + } + } + + subscribe(observer: Observer) { this._observers.add(observer) } + unsubscribe(observer: Observer) { this._observers.delete(observer) } +} + +class Atom extends SourceBase { + constructor( + displayName: string, + initialValue: T, + _isEqual = Object.is + ) { + super(displayName, _isEqual) + this.setValue(initialValue) + } + + override get value() { return super.value } + override setValue(value: T) { super.setValue(value, true) } +} + +type Reducer = (current: Result, next: Source) => Result + +abstract class SourceObserverBase extends SourceBase implements Observer { + abstract readonly sources: ReadonlySet> + + override subscribe(observer: Observer): void { + if (!this._observers.size) + this.attach() + + super.subscribe(observer) + } + + override unsubscribe(observer: Observer): void { + super.unsubscribe(observer) + + if (!this._observers.size) + this.detach() + } + + protected attach() { + for (let source of this.sources) + source.subscribe(this) + } + + protected detach() { + for (let source of this.sources) + source.unsubscribe(this) + } + + abstract sourceUpdated(source: Source, value: S): void +} + +class Reduction extends SourceObserverBase { + sources = new Set>() + private _reducers = new Map, (current: T, next: any) => T>() + + on(source: Source, reduce: Reducer) { + this.sources.add(source) + this._reducers.set(source, reduce) + if (this._observers.size) + source.subscribe(this) + } + + override get value() { return super.value } + + sourceUpdated(source: Source, value: S) { + let reduce = this._reducers.get(source) + if (reduce) { + let newValue = reduce(this._value, value) + this.setValue(newValue) + } + } +} + +class Effect implements Observer { + readonly sources = new Set>() + sourceLastUpdated = -1 + lastRun = -1 + + constructor( + public readonly displayName: string, + private _effect: () => T, + private _onSourceUpdatedSinceLastRun: () => void + ) { } + + attach() { + for (let source of this.sources) + source.subscribe(this) + } + + detach() { + for (let source of this.sources) + source.unsubscribe(this) + } + + sourceUpdated(source: Source, value: S): void { + if (source.lastUpdated > this.lastRun) { + this.sourceLastUpdated = source.lastUpdated + this._onSourceUpdatedSinceLastRun() + } + } + + run() { + this.lastRun = globalVersion + + let result: T; + let tracker = new SourceTracker(this) + + try { + result = this._effect() + } finally { + tracker[dispose]() + } + + for (let oldSource of this.sources.difference(tracker.accessed)) { + this.sources.delete(oldSource) + oldSource.unsubscribe(this) + } + + for (let newSource of tracker.accessed.difference(this.sources)) { + this.sources.add(newSource) + newSource.subscribe(this) + } + + return result; + } +} + +class Reaction extends Effect { + constructor( + displayName: string, + reaction: () => void, + private _schedule?: (run: () => void) => void + ) { + super(displayName, reaction, () => { + let wasReactingTo = reactingTo + if (this._schedule) { + this._schedule(() => { + reactingTo = wasReactingTo + try { + this.run() + } finally { + reactingTo = null + } + }) + } else { + this.run() + } + }) + } +} + +class Derivation extends SourceObserverBase { + private _derive: Effect + + constructor( + displayName: string, + derive: () => T, + skipUpdate = Object.is + ) { + super(displayName, skipUpdate) + this._derive = new Effect(displayName, derive, () => { + if (this._observers.size) + this.update() + }) + } + + get sources() { return this._derive.sources } + protected override attach() { this._derive.attach() } + protected override detach() { this._derive.detach() } + + sourceUpdated(source: Source, value: S) { this._derive.sourceUpdated(source, value) } + + override get value() { + globalTracker?.sourceAccessed(this) + + if (this._derive.sourceLastUpdated > this._derive.lastRun) + this.update() + + return this._value + } + + private update() { + this.setValue(this._derive.run()) + } +} + +class Event extends SourceBase { + constructor(displayName: string) { super(displayName, () => false) } + emit(value: T) { this.setValue(value, true) } +} + +class BoundEvent extends SourceObserverBase { + readonly sources = new Set>() + + constructor( + displayName: string, + source: Source, + private _transform = identity as (value: In) => Out, + skipUpdate = (old: Out, next: Out) => false + ) { + super(displayName, skipUpdate) + this.sources.add(source) + } + + sourceUpdated(source: Source, value: S): void { + let newValue = this._transform(value) + this.setValue(newValue) + } +} + +export function map(source: Source, transform: (value: In) => Out, displayName = `${source.displayName}.map(${nameOfCallback(transform)})`) { + return new BoundEvent(displayName, source, transform) +} + +export function filter(source: Source, predicate: (value: T) => boolean, displayName = `${source.displayName}.filter(${nameOfCallback(predicate)})`) { + return new BoundEvent(displayName, source, identity, (_, next) => !predicate(next)) +} + +export function filterMap(source: Source, transform: (value: In) => Out | undefined, displayName = `${source.displayName}.filterMap(${nameOfCallback(transform)})`) { + return new BoundEvent(displayName, source, transform, (_, next) => next == undefined) +} + +class CircularDependencyError extends Error { + constructor(path: Source[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}`) } +} + +class SideEffectDuringReactionError extends Error { + constructor( + public reactingTo: Source, + public causing: Source + ) { super(`Side effect during reaction to ${reactingTo.displayName} in ${causing.displayName}`) } +} \ No newline at end of file diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index 5a03180..e019a0f 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -2,8 +2,8 @@ import { Unsubscribe } from "./types"; export const emptyArray = Object.freeze([]) as readonly any[]; -export function constant(value: T) { return returnValue.bind(null, value) as () => T; } -function returnValue(value: T) { return value; } +export function constant(value: T) { return identity.bind(null, value) as () => T; } +export function identity(value: T) { return value; } export function filteredName(baseName: string, predicate: Function) { return `${baseName}.filter(${nameOfCallback(predicate)})`; diff --git a/tsconfig.base.json b/tsconfig.base.json index bcbcf4f..8b77777 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2,9 +2,9 @@ "compilerOptions": { "target": "es2024", "module": "commonjs", - "lib": ["ES2022", "ESNext.Disposable", "dom"], + "lib": ["ES2024", "ESNext.Collection", "ESNext.Disposable", "dom"], "jsx": "react-jsx", - "strict": true, + "strict": true, "noImplicitOverride": true, "inlineSourceMap": true, "inlineSources": true, From 36be095848760b3c36fd54f2d6be5e2e3a69edf5 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 28 Feb 2026 16:49:57 +1000 Subject: [PATCH 02/83] Events --- packages/event-reduce/src/signals/events.ts | 128 +++++++++++++++++++ packages/event-reduce/src/signals/logging.ts | 67 ++++++++++ packages/event-reduce/src/signals/signals.ts | 50 +------- 3 files changed, 202 insertions(+), 43 deletions(-) create mode 100644 packages/event-reduce/src/signals/events.ts create mode 100644 packages/event-reduce/src/signals/logging.ts diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts new file mode 100644 index 0000000..ea58e09 --- /dev/null +++ b/packages/event-reduce/src/signals/events.ts @@ -0,0 +1,128 @@ +import { dispose, identity, nameOfCallback } from "../utils" +import { startLogging } from "./logging" +import { Source, SourceBase, SourceObserverBase } from "./signals" + +export function event(name?: string) { + let e = new Event(name ?? "(anonymous event)") + return makeEventFunction(e, (value: T) => { + let logging = startLogging('⚡ (event)', e.displayName, [value]) // NOCOMMIT Container + try { + e.emit(value) + } finally { + logging?.[dispose]() + } + return value + }) +} + +export interface AsyncEventFn extends EventFn<[PromiseLike, Context], AsyncValue> { + started: Source> + resolved: Source> + rejected: Source> +} + +export type AsyncValue = + | AsyncStart + | AsyncResult + | AsyncError + +export interface AsyncStart { + context: Context + promise: PromiseLike +} + +export interface AsyncResult { + context: Context + result: Result +} + +export interface AsyncError { + context: Context + error: any +} + +export function asyncEvent(name?: string): AsyncEventFn { + let e = new Event | AsyncResult | AsyncError>(name ?? "(anonymous async event)") + let fn = makeEventFunction(e,

>(promise: P, context: Context) => { + let logging = startLogging('⚡⌚ (async event)', e.displayName, [promise, context], () => ({ Promise: promise })) // NOCOMMIT Container + try { + e.emit({ promise, context }) + } finally { + logging?.[dispose]() + } + + promise.then( + result => { + let val = { result, context } + let logging = startLogging('⚡✔ (async resolved)', e.displayName, [val], () => ({ Promise: promise })) // NOCOMMIT Container + try { + e.emit(val) + } finally { + logging?.[dispose]() + } + }, + error => { + let val = { error, context } + let logging = startLogging('⚡✘ (async rejected)', e.displayName, [val], () => ({ Promise: promise })) // NOCOMMIT Container + try { + e.emit(val) + } finally { + logging?.[dispose]() + } + }) + + return promise + }) + + return Object.assign(fn, { + started: filter(e, v => 'promise' in v, `${e.displayName}.started`), + resolved: filter(e, v => 'result' in v, `${e.displayName}.resolved`), + rejected: filter(e, v => 'error' in v, `${e.displayName}.rejected`) + }) +} + +/** Converts an event class into a callable function */ +export function makeEventFunction(event: Event, eventFn: (...args: Args) => Return) { + Object.setPrototypeOf(eventFn, event); + eventFn.apply = Function.prototype.apply; + eventFn.call = Function.prototype.call; + return eventFn as EventFn; +} + +type EventFn = ((...args: Args) => Args[0]) & Event + +class Event extends SourceBase { + constructor(displayName: string) { super(displayName, () => false) } + emit(value: T) { this.setValue(value, true) } +} + +class BoundEvent extends SourceObserverBase { + readonly sources = new Set>() + + constructor( + displayName: string, + source: Source, + private _transform = identity as (value: In) => Out, + skipUpdate = (old: Out, next: Out) => false + ) { + super(displayName, skipUpdate) + this.sources.add(source) + } + + sourceUpdated(source: Source, value: S): void { + let newValue = this._transform(value) + this.setValue(newValue) + } +} + +export function map(source: Source, transform: (value: In) => Out, displayName = `${source.displayName}.map(${nameOfCallback(transform)})`): Source { + return new BoundEvent(displayName, source, transform) +} + +export function filter(source: Source, predicate: (value: In) => value is Out, displayName = `${source.displayName}.filter(${nameOfCallback(predicate)})`): Source { + return new BoundEvent(displayName, source, identity as (value: In) => Out, (_, next) => !predicate(next)) +} + +export function filterMap(source: Source, transform: (value: In) => Out | undefined, displayName = `${source.displayName}.filterMap(${nameOfCallback(transform)})`): Source { + return new BoundEvent(displayName, source, transform, (_, next) => next == undefined) +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/logging.ts b/packages/event-reduce/src/signals/logging.ts new file mode 100644 index 0000000..7153b09 --- /dev/null +++ b/packages/event-reduce/src/signals/logging.ts @@ -0,0 +1,67 @@ +import { dispose } from "../utils"; + +let loggingEnabled = false; + +let logStack = [[]] as LogItem[][]; + +type LogItem = LogMessage | LogGroup; + +type LogMessage = any[]; + +type LogGroup = { + message: LogMessage; + children: LogItem[]; +} + +/** + * For logged values that have their own specific log formatting. + * `args` will be passed as separate arguments to `console.log` + **/ +export class LogValue { constructor(public args: unknown[]) { } } + +export function enableLogging(enable = true) { + loggingEnabled = enable; +} + +export function startLogging(type: string, displayName: string, args: readonly any[], getInfo?: () => Info) { + if (process.env.NODE_ENV !== 'production' && loggingEnabled) { + logStack.push([]); + return { [dispose]() { logMessage(type, displayName, args, getInfo, logStack.pop()!); } } + } +} + +export function log(type: string, displayName: string, args: readonly any[], getInfo?: () => Info) { + if (process.env.NODE_ENV !== 'production' && loggingEnabled) + logMessage(type, displayName, args, getInfo); +} + +function logMessage(type: string, displayName: string, args: readonly any[], getInfo?: () => Info, childLogs: LogItem[] = []) { + let group = { + message: [`${displayName} %c${type}`, 'color: grey; font-weight: normal;', ...args], + children: Object.entries(getInfo?.() ?? {} as Info) + .map(([key, value]) => value instanceof LogValue + ? [`${key}:`, ...value.args] + : [`${key}:`, value]) + .concat(childLogs) + }; + logStack.at(-1)!.push(group); + + if (logStack.length == 1) { + flushLogs(logStack[0]); + logStack = [[]]; + } +} + +function flushLogs(logs: LogItem[]) { + for (let log of logs) { + if (Array.isArray(log)) { + console.log(...log); + } else if (!log.children.length) { + console.log(...log.message); + } else { + console.groupCollapsed(...log.message); + flushLogs(log.children); + console.groupEnd(); + } + } +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index f5f901e..0c9f058 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -35,26 +35,26 @@ let globalTracker: SourceTracker | null = null let globalVersion = 0 let reactingTo: Source | null = null -interface Source { - readonly displayName: string +export interface Source { + displayName: string readonly lastUpdated: number subscribe(observer: Observer): void unsubscribe(observer: Observer): void } -interface Observer { - readonly displayName: string +export interface Observer { + displayName: string readonly sources: ReadonlySet> sourceUpdated(source: Source, value: S): void } -abstract class SourceBase implements Source { +export abstract class SourceBase implements Source { protected readonly _observers = new Set>() protected _value!: T lastUpdated = -1 constructor( - public readonly displayName: string, + public displayName: string, private _skipUpdate = Object.is ) { } @@ -104,7 +104,7 @@ class Atom extends SourceBase { type Reducer = (current: Result, next: Source) => Result -abstract class SourceObserverBase extends SourceBase implements Observer { +export abstract class SourceObserverBase extends SourceBase implements Observer { abstract readonly sources: ReadonlySet> override subscribe(observer: Observer): void { @@ -269,42 +269,6 @@ class Derivation extends SourceObserverBase { } } -class Event extends SourceBase { - constructor(displayName: string) { super(displayName, () => false) } - emit(value: T) { this.setValue(value, true) } -} - -class BoundEvent extends SourceObserverBase { - readonly sources = new Set>() - - constructor( - displayName: string, - source: Source, - private _transform = identity as (value: In) => Out, - skipUpdate = (old: Out, next: Out) => false - ) { - super(displayName, skipUpdate) - this.sources.add(source) - } - - sourceUpdated(source: Source, value: S): void { - let newValue = this._transform(value) - this.setValue(newValue) - } -} - -export function map(source: Source, transform: (value: In) => Out, displayName = `${source.displayName}.map(${nameOfCallback(transform)})`) { - return new BoundEvent(displayName, source, transform) -} - -export function filter(source: Source, predicate: (value: T) => boolean, displayName = `${source.displayName}.filter(${nameOfCallback(predicate)})`) { - return new BoundEvent(displayName, source, identity, (_, next) => !predicate(next)) -} - -export function filterMap(source: Source, transform: (value: In) => Out | undefined, displayName = `${source.displayName}.filterMap(${nameOfCallback(transform)})`) { - return new BoundEvent(displayName, source, transform, (_, next) => next == undefined) -} - class CircularDependencyError extends Error { constructor(path: Source[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}`) } } From 257b56ed3f714c6b5d8e8bac8fd4298f0169eda3 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 28 Feb 2026 21:08:29 +1000 Subject: [PATCH 03/83] Reductions --- .../event-reduce/src/signals/reduction.ts | 39 +++++++++++++++++++ packages/event-reduce/src/signals/signals.ts | 24 ------------ 2 files changed, 39 insertions(+), 24 deletions(-) create mode 100644 packages/event-reduce/src/signals/reduction.ts diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts new file mode 100644 index 0000000..f95ba9b --- /dev/null +++ b/packages/event-reduce/src/signals/reduction.ts @@ -0,0 +1,39 @@ +import { Source, SourceObserverBase } from "./signals" + +export type Reducer = (current: Result, next: Source) => Result + +export class Reduction extends SourceObserverBase { + sources = new Set>() + private _reducers = new Map, (current: T, next: any) => T>() + + constructor( + displayName: string, + isEqual = Object.is, + private _events?: Events + ) { super(displayName, isEqual) } + + on(source: Source, reduce: Reducer): this + on(getSource: (events: Events) => Source, reduce: Reducer): this + on(source: Source | ((events: Events) => Source), reduce: Reducer) { + if (!('lastUpdated' in source)) + source = source(this._events!) + + this.sources.add(source) + this._reducers.set(source, reduce) + + if (this._observers.size) + source.subscribe(this) + + return this + } + + override get value() { return super.value } + + sourceUpdated(source: Source, value: S) { + let reduce = this._reducers.get(source) + if (reduce) { + let newValue = reduce(this._value, value) + this.setValue(newValue) + } + } +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 0c9f058..e9ad251 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -102,8 +102,6 @@ class Atom extends SourceBase { override setValue(value: T) { super.setValue(value, true) } } -type Reducer = (current: Result, next: Source) => Result - export abstract class SourceObserverBase extends SourceBase implements Observer { abstract readonly sources: ReadonlySet> @@ -134,28 +132,6 @@ export abstract class SourceObserverBase extends SourceBase abstract sourceUpdated(source: Source, value: S): void } -class Reduction extends SourceObserverBase { - sources = new Set>() - private _reducers = new Map, (current: T, next: any) => T>() - - on(source: Source, reduce: Reducer) { - this.sources.add(source) - this._reducers.set(source, reduce) - if (this._observers.size) - source.subscribe(this) - } - - override get value() { return super.value } - - sourceUpdated(source: Source, value: S) { - let reduce = this._reducers.get(source) - if (reduce) { - let newValue = reduce(this._value, value) - this.setValue(newValue) - } - } -} - class Effect implements Observer { readonly sources = new Set>() sourceLastUpdated = -1 From 7de2ff6069e2a9ade3234019c73390a5f67c94ef Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 1 Mar 2026 12:02:04 +1000 Subject: [PATCH 04/83] Ownership for reductions --- .../event-reduce/src/signals/reduction.ts | 58 +++++- packages/event-reduce/src/signals/signals.ts | 14 +- packages/event-reduce/src/signals/tracking.ts | 170 ++++++++++++++++++ tests/SignalsOwnership.test.ts | 98 ++++++++++ 4 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 packages/event-reduce/src/signals/tracking.ts create mode 100644 tests/SignalsOwnership.test.ts diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index f95ba9b..ade0212 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,16 +1,31 @@ import { Source, SourceObserverBase } from "./signals" +import { addSourceOwner, currentOwner, removeSourceOwner, Owner } from "./tracking" export type Reducer = (current: Result, next: Source) => Result +export class OwnerlessReductionError extends Error { + constructor(reductionName: string) { super(`Reduction '${reductionName}' created without an owner`) } +} + export class Reduction extends SourceObserverBase { sources = new Set>() private _reducers = new Map, (current: T, next: any) => T>() + private _owners = new Set() + private _attached = false constructor( displayName: string, isEqual = Object.is, private _events?: Events - ) { super(displayName, isEqual) } + ) { + super(displayName, isEqual) + + let owner = currentOwner() + if (!owner) + throw new OwnerlessReductionError(displayName) + + this[addSourceOwner](owner) + } on(source: Source, reduce: Reducer): this on(getSource: (events: Events) => Source, reduce: Reducer): this @@ -20,8 +35,8 @@ export class Reduction extends SourceObserverBase { this.sources.add(source) this._reducers.set(source, reduce) - - if (this._observers.size) + + if (this._attached) source.subscribe(this) return this @@ -36,4 +51,41 @@ export class Reduction extends SourceObserverBase { this.setValue(newValue) } } + + [addSourceOwner](owner: Owner) { + this._owners.add(owner) + this.syncSubscriptionState() + } + + [removeSourceOwner](owner: Owner) { + this._owners.delete(owner) + this.syncSubscriptionState() + } + + protected override attach() { + if (this._attached) + return + + for (let source of this.sources) + source.subscribe(this) + + this._attached = true + } + + protected override detach() { + if (this._owners.size || !this._attached) + return + + for (let source of this.sources) + source.unsubscribe(this) + + this._attached = false + } + + private syncSubscriptionState() { + if (this._owners.size || this._observers.size) + this.attach() + else + this.detach() + } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index e9ad251..3e7a9af 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,4 +1,5 @@ -import { dispose, identity, nameOfCallback } from "../utils" +import { dispose } from "../utils" +import { Owner, updateOwnedValues, withOwner } from "./tracking" class SourceTracker { private readonly _previous: SourceTracker | null @@ -68,7 +69,9 @@ export abstract class SourceBase implements Source { throw new SideEffectDuringReactionError(reactingTo, this) if (!this._skipUpdate(this._value, value)) { + let previousValue = this._value this._value = value + updateOwnedValues(this, previousValue, value) this.lastUpdated = newVersion ? ++globalVersion : globalVersion if (newVersion) @@ -140,7 +143,8 @@ class Effect implements Observer { constructor( public readonly displayName: string, private _effect: () => T, - private _onSourceUpdatedSinceLastRun: () => void + private _onSourceUpdatedSinceLastRun: () => void, + private _owner?: Owner ) { } attach() { @@ -167,7 +171,9 @@ class Effect implements Observer { let tracker = new SourceTracker(this) try { - result = this._effect() + result = this._owner + ? withOwner(this._owner, this._effect) + : this._effect() } finally { tracker[dispose]() } @@ -222,7 +228,7 @@ class Derivation extends SourceObserverBase { this._derive = new Effect(displayName, derive, () => { if (this._observers.size) this.update() - }) + }, this) } get sources() { return this._derive.sources } diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts new file mode 100644 index 0000000..e522b5d --- /dev/null +++ b/packages/event-reduce/src/signals/tracking.ts @@ -0,0 +1,170 @@ +import { Source } from "./signals" + +export const addSourceOwner = Symbol('add source owner') +export const removeSourceOwner = Symbol('remove source owner') + +export const modelBrand = Symbol('signal model') + +export type Trackable = object & { + [addSourceOwner](owner: Owner): void + [removeSourceOwner](owner: Owner): void +} + +export type Model = Trackable & { [modelBrand]: true } +export type Owner = Source | Model + +let ownerStack: Owner[] = [] + +let modelOwners = new WeakMap>() + +let modelOwnedValues = new WeakMap>() + +export function asModel(value: T): T & Model { + let model = value as T & Model + + if (!(addSourceOwner in model)) { + Object.defineProperty(model, addSourceOwner, { + value: (owner: Owner) => addModelOwner(model, owner), + enumerable: false, + configurable: false, + writable: false + }) + } + + if (!(removeSourceOwner in model)) { + Object.defineProperty(model, removeSourceOwner, { + value: (owner: Owner) => removeModelOwner(model, owner), + enumerable: false, + configurable: false, + writable: false + }) + } + + Object.defineProperty(value, modelBrand, { + value: true, + enumerable: false, + configurable: false, + writable: false + }) + + return value as T & Model +} + +export function withOwner(owner: Owner, callback: () => T): T { + ownerStack.push(owner) + try { + return callback() + } finally { + ownerStack.pop() + } +} + +export function currentOwner() { + return ownerStack.at(-1) +} + +export function updateOwnedValues(owner: Owner, previousValue: unknown, nextValue: unknown) { + let oldOwned = collectOwnedValues(previousValue) + let newOwned = collectOwnedValues(nextValue) + + for (let removed of oldOwned.difference(newOwned)) + removeOwnedValue(owner, removed) + + for (let added of newOwned.difference(oldOwned)) + addOwnedValue(owner, added) +} + +function collectOwnedValues(value: unknown): Set { + let owned = new Set() + + addOwnedValueCandidate(value, owned) + + if (isModel(value)) + return owned + + if (Array.isArray(value)) { + for (let item of value) + addOwnedValueCandidate(item, owned) + } else if (value && typeof value == 'object') { + for (let item of Object.values(value)) + addOwnedValueCandidate(item, owned) + } + + return owned +} + +function collectOwnedValuesInModel(value: Model): Set { + let owned = new Set() + + for (let item of Object.values(value)) { + if (item == value) + continue + + if (isTrackable(item)) + owned.add(item) + } + + return owned +} + +function addOwnedValueCandidate(value: unknown, owned: Set) { + if (isTrackable(value)) + owned.add(value) +} + +function addOwnedValue(owner: Owner, owned: Trackable) { + owned[addSourceOwner](owner) +} + +function removeOwnedValue(owner: Owner, owned: Trackable) { + owned[removeSourceOwner](owner) +} + +function addModelOwner(model: Model, owner: Owner) { + let owners = modelOwners.get(model) + if (!owners) { + owners = new Set() + modelOwners.set(model, owners) + } + + if (!owners.size) { + let ownedValues = collectOwnedValuesInModel(model) + modelOwnedValues.set(model, ownedValues) + + for (let owned of ownedValues) + owned[addSourceOwner](model) + } + + owners.add(owner) +} + +function removeModelOwner(model: Model, owner: Owner) { + let owners = modelOwners.get(model) + if (!owners) + return + + owners.delete(owner) + if (!owners.size) { + let ownedValues = modelOwnedValues.get(model) + if (ownedValues) { + for (let owned of ownedValues) + owned[removeSourceOwner](model) + modelOwnedValues.delete(model) + } + + modelOwners.delete(model) + } +} + +function isTrackable(value: unknown): value is Trackable { + return !!value + && typeof value == 'object' + && addSourceOwner in (value as object) + && removeSourceOwner in (value as object) +} + +function isModel(value: unknown): value is Model { + return !!value + && typeof value == 'object' + && modelBrand in (value as object) +} diff --git a/tests/SignalsOwnership.test.ts b/tests/SignalsOwnership.test.ts new file mode 100644 index 0000000..0ba78ef --- /dev/null +++ b/tests/SignalsOwnership.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { OwnerlessReductionError, Reduction } from "event-reduce/lib/signals/reduction"; +import { SourceBase } from "event-reduce/lib/signals/signals"; +import { addSourceOwner, asModel, removeSourceOwner, withOwner } from "event-reduce/lib/signals/tracking"; + +class TestSource extends SourceBase { + constructor(name: string) { super(name, () => false); } + emit(value: T) { this.setValue(value, true); } +} + +describe("signal reduction ownership", () => { + test("ownerless reduction throws", () => { + expect(() => new Reduction("r")).toThrow(OwnerlessReductionError); + }); + + test("detaches after last owner removed", () => { + let owner = new TestSource("owner"); + let updates = new TestSource("updates"); + + let reduction = withOwner(owner, () => + new Reduction("r").on(updates, (current = 0, next) => current + next)); + + updates.emit(1); + expect(reduction.value).toBe(1); + + reduction[removeSourceOwner](owner); + + updates.emit(1); + expect(reduction.value).toBe(1); + }); + + test("array ownership diffs keep/remove/add owners", () => { + let bootstrapOwner = new TestSource("bootstrap"); + let holder = new TestSource("holder"); + let updates = new TestSource("updates"); + + let r1 = withOwner(bootstrapOwner, () => + new Reduction("r1").on(updates, (current = 0, next) => current + next)); + let r2 = withOwner(bootstrapOwner, () => + new Reduction("r2").on(updates, (current = 0, next) => current + next)); + + r1[removeSourceOwner](bootstrapOwner); + r2[removeSourceOwner](bootstrapOwner); + + holder.emit([r1, r2]); + updates.emit(1); + expect(r1.value).toBe(1); + expect(r2.value).toBe(1); + + holder.emit([r2]); + updates.emit(1); + expect(r1.value).toBe(1); + expect(r2.value).toBe(2); + + holder.emit([r2, r1]); + updates.emit(1); + expect(r1.value).toBe(2); + expect(r2.value).toBe(3); + }); + + test("model in source value owns reductions inside model", () => { + let bootstrapOwner = new TestSource("bootstrap"); + let holder = new TestSource("holder"); + let updates = new TestSource("updates"); + + let reduction = withOwner(bootstrapOwner, () => + new Reduction("r").on(updates, (current = 0, next) => current + next)); + + reduction[removeSourceOwner](bootstrapOwner); + + let model = asModel({ reduction }); + holder.emit(model); + + updates.emit(1); + expect(reduction.value).toBe(1); + + holder.emit(undefined); + updates.emit(1); + expect(reduction.value).toBe(1); + }); + + test("withOwner can be used for exceptional explicit ownership", () => { + let owner = new TestSource("owner"); + let updates = new TestSource("updates"); + + let reduction = withOwner(owner, () => + new Reduction("r").on(updates, (current = 0, next) => current + next)); + + updates.emit(1); + expect(reduction.value).toBe(1); + + reduction[removeSourceOwner](owner); + reduction[addSourceOwner](owner); + + updates.emit(1); + expect(reduction.value).toBe(2); + }); +}); From a4e1785342ae5d55a4773ae7a9694d17a46e463d Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 1 Mar 2026 14:44:26 +1000 Subject: [PATCH 05/83] Ownership refactoring --- .vscode/tasks.json | 4 +- LICENSE | 2 +- packages/event-reduce/src/signals/models.ts | 19 ++ .../event-reduce/src/signals/reduction.ts | 33 ++- packages/event-reduce/src/signals/tracking.ts | 198 +++++++++++------- tests/SignalsOwnership.test.ts | 29 ++- 6 files changed, 171 insertions(+), 114 deletions(-) create mode 100644 packages/event-reduce/src/signals/models.ts diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e49ed0c..a3c7cb9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,8 +4,8 @@ "version": "2.0.0", "tasks": [ { - "type": "typescript", - "tsconfig": "tsconfig.json", + "type": "bun", + "script": "prepare", "label": "Compile TypeScript", "problemMatcher": [ "$tsc" diff --git a/LICENSE b/LICENSE index 0ca02e3..085aff4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Simon Oxtoby +Copyright (c) 2026 Simon Oxtoby Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts new file mode 100644 index 0000000..dcbaf77 --- /dev/null +++ b/packages/event-reduce/src/signals/models.ts @@ -0,0 +1,19 @@ +export type Model = { [modelBrand]: true } +const modelBrand = Symbol('signal model') + +export function asModel(value: T): T & Model { + Object.defineProperty(value, modelBrand, { + value: true, + enumerable: false, + configurable: false, + writable: false + }) + + return value as T & Model +} + +export function isModel(value: unknown): value is Model { + return !!value + && typeof value == 'object' + && modelBrand in value +} diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index ade0212..b2a3189 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,5 +1,6 @@ +import { dispose } from "../utils" import { Source, SourceObserverBase } from "./signals" -import { addSourceOwner, currentOwner, removeSourceOwner, Owner } from "./tracking" +import { addTrackableOwner, currentOwner } from "./tracking" export type Reducer = (current: Result, next: Source) => Result @@ -10,8 +11,8 @@ export class OwnerlessReductionError extends Error { export class Reduction extends SourceObserverBase { sources = new Set>() private _reducers = new Map, (current: T, next: any) => T>() - private _owners = new Set() private _attached = false + private _disposed = false constructor( displayName: string, @@ -24,7 +25,8 @@ export class Reduction extends SourceObserverBase { if (!owner) throw new OwnerlessReductionError(displayName) - this[addSourceOwner](owner) + addTrackableOwner(this, owner) + this.attach() } on(source: Source, reduce: Reducer): this @@ -52,18 +54,8 @@ export class Reduction extends SourceObserverBase { } } - [addSourceOwner](owner: Owner) { - this._owners.add(owner) - this.syncSubscriptionState() - } - - [removeSourceOwner](owner: Owner) { - this._owners.delete(owner) - this.syncSubscriptionState() - } - protected override attach() { - if (this._attached) + if (this._attached || this._disposed) return for (let source of this.sources) @@ -73,7 +65,7 @@ export class Reduction extends SourceObserverBase { } protected override detach() { - if (this._owners.size || !this._attached) + if (!this._attached) return for (let source of this.sources) @@ -82,10 +74,11 @@ export class Reduction extends SourceObserverBase { this._attached = false } - private syncSubscriptionState() { - if (this._owners.size || this._observers.size) - this.attach() - else - this.detach() + [dispose]() { + if (this._disposed) + return + + this._disposed = true + this.detach() } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index e522b5d..5c9b3cd 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -1,53 +1,58 @@ +import { dispose } from "../utils" +import { isModel, Model } from "./models" import { Source } from "./signals" -export const addSourceOwner = Symbol('add source owner') -export const removeSourceOwner = Symbol('remove source owner') - -export const modelBrand = Symbol('signal model') - export type Trackable = object & { - [addSourceOwner](owner: Owner): void - [removeSourceOwner](owner: Owner): void + [dispose](): void } -export type Model = Trackable & { [modelBrand]: true } export type Owner = Source | Model let ownerStack: Owner[] = [] -let modelOwners = new WeakMap>() +type OwnedValue = Trackable | Model + +let ownedValueOwners = new WeakMap>() let modelOwnedValues = new WeakMap>() -export function asModel(value: T): T & Model { - let model = value as T & Model +type OwnedValuesSnapshot = { + value: unknown + owned: Set +} + +let ownerOwnedValues = new WeakMap() - if (!(addSourceOwner in model)) { - Object.defineProperty(model, addSourceOwner, { - value: (owner: Owner) => addModelOwner(model, owner), - enumerable: false, - configurable: false, - writable: false - }) +export function addTrackableOwner(trackable: Trackable, owner: Owner) { + let owners = ownedValueOwners.get(trackable) + if (!owners) { + owners = new Set() + ownedValueOwners.set(trackable, owners) } - if (!(removeSourceOwner in model)) { - Object.defineProperty(model, removeSourceOwner, { - value: (owner: Owner) => removeModelOwner(model, owner), - enumerable: false, - configurable: false, - writable: false - }) + let hadOwners = !!owners.size + owners.add(owner) + return !hadOwners +} + +export function removeTrackableOwner(trackable: Trackable, owner: Owner) { + let owners = ownedValueOwners.get(trackable) + if (!owners) + return false + + owners.delete(owner) + + if (!owners.size) { + ownedValueOwners.delete(trackable) + trackable[dispose]() + return true } - Object.defineProperty(value, modelBrand, { - value: true, - enumerable: false, - configurable: false, - writable: false - }) + return false +} - return value as T & Model +export function hasTrackableOwners(trackable: Trackable) { + return !!ownedValueOwners.get(trackable)?.size } export function withOwner(owner: Owner, callback: () => T): T { @@ -64,18 +69,48 @@ export function currentOwner() { } export function updateOwnedValues(owner: Owner, previousValue: unknown, nextValue: unknown) { - let oldOwned = collectOwnedValues(previousValue) + if (Object.is(previousValue, nextValue)) + return + + let snapshot = ownerOwnedValues.get(owner) + let oldOwned = snapshot && Object.is(snapshot.value, previousValue) + ? snapshot.owned + : collectOwnedValues(previousValue) + let newOwned = collectOwnedValues(nextValue) + ownerOwnedValues.set(owner, { value: nextValue, owned: newOwned }) + + if (!oldOwned.size && !newOwned.size) + return + + if (!oldOwned.size) { + for (let added of newOwned) + addOwnedValue(owner, added) + return + } - for (let removed of oldOwned.difference(newOwned)) - removeOwnedValue(owner, removed) + if (!newOwned.size) { + for (let removed of oldOwned) + removeOwnedValue(owner, removed) + return + } + + for (let removed of oldOwned) { + if (!newOwned.has(removed)) + removeOwnedValue(owner, removed) + } - for (let added of newOwned.difference(oldOwned)) - addOwnedValue(owner, added) + for (let added of newOwned) { + if (!oldOwned.has(added)) + addOwnedValue(owner, added) + } } -function collectOwnedValues(value: unknown): Set { - let owned = new Set() +function collectOwnedValues(value: unknown): Set { + if (!value || typeof value != 'object') + return new Set() + + let owned = new Set() addOwnedValueCandidate(value, owned) @@ -83,48 +118,48 @@ function collectOwnedValues(value: unknown): Set { return owned if (Array.isArray(value)) { - for (let item of value) - addOwnedValueCandidate(item, owned) - } else if (value && typeof value == 'object') { - for (let item of Object.values(value)) - addOwnedValueCandidate(item, owned) + for (let index = 0; index < value.length; index++) + addOwnedValueCandidate(value[index], owned) + } else { + for (let key in value) { + if (!Object.hasOwn(value, key)) + continue + + addOwnedValueCandidate((value as Record)[key], owned) + } } return owned } -function collectOwnedValuesInModel(value: Model): Set { - let owned = new Set() - - for (let item of Object.values(value)) { - if (item == value) - continue +function addOwnedValueCandidate(value: unknown, owned: Set) { + if (isTrackable(value) || isModel(value)) + owned.add(value) +} - if (isTrackable(item)) - owned.add(item) +function addOwnedValue(owner: Owner, owned: OwnedValue) { + if (isTrackable(owned)) { + addTrackableOwner(owned, owner) + return } - return owned -} - -function addOwnedValueCandidate(value: unknown, owned: Set) { - if (isTrackable(value)) - owned.add(value) + addModelOwner(owned, owner) } -function addOwnedValue(owner: Owner, owned: Trackable) { - owned[addSourceOwner](owner) -} +function removeOwnedValue(owner: Owner, owned: OwnedValue) { + if (isTrackable(owned)) { + removeTrackableOwner(owned, owner) + return + } -function removeOwnedValue(owner: Owner, owned: Trackable) { - owned[removeSourceOwner](owner) + removeModelOwner(owned, owner) } function addModelOwner(model: Model, owner: Owner) { - let owners = modelOwners.get(model) + let owners = ownedValueOwners.get(model) if (!owners) { owners = new Set() - modelOwners.set(model, owners) + ownedValueOwners.set(model, owners) } if (!owners.size) { @@ -132,14 +167,14 @@ function addModelOwner(model: Model, owner: Owner) { modelOwnedValues.set(model, ownedValues) for (let owned of ownedValues) - owned[addSourceOwner](model) + addTrackableOwner(owned, model) } owners.add(owner) } function removeModelOwner(model: Model, owner: Owner) { - let owners = modelOwners.get(model) + let owners = ownedValueOwners.get(model) if (!owners) return @@ -148,23 +183,34 @@ function removeModelOwner(model: Model, owner: Owner) { let ownedValues = modelOwnedValues.get(model) if (ownedValues) { for (let owned of ownedValues) - owned[removeSourceOwner](model) + removeTrackableOwner(owned, model) modelOwnedValues.delete(model) } - modelOwners.delete(model) + ownedValueOwners.delete(model) } } -function isTrackable(value: unknown): value is Trackable { - return !!value - && typeof value == 'object' - && addSourceOwner in (value as object) - && removeSourceOwner in (value as object) +function collectOwnedValuesInModel(value: Model): Set { + let owned = new Set() + + for (let key in value) { + if (!Object.hasOwn(value, key)) + continue + + let item = (value as Record)[key] + if (item == value) + continue + + if (isTrackable(item)) + owned.add(item) + } + + return owned } -function isModel(value: unknown): value is Model { +export function isTrackable(value: unknown): value is Trackable { return !!value && typeof value == 'object' - && modelBrand in (value as object) + && dispose in (value as object) } diff --git a/tests/SignalsOwnership.test.ts b/tests/SignalsOwnership.test.ts index 0ba78ef..87569a3 100644 --- a/tests/SignalsOwnership.test.ts +++ b/tests/SignalsOwnership.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; +import { asModel } from "event-reduce/lib/signals/models"; import { OwnerlessReductionError, Reduction } from "event-reduce/lib/signals/reduction"; import { SourceBase } from "event-reduce/lib/signals/signals"; -import { addSourceOwner, asModel, removeSourceOwner, withOwner } from "event-reduce/lib/signals/tracking"; +import { addTrackableOwner, removeTrackableOwner, withOwner } from "event-reduce/lib/signals/tracking"; class TestSource extends SourceBase { constructor(name: string) { super(name, () => false); } @@ -13,7 +14,7 @@ describe("signal reduction ownership", () => { expect(() => new Reduction("r")).toThrow(OwnerlessReductionError); }); - test("detaches after last owner removed", () => { + test("disposes after last owner removed", () => { let owner = new TestSource("owner"); let updates = new TestSource("updates"); @@ -23,25 +24,21 @@ describe("signal reduction ownership", () => { updates.emit(1); expect(reduction.value).toBe(1); - reduction[removeSourceOwner](owner); + removeTrackableOwner(reduction, owner); updates.emit(1); expect(reduction.value).toBe(1); }); test("array ownership diffs keep/remove/add owners", () => { - let bootstrapOwner = new TestSource("bootstrap"); let holder = new TestSource("holder"); let updates = new TestSource("updates"); - let r1 = withOwner(bootstrapOwner, () => + let r1 = withOwner(holder, () => new Reduction("r1").on(updates, (current = 0, next) => current + next)); - let r2 = withOwner(bootstrapOwner, () => + let r2 = withOwner(holder, () => new Reduction("r2").on(updates, (current = 0, next) => current + next)); - r1[removeSourceOwner](bootstrapOwner); - r2[removeSourceOwner](bootstrapOwner); - holder.emit([r1, r2]); updates.emit(1); expect(r1.value).toBe(1); @@ -54,7 +51,7 @@ describe("signal reduction ownership", () => { holder.emit([r2, r1]); updates.emit(1); - expect(r1.value).toBe(2); + expect(r1.value).toBe(1); expect(r2.value).toBe(3); }); @@ -66,10 +63,12 @@ describe("signal reduction ownership", () => { let reduction = withOwner(bootstrapOwner, () => new Reduction("r").on(updates, (current = 0, next) => current + next)); - reduction[removeSourceOwner](bootstrapOwner); + addTrackableOwner(reduction, holder); + removeTrackableOwner(reduction, bootstrapOwner); let model = asModel({ reduction }); holder.emit(model); + removeTrackableOwner(reduction, holder); updates.emit(1); expect(reduction.value).toBe(1); @@ -79,7 +78,7 @@ describe("signal reduction ownership", () => { expect(reduction.value).toBe(1); }); - test("withOwner can be used for exceptional explicit ownership", () => { + test("disposed reductions do not reactivate when owner is re-added", () => { let owner = new TestSource("owner"); let updates = new TestSource("updates"); @@ -89,10 +88,10 @@ describe("signal reduction ownership", () => { updates.emit(1); expect(reduction.value).toBe(1); - reduction[removeSourceOwner](owner); - reduction[addSourceOwner](owner); + removeTrackableOwner(reduction, owner); + addTrackableOwner(reduction, owner); updates.emit(1); - expect(reduction.value).toBe(2); + expect(reduction.value).toBe(1); }); }); From f97310e5e825eb783ab5e5aade07ceb5581a51fc Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 1 Mar 2026 16:58:50 +1000 Subject: [PATCH 06/83] Much simpler tracking --- .../event-reduce/src/signals/reduction.ts | 13 +- packages/event-reduce/src/signals/signals.ts | 14 +- packages/event-reduce/src/signals/tracking.ts | 217 ++++-------------- packages/event-reduce/src/utils.ts | 2 + tests/SignalsOwnership.test.ts | 174 +++++++------- 5 files changed, 151 insertions(+), 269 deletions(-) diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index b2a3189..c5aef40 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,12 +1,9 @@ import { dispose } from "../utils" import { Source, SourceObserverBase } from "./signals" -import { addTrackableOwner, currentOwner } from "./tracking" +import { currentOwnerScope } from "./tracking" export type Reducer = (current: Result, next: Source) => Result -export class OwnerlessReductionError extends Error { - constructor(reductionName: string) { super(`Reduction '${reductionName}' created without an owner`) } -} export class Reduction extends SourceObserverBase { sources = new Set>() @@ -21,11 +18,9 @@ export class Reduction extends SourceObserverBase { ) { super(displayName, isEqual) - let owner = currentOwner() - if (!owner) + if (!currentOwnerScope()) throw new OwnerlessReductionError(displayName) - addTrackableOwner(this, owner) this.attach() } @@ -81,4 +76,8 @@ export class Reduction extends SourceObserverBase { this._disposed = true this.detach() } +} + +export class OwnerlessReductionError extends Error { + constructor(reductionName: string) { super(`Reduction '${reductionName}' created without an owner.`) } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 3e7a9af..4e6ff21 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -64,14 +64,14 @@ export abstract class SourceBase implements Source { return this._value } - protected setValue(value: T, newVersion = false) { + protected setValue(newValue: T, newVersion = false) { // TODO better name for newVersion - creates new global version if (newVersion && reactingTo) throw new SideEffectDuringReactionError(reactingTo, this) - if (!this._skipUpdate(this._value, value)) { + if (!this._skipUpdate(this._value, newValue)) { let previousValue = this._value - this._value = value - updateOwnedValues(this, previousValue, value) + this._value = newValue + updateOwnedValues(this, previousValue, newValue) this.lastUpdated = newVersion ? ++globalVersion : globalVersion if (newVersion) @@ -169,12 +169,12 @@ class Effect implements Observer { let result: T; let tracker = new SourceTracker(this) + let ownerScope = this._owner ? withOwner(this._owner) : null try { - result = this._owner - ? withOwner(this._owner, this._effect) - : this._effect() + result = this._effect() } finally { + ownerScope?.[dispose]() tracker[dispose]() } diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index 5c9b3cd..3557dc4 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -1,70 +1,49 @@ -import { dispose } from "../utils" +import { dispose, emptySet } from "../utils" import { isModel, Model } from "./models" +import { Reduction } from "./reduction" import { Source } from "./signals" -export type Trackable = object & { - [dispose](): void -} +export type Trackable = Reduction | Model export type Owner = Source | Model let ownerStack: Owner[] = [] -type OwnedValue = Trackable | Model - -let ownedValueOwners = new WeakMap>() +let ownerRegistry = new WeakMap>() -let modelOwnedValues = new WeakMap>() +let ownedValuesSnapshots = new WeakMap>() -type OwnedValuesSnapshot = { - value: unknown - owned: Set -} +export function addOwner(trackable: Trackable, owner: Owner) { + let owners = ownerRegistry.get(trackable) -let ownerOwnedValues = new WeakMap() - -export function addTrackableOwner(trackable: Trackable, owner: Owner) { - let owners = ownedValueOwners.get(trackable) if (!owners) { - owners = new Set() - ownedValueOwners.set(trackable, owners) + owners = new Set() + ownerRegistry.set(trackable, owners) } - let hadOwners = !!owners.size owners.add(owner) - return !hadOwners } -export function removeTrackableOwner(trackable: Trackable, owner: Owner) { - let owners = ownedValueOwners.get(trackable) - if (!owners) - return false - - owners.delete(owner) +export function removeOwner(trackable: Trackable, owner: Owner) { + let owners = ownerRegistry.get(trackable) + if (owners) { + owners.delete(owner) - if (!owners.size) { - ownedValueOwners.delete(trackable) - trackable[dispose]() - return true + if (!owners.size) { + ownerRegistry.delete(trackable) + if (trackable instanceof Reduction) + trackable[dispose]() + } } - - return false -} - -export function hasTrackableOwners(trackable: Trackable) { - return !!ownedValueOwners.get(trackable)?.size } -export function withOwner(owner: Owner, callback: () => T): T { +// TODO may not need this if we're just going to check values anyway, although still want to avoid loose reductions +export function withOwner(owner: Owner) { ownerStack.push(owner) - try { - return callback() - } finally { - ownerStack.pop() - } + return { [dispose]() { ownerStack.pop() } } as Disposable } -export function currentOwner() { +export function currentOwnerScope() { return ownerStack.at(-1) } @@ -72,145 +51,35 @@ export function updateOwnedValues(owner: Owner, previousValue: unknown, nextValu if (Object.is(previousValue, nextValue)) return - let snapshot = ownerOwnedValues.get(owner) - let oldOwned = snapshot && Object.is(snapshot.value, previousValue) - ? snapshot.owned - : collectOwnedValues(previousValue) - - let newOwned = collectOwnedValues(nextValue) - ownerOwnedValues.set(owner, { value: nextValue, owned: newOwned }) - - if (!oldOwned.size && !newOwned.size) - return + let previouslyTracked = canContainTrackableValues(previousValue) + ? ownedValuesSnapshots.get(previousValue) ?? collectTrackableValues(previousValue) + : emptySet - if (!oldOwned.size) { - for (let added of newOwned) - addOwnedValue(owner, added) - return - } + let newlyTracked = canContainTrackableValues(nextValue) + ? collectTrackableValues(nextValue) + : emptySet - if (!newOwned.size) { - for (let removed of oldOwned) - removeOwnedValue(owner, removed) - return - } + for (let removed of previouslyTracked) + if (!newlyTracked.has(removed)) + removeOwner(removed, owner) - for (let removed of oldOwned) { - if (!newOwned.has(removed)) - removeOwnedValue(owner, removed) - } - - for (let added of newOwned) { - if (!oldOwned.has(added)) - addOwnedValue(owner, added) - } -} - -function collectOwnedValues(value: unknown): Set { - if (!value || typeof value != 'object') - return new Set() - - let owned = new Set() - - addOwnedValueCandidate(value, owned) - - if (isModel(value)) - return owned - - if (Array.isArray(value)) { - for (let index = 0; index < value.length; index++) - addOwnedValueCandidate(value[index], owned) - } else { - for (let key in value) { - if (!Object.hasOwn(value, key)) - continue - - addOwnedValueCandidate((value as Record)[key], owned) - } - } - - return owned + for (let added of newlyTracked) + if (!previouslyTracked.has(added)) + addOwner(added, owner) } -function addOwnedValueCandidate(value: unknown, owned: Set) { - if (isTrackable(value) || isModel(value)) - owned.add(value) +function collectTrackableValues(value: object): ReadonlySet { + return isTrackable(value) ? new Set([value]) + : Array.isArray(value) ? new Set(value.filter(isTrackable)) + : new Set(Object.values(value).filter(isTrackable)) } -function addOwnedValue(owner: Owner, owned: OwnedValue) { - if (isTrackable(owned)) { - addTrackableOwner(owned, owner) - return - } - - addModelOwner(owned, owner) -} - -function removeOwnedValue(owner: Owner, owned: OwnedValue) { - if (isTrackable(owned)) { - removeTrackableOwner(owned, owner) - return - } - - removeModelOwner(owned, owner) -} - -function addModelOwner(model: Model, owner: Owner) { - let owners = ownedValueOwners.get(model) - if (!owners) { - owners = new Set() - ownedValueOwners.set(model, owners) - } - - if (!owners.size) { - let ownedValues = collectOwnedValuesInModel(model) - modelOwnedValues.set(model, ownedValues) - - for (let owned of ownedValues) - addTrackableOwner(owned, model) - } - - owners.add(owner) -} - -function removeModelOwner(model: Model, owner: Owner) { - let owners = ownedValueOwners.get(model) - if (!owners) - return - - owners.delete(owner) - if (!owners.size) { - let ownedValues = modelOwnedValues.get(model) - if (ownedValues) { - for (let owned of ownedValues) - removeTrackableOwner(owned, model) - modelOwnedValues.delete(model) - } - - ownedValueOwners.delete(model) - } -} - -function collectOwnedValuesInModel(value: Model): Set { - let owned = new Set() - - for (let key in value) { - if (!Object.hasOwn(value, key)) - continue - - let item = (value as Record)[key] - if (item == value) - continue - - if (isTrackable(item)) - owned.add(item) - } - - return owned +function canContainTrackableValues(value: unknown): value is object { + return !!value + && typeof value == 'object' } export function isTrackable(value: unknown): value is Trackable { - return !!value - && typeof value == 'object' - && dispose in (value as object) + return isModel(value) + || value instanceof Reduction } diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index e019a0f..9917ea3 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -2,6 +2,8 @@ import { Unsubscribe } from "./types"; export const emptyArray = Object.freeze([]) as readonly any[]; +export const emptySet = Object.freeze(new Set()) as ReadonlySet; + export function constant(value: T) { return identity.bind(null, value) as () => T; } export function identity(value: T) { return value; } diff --git a/tests/SignalsOwnership.test.ts b/tests/SignalsOwnership.test.ts index 87569a3..d16f879 100644 --- a/tests/SignalsOwnership.test.ts +++ b/tests/SignalsOwnership.test.ts @@ -1,97 +1,109 @@ -import { describe, expect, test } from "bun:test"; -import { asModel } from "event-reduce/lib/signals/models"; -import { OwnerlessReductionError, Reduction } from "event-reduce/lib/signals/reduction"; -import { SourceBase } from "event-reduce/lib/signals/signals"; -import { addTrackableOwner, removeTrackableOwner, withOwner } from "event-reduce/lib/signals/tracking"; +import { describe, expect, test } from "bun:test" +import { asModel } from "event-reduce/lib/signals/models" +import { OwnerlessReductionError, Reduction } from "event-reduce/lib/signals/reduction" +import { SourceBase } from "event-reduce/lib/signals/signals" +import { addOwner, removeOwner, withOwner } from "event-reduce/lib/signals/tracking" class TestSource extends SourceBase { - constructor(name: string) { super(name, () => false); } - emit(value: T) { this.setValue(value, true); } + constructor(name: string) { super(name, () => false) } + emit(value: T) { this.setValue(value, true) } } describe("signal reduction ownership", () => { test("ownerless reduction throws", () => { - expect(() => new Reduction("r")).toThrow(OwnerlessReductionError); - }); + expect(() => new Reduction("r")).toThrow(OwnerlessReductionError) + }) test("disposes after last owner removed", () => { - let owner = new TestSource("owner"); - let updates = new TestSource("updates"); + let owner = new TestSource("owner") + let updates = new TestSource("updates") - let reduction = withOwner(owner, () => - new Reduction("r").on(updates, (current = 0, next) => current + next)); + let reduction: Reduction + { + using _ = withOwner(owner) + reduction = new Reduction("r").on(updates, (current = 0, next) => current + next) + } - updates.emit(1); - expect(reduction.value).toBe(1); + updates.emit(1) + expect(reduction.value).toBe(1) - removeTrackableOwner(reduction, owner); + owner.emit(reduction) + owner.emit(undefined) - updates.emit(1); - expect(reduction.value).toBe(1); - }); + updates.emit(1) + expect(reduction.value).toBe(1) + }) test("array ownership diffs keep/remove/add owners", () => { - let holder = new TestSource("holder"); - let updates = new TestSource("updates"); - - let r1 = withOwner(holder, () => - new Reduction("r1").on(updates, (current = 0, next) => current + next)); - let r2 = withOwner(holder, () => - new Reduction("r2").on(updates, (current = 0, next) => current + next)); - - holder.emit([r1, r2]); - updates.emit(1); - expect(r1.value).toBe(1); - expect(r2.value).toBe(1); - - holder.emit([r2]); - updates.emit(1); - expect(r1.value).toBe(1); - expect(r2.value).toBe(2); - - holder.emit([r2, r1]); - updates.emit(1); - expect(r1.value).toBe(1); - expect(r2.value).toBe(3); - }); - - test("model in source value owns reductions inside model", () => { - let bootstrapOwner = new TestSource("bootstrap"); - let holder = new TestSource("holder"); - let updates = new TestSource("updates"); - - let reduction = withOwner(bootstrapOwner, () => - new Reduction("r").on(updates, (current = 0, next) => current + next)); - - addTrackableOwner(reduction, holder); - removeTrackableOwner(reduction, bootstrapOwner); - - let model = asModel({ reduction }); - holder.emit(model); - removeTrackableOwner(reduction, holder); - - updates.emit(1); - expect(reduction.value).toBe(1); - - holder.emit(undefined); - updates.emit(1); - expect(reduction.value).toBe(1); - }); + let holder = new TestSource("holder") + let updates = new TestSource("updates") + let r1, r2: Reduction + { + using _ = withOwner(holder) + r1 = new Reduction("r1").on(updates, (current = 0, next) => current + next) + r2 = new Reduction("r2").on(updates, (current = 0, next) => current + next) + } + + holder.emit([r1, r2]) + updates.emit(1) + expect(r1.value).toBe(1) + expect(r2.value).toBe(1) + + holder.emit([r2]) + updates.emit(1) + expect(r1.value).toBe(1) + expect(r2.value).toBe(2) + + holder.emit([r2, r1]) + updates.emit(1) + expect(r1.value).toBe(1) + expect(r2.value).toBe(3) + }) + + test("replacing owned reduction with model disposes reduction", () => { + let bootstrapOwner = new TestSource("bootstrap") + let holder = new TestSource("holder") + let updates = new TestSource("updates") + + let reduction: Reduction + { + using _ = withOwner(bootstrapOwner) + reduction = new Reduction("r").on(updates, (current = 0, next) => current + next) + } + + holder.emit(reduction) + updates.emit(1) + expect(reduction.value).toBe(1) + + let model = asModel({ reduction }) + holder.emit(model) + + updates.emit(1) + expect(reduction.value).toBe(1) + + holder.emit(undefined) + updates.emit(1) + expect(reduction.value).toBe(1) + }) test("disposed reductions do not reactivate when owner is re-added", () => { - let owner = new TestSource("owner"); - let updates = new TestSource("updates"); - - let reduction = withOwner(owner, () => - new Reduction("r").on(updates, (current = 0, next) => current + next)); - - updates.emit(1); - expect(reduction.value).toBe(1); - - removeTrackableOwner(reduction, owner); - addTrackableOwner(reduction, owner); - - updates.emit(1); - expect(reduction.value).toBe(1); - }); -}); + let owner = new TestSource("owner") + let updates = new TestSource("updates") + + let reduction: Reduction + { + using _ = withOwner(owner) + reduction = new Reduction("r").on(updates, (current = 0, next) => current + next) + } + + updates.emit(1) + expect(reduction.value).toBe(1) + + addOwner(reduction, owner) + removeOwner(reduction, owner) + addOwner(reduction, owner) + + updates.emit(1) + expect(reduction.value).toBe(1) + }) +}) From 9dab4dc8f2fd417f19281db7b86fc3b01c4f768e Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Mon, 2 Mar 2026 19:10:22 +1000 Subject: [PATCH 07/83] Breaking up signals.ts some more and adding reduce and derive functions --- .../event-reduce/src/signals/derivation.ts | 52 +++++ packages/event-reduce/src/signals/effect.ts | 65 +++++++ packages/event-reduce/src/signals/events.ts | 20 +- packages/event-reduce/src/signals/reaction.ts | 28 +++ .../event-reduce/src/signals/reduction.ts | 31 ++- packages/event-reduce/src/signals/signals.ts | 181 ++++-------------- packages/event-reduce/src/signals/tracking.ts | 4 +- 7 files changed, 216 insertions(+), 165 deletions(-) create mode 100644 packages/event-reduce/src/signals/derivation.ts create mode 100644 packages/event-reduce/src/signals/effect.ts create mode 100644 packages/event-reduce/src/signals/reaction.ts diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts new file mode 100644 index 0000000..15f3e08 --- /dev/null +++ b/packages/event-reduce/src/signals/derivation.ts @@ -0,0 +1,52 @@ +import { nameOfFunction } from "../utils" +import { Effect } from "./effect" +import { ISource, SourceObserverBase, globalTracker } from "./signals" + +export interface IDerivation extends ISource { + readonly value: T +} + +export function derive(derive: () => T, displayName?: string): IDerivation +export function derive(derive: () => T, valuesEqual: (a: T, b: T) => boolean, displayName?: string): IDerivation +export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, b: T) => boolean), maybeDisplayName?: string): IDerivation { + let [skipUpdate, displayName] = typeof displayNameOrSkip == 'function' + ? [displayNameOrSkip, maybeDisplayName] + : [Object.is, displayNameOrSkip] + return new Derivation(displayName ?? nameOfFunction(derive), derive, skipUpdate) +} + +class Derivation extends SourceObserverBase { + private _derive: Effect + + constructor( + displayName: string, + derive: () => T, + skipUpdate = Object.is + ) { + super(displayName, skipUpdate) + this._derive = new Effect(displayName, derive, () => { + if (this._observers.size) + this.update() + }, this) + } + + get sources() { return this._derive.sources } + protected override attach() { this._derive.attach() } + protected override detach() { this._derive.detach() } + + sourceUpdated(source: ISource, value: S) { this._derive.sourceUpdated(source, value) } + + override get value() { + // Notifying tracker before checking for updates so circular reference is detected early + globalTracker?.sourceAccessed(this) + + if (this._derive.sourceLastUpdated > this._derive.lastRun) + this.update() + + return this._value // Not using super.value to avoid duplicate tracker notification + } + + private update() { + this.setValue(this._derive.run()) + } +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/effect.ts b/packages/event-reduce/src/signals/effect.ts new file mode 100644 index 0000000..d8b4392 --- /dev/null +++ b/packages/event-reduce/src/signals/effect.ts @@ -0,0 +1,65 @@ +import { dispose } from "../utils" +import { globalVersion, IObserver, ISource, SourceTracker } from "./signals" +import { Owner, withOwner } from "./tracking" + +export class Effect implements IObserver { + readonly sources = new Set>() + sourceLastUpdated = -1 + lastRun = -1 + + constructor( + public readonly displayName: string, + private _effect: () => T, + private _onSourceUpdatedSinceLastRun: () => void, + private _owner?: Owner + ) { } + + attach() { + for (let source of this.sources) + source.subscribe(this) + } + + detach() { + for (let source of this.sources) + source.unsubscribe(this) + } + + sourceUpdated(source: ISource, value: S): void { + if (source.lastUpdated > this.lastRun) { + this.sourceLastUpdated = source.lastUpdated + this._onSourceUpdatedSinceLastRun() + } + } + + run() { + this.lastRun = globalVersion + + let result: T; + let tracker = new SourceTracker(this) + let ownerScope = this._owner ? withOwner(this._owner) : null + + try { + result = this._effect() + } finally { + ownerScope?.[dispose]() + tracker[dispose]() + } + + for (let oldSource of this.sources.difference(tracker.accessed)) { + this.sources.delete(oldSource) + oldSource.unsubscribe(this) + } + + for (let newSource of tracker.accessed.difference(this.sources)) { + this.sources.add(newSource) + newSource.subscribe(this) + } + + return result; + } + + [dispose]() { + this.detach() + this.sources.clear() + } +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index ea58e09..07d62cb 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -1,6 +1,6 @@ import { dispose, identity, nameOfCallback } from "../utils" import { startLogging } from "./logging" -import { Source, SourceBase, SourceObserverBase } from "./signals" +import { ISource, SourceBase, SourceObserverBase } from "./signals" export function event(name?: string) { let e = new Event(name ?? "(anonymous event)") @@ -16,9 +16,9 @@ export function event(name?: string) { } export interface AsyncEventFn extends EventFn<[PromiseLike, Context], AsyncValue> { - started: Source> - resolved: Source> - rejected: Source> + started: ISource> + resolved: ISource> + rejected: ISource> } export type AsyncValue = @@ -97,11 +97,11 @@ class Event extends SourceBase { } class BoundEvent extends SourceObserverBase { - readonly sources = new Set>() + readonly sources = new Set>() constructor( displayName: string, - source: Source, + source: ISource, private _transform = identity as (value: In) => Out, skipUpdate = (old: Out, next: Out) => false ) { @@ -109,20 +109,20 @@ class BoundEvent extends SourceObserverBase { this.sources.add(source) } - sourceUpdated(source: Source, value: S): void { + sourceUpdated(source: ISource, value: S): void { let newValue = this._transform(value) this.setValue(newValue) } } -export function map(source: Source, transform: (value: In) => Out, displayName = `${source.displayName}.map(${nameOfCallback(transform)})`): Source { +export function map(source: ISource, transform: (value: In) => Out, displayName = `${source.displayName}.map(${nameOfCallback(transform)})`): ISource { return new BoundEvent(displayName, source, transform) } -export function filter(source: Source, predicate: (value: In) => value is Out, displayName = `${source.displayName}.filter(${nameOfCallback(predicate)})`): Source { +export function filter(source: ISource, predicate: (value: In) => value is Out, displayName = `${source.displayName}.filter(${nameOfCallback(predicate)})`): ISource { return new BoundEvent(displayName, source, identity as (value: In) => Out, (_, next) => !predicate(next)) } -export function filterMap(source: Source, transform: (value: In) => Out | undefined, displayName = `${source.displayName}.filterMap(${nameOfCallback(transform)})`): Source { +export function filterMap(source: ISource, transform: (value: In) => Out | undefined, displayName = `${source.displayName}.filterMap(${nameOfCallback(transform)})`): ISource { return new BoundEvent(displayName, source, transform, (_, next) => next == undefined) } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/reaction.ts b/packages/event-reduce/src/signals/reaction.ts new file mode 100644 index 0000000..be5a804 --- /dev/null +++ b/packages/event-reduce/src/signals/reaction.ts @@ -0,0 +1,28 @@ +import { dispose } from "../utils" +import { Effect } from "./effect" +import { reactingTo, startReactingTo } from "./signals" + +// NOCOMMIT not sure I have a use for this +export class Reaction extends Effect { + constructor( + displayName: string, + reaction: () => void, + private _schedule?: (run: () => void) => void + ) { + super(displayName, reaction, () => { + if (this._schedule) { + let wasReactingTo = reactingTo + this._schedule(() => { + let reaction = wasReactingTo ? startReactingTo(wasReactingTo) : null + try { + this.run() + } finally { + reaction?.[dispose]() + } + }) + } else { + this.run() + } + }) + } +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index c5aef40..93213d5 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,13 +1,28 @@ import { dispose } from "../utils" -import { Source, SourceObserverBase } from "./signals" +import { ISource, SourceObserverBase } from "./signals" import { currentOwnerScope } from "./tracking" -export type Reducer = (current: Result, next: Source) => Result +export interface IReduction extends ISource { + readonly value: T + on(source: ISource, reduce: Reducer): this + on(getSource: (events: Events) => ISource, reduce: Reducer): this +} + +export function reduce(initialValue: T, displayName?: string): IReduction +export function reduce(initialValue: T, events: Events, displayName?: string): IReduction +export function reduce(initialValue: T, eventsOrDisplayName?: Events | string, maybeDisplayName?: string): IReduction { + let [events, displayName] = typeof eventsOrDisplayName == 'string' + ? [undefined as Events, eventsOrDisplayName] + : [eventsOrDisplayName, maybeDisplayName] + return new Reduction(displayName ?? "(anonymous reduction)", Object.is, events) +} + +export type Reducer = (current: Result, next: Source) => Result export class Reduction extends SourceObserverBase { - sources = new Set>() - private _reducers = new Map, (current: T, next: any) => T>() + sources = new Set>() + private _reducers = new Map, (current: T, next: any) => T>() private _attached = false private _disposed = false @@ -24,9 +39,9 @@ export class Reduction extends SourceObserverBase { this.attach() } - on(source: Source, reduce: Reducer): this - on(getSource: (events: Events) => Source, reduce: Reducer): this - on(source: Source | ((events: Events) => Source), reduce: Reducer) { + on(source: ISource, reduce: Reducer): this + on(getSource: (events: Events) => ISource, reduce: Reducer): this + on(source: ISource | ((events: Events) => ISource), reduce: Reducer) { if (!('lastUpdated' in source)) source = source(this._events!) @@ -41,7 +56,7 @@ export class Reduction extends SourceObserverBase { override get value() { return super.value } - sourceUpdated(source: Source, value: S) { + sourceUpdated(source: ISource, value: S) { let reduce = this._reducers.get(source) if (reduce) { let newValue = reduce(this._value, value) diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 4e6ff21..d0c0ec4 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,16 +1,16 @@ import { dispose } from "../utils" -import { Owner, updateOwnedValues, withOwner } from "./tracking" +import { updateOwnedValues } from "./tracking" -class SourceTracker { +export class SourceTracker { private readonly _previous: SourceTracker | null - readonly accessed = new Set>() + readonly accessed = new Set>() - constructor(private readonly _observer: Observer) { + constructor(private readonly _observer: IObserver) { this._previous = globalTracker globalTracker = this } - sourceAccessed(source: Source) { + sourceAccessed(source: ISource) { let circularDependency = this.findCircularDependency(source) if (circularDependency) throw new CircularDependencyError([...circularDependency, source]) @@ -18,12 +18,12 @@ class SourceTracker { this.accessed.add(source) } - private findCircularDependency(source: Source): Source[] | null { - if (this._observer == source as any as Observer) + private findCircularDependency(source: ISource): ISource[] | null { + if (this._observer == source as any as IObserver) return [source] let circularDependency = this._previous?.findCircularDependency(source) return circularDependency - ? [...circularDependency, this._observer as any as Source] + ? [...circularDependency, this._observer as any as ISource] : null } @@ -32,25 +32,31 @@ class SourceTracker { } } -let globalTracker: SourceTracker | null = null -let globalVersion = 0 -let reactingTo: Source | null = null +export let globalTracker: SourceTracker | null = null +export let globalVersion = 0 +export let reactingTo: ISource | null = null -export interface Source { +export function startReactingTo(source: ISource) { + let previouslyReactingTo = reactingTo + reactingTo = source + return { [dispose]() { reactingTo = previouslyReactingTo } } +} + +export interface ISource { displayName: string readonly lastUpdated: number - subscribe(observer: Observer): void - unsubscribe(observer: Observer): void + subscribe(observer: IObserver): void + unsubscribe(observer: IObserver): void } -export interface Observer { +export interface IObserver { displayName: string - readonly sources: ReadonlySet> - sourceUpdated(source: Source, value: S): void + readonly sources: ReadonlySet> + sourceUpdated(source: ISource, value: S): void } -export abstract class SourceBase implements Source { - protected readonly _observers = new Set>() +export abstract class SourceBase implements ISource { + protected readonly _observers = new Set>() protected _value!: T lastUpdated = -1 @@ -87,10 +93,11 @@ export abstract class SourceBase implements Source { } } - subscribe(observer: Observer) { this._observers.add(observer) } - unsubscribe(observer: Observer) { this._observers.delete(observer) } + subscribe(observer: IObserver) { this._observers.add(observer) } + unsubscribe(observer: IObserver) { this._observers.delete(observer) } } +// NOCOMMIT not sure I have a use for this (maybe replacement for Subject?) class Atom extends SourceBase { constructor( displayName: string, @@ -105,17 +112,17 @@ class Atom extends SourceBase { override setValue(value: T) { super.setValue(value, true) } } -export abstract class SourceObserverBase extends SourceBase implements Observer { - abstract readonly sources: ReadonlySet> +export abstract class SourceObserverBase extends SourceBase implements IObserver { + abstract readonly sources: ReadonlySet> - override subscribe(observer: Observer): void { + override subscribe(observer: IObserver): void { if (!this._observers.size) this.attach() super.subscribe(observer) } - override unsubscribe(observer: Observer): void { + override unsubscribe(observer: IObserver): void { super.unsubscribe(observer) if (!this._observers.size) @@ -132,132 +139,16 @@ export abstract class SourceObserverBase extends SourceBase source.unsubscribe(this) } - abstract sourceUpdated(source: Source, value: S): void -} - -class Effect implements Observer { - readonly sources = new Set>() - sourceLastUpdated = -1 - lastRun = -1 - - constructor( - public readonly displayName: string, - private _effect: () => T, - private _onSourceUpdatedSinceLastRun: () => void, - private _owner?: Owner - ) { } - - attach() { - for (let source of this.sources) - source.subscribe(this) - } - - detach() { - for (let source of this.sources) - source.unsubscribe(this) - } - - sourceUpdated(source: Source, value: S): void { - if (source.lastUpdated > this.lastRun) { - this.sourceLastUpdated = source.lastUpdated - this._onSourceUpdatedSinceLastRun() - } - } - - run() { - this.lastRun = globalVersion - - let result: T; - let tracker = new SourceTracker(this) - let ownerScope = this._owner ? withOwner(this._owner) : null - - try { - result = this._effect() - } finally { - ownerScope?.[dispose]() - tracker[dispose]() - } - - for (let oldSource of this.sources.difference(tracker.accessed)) { - this.sources.delete(oldSource) - oldSource.unsubscribe(this) - } - - for (let newSource of tracker.accessed.difference(this.sources)) { - this.sources.add(newSource) - newSource.subscribe(this) - } - - return result; - } -} - -class Reaction extends Effect { - constructor( - displayName: string, - reaction: () => void, - private _schedule?: (run: () => void) => void - ) { - super(displayName, reaction, () => { - let wasReactingTo = reactingTo - if (this._schedule) { - this._schedule(() => { - reactingTo = wasReactingTo - try { - this.run() - } finally { - reactingTo = null - } - }) - } else { - this.run() - } - }) - } -} - -class Derivation extends SourceObserverBase { - private _derive: Effect - - constructor( - displayName: string, - derive: () => T, - skipUpdate = Object.is - ) { - super(displayName, skipUpdate) - this._derive = new Effect(displayName, derive, () => { - if (this._observers.size) - this.update() - }, this) - } - - get sources() { return this._derive.sources } - protected override attach() { this._derive.attach() } - protected override detach() { this._derive.detach() } - - sourceUpdated(source: Source, value: S) { this._derive.sourceUpdated(source, value) } - - override get value() { - globalTracker?.sourceAccessed(this) - - if (this._derive.sourceLastUpdated > this._derive.lastRun) - this.update() - - return this._value - } - - private update() { - this.setValue(this._derive.run()) - } + abstract sourceUpdated(source: ISource, value: S): void } class CircularDependencyError extends Error { - constructor(path: Source[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}`) } + constructor(path: ISource[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}`) } } class SideEffectDuringReactionError extends Error { constructor( - public reactingTo: Source, - public causing: Source + public reactingTo: ISource, + public causing: ISource ) { super(`Side effect during reaction to ${reactingTo.displayName} in ${causing.displayName}`) } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index 3557dc4..dae5edb 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -1,11 +1,11 @@ import { dispose, emptySet } from "../utils" import { isModel, Model } from "./models" import { Reduction } from "./reduction" -import { Source } from "./signals" +import { ISource } from "./signals" export type Trackable = Reduction | Model -export type Owner = Source | Model +export type Owner = ISource | Model let ownerStack: Owner[] = [] From 23d5c7609cad668cc6d795d6ee0b97e8fd72264e Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Tue, 3 Mar 2026 17:32:01 +1000 Subject: [PATCH 08/83] Minor cleanup --- packages/event-reduce/src/signals/derivation.ts | 12 +++++++++--- packages/event-reduce/src/signals/effect.ts | 5 +---- packages/event-reduce/src/signals/events.ts | 10 +++++----- packages/event-reduce/src/signals/signals.ts | 10 ++++++---- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 15f3e08..e738a5f 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -1,6 +1,7 @@ -import { nameOfFunction } from "../utils" +import { dispose, nameOfFunction } from "../utils" import { Effect } from "./effect" import { ISource, SourceObserverBase, globalTracker } from "./signals" +import { withOwner } from "./tracking" export interface IDerivation extends ISource { readonly value: T @@ -27,7 +28,7 @@ class Derivation extends SourceObserverBase { this._derive = new Effect(displayName, derive, () => { if (this._observers.size) this.update() - }, this) + }) } get sources() { return this._derive.sources } @@ -47,6 +48,11 @@ class Derivation extends SourceObserverBase { } private update() { - this.setValue(this._derive.run()) + let ownerScope = withOwner(this) + try { + this.setValue(this._derive.run()) + } finally { + ownerScope[dispose]() + } } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/effect.ts b/packages/event-reduce/src/signals/effect.ts index d8b4392..24d069c 100644 --- a/packages/event-reduce/src/signals/effect.ts +++ b/packages/event-reduce/src/signals/effect.ts @@ -10,8 +10,7 @@ export class Effect implements IObserver { constructor( public readonly displayName: string, private _effect: () => T, - private _onSourceUpdatedSinceLastRun: () => void, - private _owner?: Owner + private _onSourceUpdatedSinceLastRun: () => void ) { } attach() { @@ -36,12 +35,10 @@ export class Effect implements IObserver { let result: T; let tracker = new SourceTracker(this) - let ownerScope = this._owner ? withOwner(this._owner) : null try { result = this._effect() } finally { - ownerScope?.[dispose]() tracker[dispose]() } diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index 07d62cb..6950a81 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -91,12 +91,12 @@ export function makeEventFunction = ((...args: Args) => Args[0]) & Event -class Event extends SourceBase { +export class Event extends SourceBase { constructor(displayName: string) { super(displayName, () => false) } emit(value: T) { this.setValue(value, true) } } -class BoundEvent extends SourceObserverBase { +class BoundSource extends SourceObserverBase { readonly sources = new Set>() constructor( @@ -116,13 +116,13 @@ class BoundEvent extends SourceObserverBase { } export function map(source: ISource, transform: (value: In) => Out, displayName = `${source.displayName}.map(${nameOfCallback(transform)})`): ISource { - return new BoundEvent(displayName, source, transform) + return new BoundSource(displayName, source, transform) } export function filter(source: ISource, predicate: (value: In) => value is Out, displayName = `${source.displayName}.filter(${nameOfCallback(predicate)})`): ISource { - return new BoundEvent(displayName, source, identity as (value: In) => Out, (_, next) => !predicate(next)) + return new BoundSource(displayName, source, identity as (value: In) => Out, (_, next) => !predicate(next)) } export function filterMap(source: ISource, transform: (value: In) => Out | undefined, displayName = `${source.displayName}.filterMap(${nameOfCallback(transform)})`): ISource { - return new BoundEvent(displayName, source, transform, (_, next) => next == undefined) + return new BoundSource(displayName, source, transform, (_, next) => next == undefined) } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index d0c0ec4..46e0722 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -5,15 +5,17 @@ export class SourceTracker { private readonly _previous: SourceTracker | null readonly accessed = new Set>() - constructor(private readonly _observer: IObserver) { + constructor(private readonly _observer: IObserver | null) { this._previous = globalTracker globalTracker = this } sourceAccessed(source: ISource) { - let circularDependency = this.findCircularDependency(source) - if (circularDependency) - throw new CircularDependencyError([...circularDependency, source]) + if (this._observer) { + let circularDependency = this.findCircularDependency(source) + if (circularDependency) + throw new CircularDependencyError([...circularDependency, source]) + } this.accessed.add(source) } From 58e0742d291dd3d9639ece57bf1579bfe16d889a Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Tue, 3 Mar 2026 18:49:15 +1000 Subject: [PATCH 09/83] Basic derived state --- packages/event-reduce/src/signals/signals.ts | 10 +++++++++- packages/event-reduce/src/signals/state.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 packages/event-reduce/src/signals/state.ts diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 46e0722..8bad517 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,4 +1,6 @@ -import { dispose } from "../utils" +import { dispose, isObject } from "../utils" +import { derive, IDerivation } from "./derivation" +import { getState, State } from "./state" import { updateOwnedValues } from "./tracking" export class SourceTracker { @@ -58,6 +60,7 @@ export interface IObserver { } export abstract class SourceBase implements ISource { + private _json?: IDerivation> protected readonly _observers = new Set>() protected _value!: T lastUpdated = -1 @@ -67,6 +70,11 @@ export abstract class SourceBase implements ISource { private _skipUpdate = Object.is ) { } + toJSON() { + // Lazy init to avoid infinite loop in Derivation constructor + return (this._json ??= derive(() => getState(this.value), `toJSON(${this.displayName})`)).value + } + protected get value() { globalTracker?.sourceAccessed(this) return this._value diff --git a/packages/event-reduce/src/signals/state.ts b/packages/event-reduce/src/signals/state.ts new file mode 100644 index 0000000..45e0fd0 --- /dev/null +++ b/packages/event-reduce/src/signals/state.ts @@ -0,0 +1,16 @@ +import { dispose, isObject } from "../utils" + +export type State = T + +export let serializeDerivedValues = false + +export function derivedValuedSerialization() { + let previous = serializeDerivedValues + return { [dispose]() { serializeDerivedValues = previous } } +} + +export function getState(model: T): State { + return isObject(model) && 'toJSON' in model + ? (model as any).toJSON() + : model +} \ No newline at end of file From 2d4c3ffa1576d7b77fc0448b3ab3205bcda5301e Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Tue, 3 Mar 2026 21:25:14 +1000 Subject: [PATCH 10/83] Model decorators --- .../event-reduce/src/signals/derivation.ts | 6 +- packages/event-reduce/src/signals/events.ts | 41 ++- packages/event-reduce/src/signals/models.ts | 348 +++++++++++++++++- .../event-reduce/src/signals/reduction.ts | 2 - packages/event-reduce/src/signals/signals.ts | 37 +- 5 files changed, 393 insertions(+), 41 deletions(-) diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index e738a5f..0ebf045 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -1,6 +1,6 @@ import { dispose, nameOfFunction } from "../utils" import { Effect } from "./effect" -import { ISource, SourceObserverBase, globalTracker } from "./signals" +import { ISource, SourceObserverBase, trackSourceAccessed } from "./signals" import { withOwner } from "./tracking" export interface IDerivation extends ISource { @@ -16,7 +16,7 @@ export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, return new Derivation(displayName ?? nameOfFunction(derive), derive, skipUpdate) } -class Derivation extends SourceObserverBase { +export class Derivation extends SourceObserverBase { private _derive: Effect constructor( @@ -39,7 +39,7 @@ class Derivation extends SourceObserverBase { override get value() { // Notifying tracker before checking for updates so circular reference is detected early - globalTracker?.sourceAccessed(this) + trackSourceAccessed(this) if (this._derive.sourceLastUpdated > this._derive.lastRun) this.update() diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index 6950a81..ec1ca4c 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -2,20 +2,34 @@ import { dispose, identity, nameOfCallback } from "../utils" import { startLogging } from "./logging" import { ISource, SourceBase, SourceObserverBase } from "./signals" -export function event(name?: string) { +export interface IEvent extends ISource { + (...args: Args): Args[0] +} + +export function event(name?: string): IEvent { let e = new Event(name ?? "(anonymous event)") - return makeEventFunction(e, (value: T) => { - let logging = startLogging('⚡ (event)', e.displayName, [value]) // NOCOMMIT Container + return makeEventFunction(e, (event: T) => { + let logging = startLogging('⚡ (event)', e.displayName, [event]) // NOCOMMIT Container try { - e.emit(value) + e.emit(event) } finally { logging?.[dispose]() } - return value + return event }) } -export interface AsyncEventFn extends EventFn<[PromiseLike, Context], AsyncValue> { +export class Event extends SourceBase { + constructor(displayName: string) { super(displayName, () => false) } + emit(value: T) { this.setValue(value, true) } +} + +export function isEvent(value: any): value is IEvent { + return typeof value == 'function' + && value instanceof Event +} + +export interface IAsyncEvent extends IEvent, [PromiseLike, Context]> { started: ISource> resolved: ISource> rejected: ISource> @@ -41,9 +55,9 @@ export interface AsyncError { error: any } -export function asyncEvent(name?: string): AsyncEventFn { - let e = new Event | AsyncResult | AsyncError>(name ?? "(anonymous async event)") - let fn = makeEventFunction(e,

>(promise: P, context: Context) => { +export function asyncEvent(name?: string): IAsyncEvent { + let e = new Event>(name ?? "(anonymous async event)") + let fn = makeEventFunction(e,

>(promise: P, context: Context) => { let logging = startLogging('⚡⌚ (async event)', e.displayName, [promise, context], () => ({ Promise: promise })) // NOCOMMIT Container try { e.emit({ promise, context }) @@ -86,14 +100,7 @@ export function makeEventFunction; -} - -type EventFn = ((...args: Args) => Args[0]) & Event - -export class Event extends SourceBase { - constructor(displayName: string) { super(displayName, () => false) } - emit(value: T) { this.setValue(value, true) } + return eventFn as IEvent; } class BoundSource extends SourceObserverBase { diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index dcbaf77..68bb96b 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -1,19 +1,341 @@ -export type Model = { [modelBrand]: true } -const modelBrand = Symbol('signal model') +import { argsAre, constant, getAllPropertyDescriptors, isFunction, isObject, isString, isUndefined, nameOfFunction } from "../utils" +import { Derivation, derive } from "./derivation" +import { isEvent } from "./events" +import { Reduction } from "./reduction" +import { getUnderlyingSource, ISource, isSource } from "./signals" +import { getState } from "./state" -export function asModel(value: T): T & Model { - Object.defineProperty(value, modelBrand, { - value: true, - enumerable: false, - configurable: false, - writable: false - }) +export type Model = { [modelProperties]: Record } - return value as T & Model +type UninitializedModel = { [modelPropertyFactories]?: Record ModelProperty> } +type ModelPrototype = { [prototypePropertyFactories]?: Record ModelProperty> } + +type ModelProperty = ISource | StateProperty + +const modelPropertyFactories = Symbol('model property factories') +const prototypePropertyFactories = Symbol('prototype property factories') +const modelProperties = Symbol('model properties') +const eventsBrand = Symbol('events class') + +/** + * Marks a class as a model. + * Initializes \@{@link reduced} and \@{@link derived} properties. + * Observable values accessed during construction will not be tracked. + * The model instance will take ownership of all observable and state properties. + **/ +export function model any>(target: Class): Class { + const className = nameOfFunction(target) + return { + [className]: class extends target { + [modelProperties] = {} as Record + constructor(...args: any[]) { + super(...args) + + let prototypeFactories = (target.prototype as ModelPrototype)?.[prototypePropertyFactories] + if (prototypeFactories) + for (let key of Object.keys(prototypeFactories)) + this[modelProperties][key] = prototypeFactories[key](this) + + let instanceFactories = (this as UninitializedModel)[modelPropertyFactories] + if (instanceFactories) + for (let key of Object.keys(instanceFactories)) + this[modelProperties][key] = instanceFactories[key]() + + for (let [key, property] of Object.entries(this[modelProperties])) { + if (isSource(property)) { // No need to do this for state properties + Object.defineProperty(this, key, { + // Bind directly to the property for cleaner call stack (NOCOMMIT check this works) + get: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(property), 'value')!.get!.bind(property), + enumerable: true, + configurable: true + }) + } else if (process.env.NODE_ENV !== 'production' + && (isEvent(property.value) || isEventsClass(property.value)) + ) { + throw new EventsMarkedAsStateError(this, key) + } + } + } + + #json = derive(() => getState(this[modelProperties]), `toJSON(${className})`) + + toJSON() { + return 'toJSON' in target + ? super.toJSON() + : this.#json.value + } + } + }[className] +} + +export function reduced(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): ClassAccessorDecoratorResult +export function reduced(getter: () => Value, context: ClassGetterDecoratorContext): void +export function reduced(prototype: object, key: string | symbol): void +export function reduced(...args: + | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] + | [() => Value, ClassGetterDecoratorContext] + | [Object, string | symbol] +) { + if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { + // Auto-accessor property decorator + let [_, context] = args + + return { + init(this: UninitializedModel, initial: Value) { + let reduction = getUnderlyingReduction(this, context.name, () => initial) + getModelPropertyFactories(this)[context.name] = constant(reduction) + return initial + }, + get(this: Target) { throw new MissingModelDecoratorError(model, context.name) } + } + } else if (argsAre(args, isFunction, isDecoratorContext)) { + // Getter property decorator + let [getter, context] = args + + context.addInitializer(function (this: Target) { + getModelPropertyFactories(this)[context.name] = getUnderlyingReduction.bind(null, this, context.name, getter) + }) + + } else { + // Legacy decorator + let [prototype, key] = args + let property = Object.getOwnPropertyDescriptor(prototype, key)! + + if (property?.get) { + let getter = property.get + + getPrototypePropertyFactories(prototype)[key] = instance => getUnderlyingReduction(instance, key, getter) + + return { + get(this: Target) { throw new MissingModelDecoratorError(model, key) }, + enumerable: true, + configurable: true + } + } else { + return sourceSetter(key, Reduction, () => new InvalidReducedPropertyError(key)) + } + } + + function getUnderlyingReduction(instance: any, key: PropertyKey, getter: Function) { + let reduction = getUnderlyingSource(getter.call(instance)) + if (!(reduction instanceof Reduction)) + throw new InvalidReducedPropertyError(key) + return reduction + } +} + +export function derived(getter: () => Value, context: ClassGetterDecoratorContext): void +export function derived(valuesEqual: (previous: Value, next: Value) => boolean): { (getter: () => Value, context: ClassGetterDecoratorContext): void; (prototype: Object, key: string | symbol): void; } +export function derived(prototype: object, key: string | symbol): void +export function derived(...args: + | [(previous: Value, next: Value) => boolean] + | [() => Value, ClassGetterDecoratorContext] + | [object, string | symbol] +) { + let valuesEqual = undefined as ((previous: any, next: any) => boolean) | undefined + + if (argsAre(args, isFunction)) { + [valuesEqual] = args + return decorate + } else if (argsAre(args, isFunction, isDecoratorContext)) { + return decorate(...args) + } else { + return decorate(...args) + } + + function decorate(getter: () => Value, context: ClassGetterDecoratorContext): void + function decorate(prototype: object, key: string | symbol): void + function decorate(...args: + | [() => Value, ClassGetterDecoratorContext] + | [object, string | symbol] + ) { + if (argsAre(args, isFunction, isDecoratorContext)) { + // Getter property decorator + let [getter, context] = args + context.addInitializer(function (this: Target) { + let derived = derive(getter.bind(this), valuesEqual ?? Object.is, String(context.name)) + getModelPropertyFactories(this)[context.name] = constant(derived) + }) + } else { + // Legacy decorator + let [prototype, key] = args + let property = Object.getOwnPropertyDescriptor(prototype, key)! + + if (property?.get) { + let getter = property.get + let createDerivation = (instance: any) => derive(getter.bind(instance), valuesEqual ?? Object.is, String(key)) + getPrototypePropertyFactories(prototype)[key] = createDerivation + return { + get(this: Target) { throw new MissingModelDecoratorError(model, key) }, + enumerable: true, + configurable: true + } + } + + return sourceSetter(key, Derivation, () => new InvalidDerivedPropertyError(key)) + } + } +} + +/** Mark a property as part of the model's state. */ +export function state(value: undefined, context: ClassFieldDecoratorContext): void +/** Mark a property as part of the model's state. */ +export function state(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): void +/** Mark a constructor parameter as a state property by specifying its name. */ +export function state(property: Key): (target: Function, ...rest: any) => void //..rest: any to support both constructor parameters and classes +/** Mark a property as a state property. */ +export function state(target: object, key: string): void +export function state(...args: + | string[] + | [undefined, ClassFieldDecoratorContext] + | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] + | [string] + | [object, string] +) { + if (argsAre(args, isObject, isString)) { + // Legacy decorator + let [instance, key] = args + getModelPropertyFactories(instance)[key] = constant(new StateProperty(instance, key)) + } else if (argsAre(args, isUndefined, isClassFieldDecoratorContext)) { + // Field decorator + let [_, context] = args + context.addInitializer(function (this: Model) { + getModelPropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) + }) + } else if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { + // Auto-accessor property decorator + let [_, context] = args + context.addInitializer(function (this: Model) { + getModelPropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) + }) + } else { + // Class decorator, or legacy constructor parameter decorator + return function decorate(ctor: new (...ctorArgs: any[]) => any) { + for (let key of args) + getPrototypePropertyFactories(ctor.prototype)[key] = instance => new StateProperty(instance, key) + } + } +} + +/** + * Marks a class as an events class. + * Automatically populates the names of event properties. + * Events returned from getters will be snapshotted, so the getter function is only called once. + */ +export let events = (target: T): T => { + const className = nameOfFunction(target) + return { + [className]: class extends target { + constructor(...args: any[]) { + super(...args) + for (let [key, property] of getAllPropertyDescriptors(this)) { + let value = this[key] + if (isEvent(value)) { + value.displayName = key + // value.container = this // NOCOMMIT container + if (property.get) + Object.defineProperty(this, key, { + value, + configurable: true, + enumerable: true + }) + } + } + (this as any)[eventsBrand] = true + } + } + }[className] +} + +export function isEventsClass(value: unknown): value is Object { + return value instanceof Object + && eventsBrand in value +} + +class StateProperty { + constructor(private _instance: any, private _key: PropertyKey) { } + toJSON() { return this.value } + get value() { return this._instance[this._key] } +} + +function isClassFieldDecoratorContext(context: ClassFieldDecoratorContext | unknown): context is ClassFieldDecoratorContext { + return isDecoratorContext(context) && context.kind == 'field' +} + +function isClassAccessorDecoratorTarget(context: ClassAccessorDecoratorTarget | unknown): context is ClassAccessorDecoratorTarget { + return isObject(context) && !!(context as ClassAccessorDecoratorTarget).get +} + +function isClassAccessorDecoratorContext(context: ClassAccessorDecoratorContext | unknown): context is ClassAccessorDecoratorContext { + return isDecoratorContext(context) && context.kind == 'accessor' +} + +function isDecoratorContext(context: ClassMemberDecoratorContext | unknown): context is DecoratorContext { + return isObject(context) && !!(context as ClassMemberDecoratorContext).kind +} + +/** For new decorators which have access to the instance. */ +function getModelPropertyFactories(instance: object) { + return (instance as UninitializedModel)[modelPropertyFactories] ??= {} +} + +/** For class decorators and legacy decorators that don't have access to the instance. */ +function getPrototypePropertyFactories(prototype: object) { + return (prototype as ModelPrototype)[prototypePropertyFactories] ??= {} +} + +function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISource }, errorFactory: () => Error): PropertyDescriptor { + return { set } + + function set(this: object, value: unknown) { + let source = getUnderlyingSource(value) + if (!(source instanceof sourceType)) + throw errorFactory() + + Object.defineProperty(this, key, { + get: () => { throw new MissingModelDecoratorError(model, key) }, + set, + enumerable: true, + configurable: true + }) + } } export function isModel(value: unknown): value is Model { - return !!value - && typeof value == 'object' - && modelBrand in value + return isObject(value) + && modelProperties in value } + +export class MissingModelDecoratorError extends Error { + constructor( + public model: object, + public property: PropertyKey + ) { + super(`${nameOfFunction(model.constructor)} class must be marked as a @model to use @reduced, @derived, or @state properties.`) + } +} + +export class InvalidReducedPropertyError extends Error { + constructor( + public property: PropertyKey + ) { + super(`@reduced property '${String(property)}' must return, or be set to, the value of a reduction.`) + } +} + +export class InvalidDerivedPropertyError extends Error { + constructor( + public property: string | symbol + ) { + super(`@derived property ${String(property)} must have a getter or be set to the value of a derivation.`) + } +} + +export class EventsMarkedAsStateError extends Error { + constructor( + public model: object, + public property: string | symbol + ) { + super(`Model property ${String(property)} returns an event or events class, and cannot be marked as @state.`) + } +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index 93213d5..2071265 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -54,8 +54,6 @@ export class Reduction extends SourceObserverBase { return this } - override get value() { return super.value } - sourceUpdated(source: ISource, value: S) { let reduce = this._reducers.get(source) if (reduce) { diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 8bad517..8705652 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,4 +1,4 @@ -import { dispose, isObject } from "../utils" +import { dispose } from "../utils" import { derive, IDerivation } from "./derivation" import { getState, State } from "./state" import { updateOwnedValues } from "./tracking" @@ -36,10 +36,28 @@ export class SourceTracker { } } -export let globalTracker: SourceTracker | null = null +let globalTracker: SourceTracker | null = null +let lastSourceAccessed: ISource | null = null export let globalVersion = 0 export let reactingTo: ISource | null = null +export function trackSourceAccessed(source: ISource) { + globalTracker?.sourceAccessed(source) + lastSourceAccessed = source +} + +export function getUnderlyingSource(value: T): ISource | null { + return lastSourceAccessed + && lastSourceAccessed instanceof SourceBase + && Object.is(lastSourceAccessed.value, value) + ? lastSourceAccessed as ISource + : null +} + +export function isSource(value: any): value is ISource { + return value instanceof SourceBase +} + export function startReactingTo(source: ISource) { let previouslyReactingTo = reactingTo reactingTo = source @@ -74,9 +92,9 @@ export abstract class SourceBase implements ISource { // Lazy init to avoid infinite loop in Derivation constructor return (this._json ??= derive(() => getState(this.value), `toJSON(${this.displayName})`)).value } - - protected get value() { - globalTracker?.sourceAccessed(this) + + get value() { + trackSourceAccessed(this) return this._value } @@ -118,7 +136,6 @@ class Atom extends SourceBase { this.setValue(initialValue) } - override get value() { return super.value } override setValue(value: T) { super.setValue(value, true) } } @@ -161,4 +178,12 @@ class SideEffectDuringReactionError extends Error { public reactingTo: ISource, public causing: ISource ) { super(`Side effect during reaction to ${reactingTo.displayName} in ${causing.displayName}`) } +} + +export class NotASourceValueError extends Error { + constructor( + public value: unknown + ) { + super("Couldn't detect source for value. Make sure you pass in an observable value directly."); + } } \ No newline at end of file From 1f59b6ce1ecac2fd108d4618ac171416b1993206 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Tue, 3 Mar 2026 21:42:44 +1000 Subject: [PATCH 11/83] Reductions update themselves if accessed during another reduction --- .../event-reduce/src/signals/reduction.ts | 35 ++++++++++++++++--- tsconfig.base.json | 2 +- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index 2071265..d2d61e8 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,5 +1,5 @@ import { dispose } from "../utils" -import { ISource, SourceObserverBase } from "./signals" +import { globalVersion, ISource, SourceBase, SourceObserverBase, SourceTracker, trackSourceAccessed } from "./signals" import { currentOwnerScope } from "./tracking" export interface IReduction extends ISource { @@ -23,6 +23,7 @@ export type Reducer = (current: Result, next: Source) => Result export class Reduction extends SourceObserverBase { sources = new Set>() private _reducers = new Map, (current: T, next: any) => T>() + private _sourcesLastChecked = -1 private _attached = false private _disposed = false @@ -55,13 +56,37 @@ export class Reduction extends SourceObserverBase { } sourceUpdated(source: ISource, value: S) { - let reduce = this._reducers.get(source) - if (reduce) { - let newValue = reduce(this._value, value) - this.setValue(newValue) + if (source.lastUpdated > this.lastUpdated) { + let reduce = this._reducers.get(source) + if (reduce) { + let tracker = new SourceTracker(this) // Required for detecting circular references in reducers + try { + let newValue = reduce(this._value, value) + this.setValue(newValue) + } finally { + tracker[dispose]() + } + } } } + override get value() { + // Notifying tracker before checking for updates so circular reference is detected early + trackSourceAccessed(this) + + if (this._sourcesLastChecked < globalVersion) + for (let source of this.sources) // At most one source should be newer + this.sourceUpdated(source, (source as SourceBase).value) + + return this._value // Not using super.value to avoid duplicate tracker notification + } + + override setValue(value: T) { + super.setValue(value) + this._sourcesLastChecked = globalVersion + } + + // NOCOMMIT shouldn't be required for reductions protected override attach() { if (this._attached || this._disposed) return diff --git a/tsconfig.base.json b/tsconfig.base.json index 8b77777..70da4ca 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "es2024", "module": "commonjs", - "lib": ["ES2024", "ESNext.Collection", "ESNext.Disposable", "dom"], + "lib": ["ES2024", "ESNext.Collection", "ESNext.Iterator", "ESNext.Disposable", "dom"], "jsx": "react-jsx", "strict": true, "noImplicitOverride": true, From dda97ef6f0511aa6d7ba9eac57ee0b5bdb8b4b9c Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 4 Mar 2026 07:59:28 +1000 Subject: [PATCH 12/83] Moving event map/filter/filterMap into SourceBase --- packages/event-reduce/src/signals/events.ts | 41 +++----------------- packages/event-reduce/src/signals/signals.ts | 26 ++++++++++++- 2 files changed, 29 insertions(+), 38 deletions(-) diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index ec1ca4c..bc61412 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -1,6 +1,6 @@ -import { dispose, identity, nameOfCallback } from "../utils" +import { dispose } from "../utils" import { startLogging } from "./logging" -import { ISource, SourceBase, SourceObserverBase } from "./signals" +import { ISource, SourceBase } from "./signals" export interface IEvent extends ISource { (...args: Args): Args[0] @@ -89,9 +89,9 @@ export function asyncEvent(name?: string): IAsync }) return Object.assign(fn, { - started: filter(e, v => 'promise' in v, `${e.displayName}.started`), - resolved: filter(e, v => 'result' in v, `${e.displayName}.resolved`), - rejected: filter(e, v => 'error' in v, `${e.displayName}.rejected`) + started: e.filter(v => 'promise' in v, `${e.displayName}.started`), + resolved: e.filter(v => 'result' in v, `${e.displayName}.resolved`), + rejected: e.filter(v => 'error' in v, `${e.displayName}.rejected`) }) } @@ -101,35 +101,4 @@ export function makeEventFunction; -} - -class BoundSource extends SourceObserverBase { - readonly sources = new Set>() - - constructor( - displayName: string, - source: ISource, - private _transform = identity as (value: In) => Out, - skipUpdate = (old: Out, next: Out) => false - ) { - super(displayName, skipUpdate) - this.sources.add(source) - } - - sourceUpdated(source: ISource, value: S): void { - let newValue = this._transform(value) - this.setValue(newValue) - } -} - -export function map(source: ISource, transform: (value: In) => Out, displayName = `${source.displayName}.map(${nameOfCallback(transform)})`): ISource { - return new BoundSource(displayName, source, transform) -} - -export function filter(source: ISource, predicate: (value: In) => value is Out, displayName = `${source.displayName}.filter(${nameOfCallback(predicate)})`): ISource { - return new BoundSource(displayName, source, identity as (value: In) => Out, (_, next) => !predicate(next)) -} - -export function filterMap(source: ISource, transform: (value: In) => Out | undefined, displayName = `${source.displayName}.filterMap(${nameOfCallback(transform)})`): ISource { - return new BoundSource(displayName, source, transform, (_, next) => next == undefined) } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 8705652..825336f 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,8 +1,15 @@ -import { dispose } from "../utils" +import { dispose, nameOfCallback } from "../utils" import { derive, IDerivation } from "./derivation" import { getState, State } from "./state" import { updateOwnedValues } from "./tracking" +/** + * TODO + * - Logging in derivations and reductions + * - Source "path" built by ownership (include derivations?) + * - extend + */ + export class SourceTracker { private readonly _previous: SourceTracker | null readonly accessed = new Set>() @@ -67,6 +74,9 @@ export function startReactingTo(source: ISource) { export interface ISource { displayName: string readonly lastUpdated: number + map(transform: (value: T) => Out, displayName?: string): ISource + filter(predicate: (value: T) => value is Out, displayName?: string): ISource + filterMap(transform: (value: T) => Out | undefined, displayName?: string): ISource subscribe(observer: IObserver): void unsubscribe(observer: IObserver): void } @@ -92,7 +102,7 @@ export abstract class SourceBase implements ISource { // Lazy init to avoid infinite loop in Derivation constructor return (this._json ??= derive(() => getState(this.value), `toJSON(${this.displayName})`)).value } - + get value() { trackSourceAccessed(this) return this._value @@ -121,6 +131,18 @@ export abstract class SourceBase implements ISource { } } + map(transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`) { + return derive(() => transform(this.value), `${displayName}.map(${nameOfCallback(transform)})`) + } + + filter(predicate: (value: T) => value is Out, displayName = `${this.displayName}.filter(${nameOfCallback(predicate)})`) { + return derive(() => this.value as Out, (_, next) => !predicate(next), `${displayName}.filter(${nameOfCallback(predicate)})`) + } + + filterMap(transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`) { + return derive(() => transform(this.value) as Out, (_, next) => next == undefined, `${displayName}.filterMap(${nameOfCallback(transform)})`) + } + subscribe(observer: IObserver) { this._observers.add(observer) } unsubscribe(observer: IObserver) { this._observers.delete(observer) } } From 974276c3b435710f369f9a8dec44a9331dcf3010 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 4 Mar 2026 23:27:44 +1000 Subject: [PATCH 13/83] Event scoping Simplified event type creation Made skipUpdate a protected method on SourceBase Reduction actually uses the initialValue --- .../event-reduce/src/signals/derivation.ts | 6 +- packages/event-reduce/src/signals/events.ts | 103 ++++++++++++------ packages/event-reduce/src/signals/models.ts | 7 +- .../event-reduce/src/signals/reduction.ts | 10 +- packages/event-reduce/src/signals/scoping.ts | 49 +++++++++ packages/event-reduce/src/signals/signals.ts | 15 +-- packages/event-reduce/src/signals/tracking.ts | 2 + 7 files changed, 144 insertions(+), 48 deletions(-) create mode 100644 packages/event-reduce/src/signals/scoping.ts diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 0ebf045..c421661 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -22,9 +22,9 @@ export class Derivation extends SourceObserverBase { constructor( displayName: string, derive: () => T, - skipUpdate = Object.is + private _skipUpdate = Object.is ) { - super(displayName, skipUpdate) + super(displayName) this._derive = new Effect(displayName, derive, () => { if (this._observers.size) this.update() @@ -47,6 +47,8 @@ export class Derivation extends SourceObserverBase { return this._value // Not using super.value to avoid duplicate tracker notification } + protected override skipUpdate(nextValue: T) { return this._skipUpdate(this._value, nextValue) } + private update() { let ownerScope = withOwner(this) try { diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index bc61412..7d320b8 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -1,32 +1,28 @@ import { dispose } from "../utils" import { startLogging } from "./logging" +import { applyScope, DeepPartial, matchesScope, Scoped } from "./scoping" import { ISource, SourceBase } from "./signals" -export interface IEvent extends ISource { +export interface IEvent extends ISource { (...args: Args): Args[0] } -export function event(name?: string): IEvent { - let e = new Event(name ?? "(anonymous event)") - return makeEventFunction(e, (event: T) => { - let logging = startLogging('⚡ (event)', e.displayName, [event]) // NOCOMMIT Container +export function event(name: string = "(anonymous event)"): IEvent { + return makeEventFunction(name, (emit: (value: T) => void, event: T) => { + let logging = startLogging('⚡ (event)', name, [event]) // NOCOMMIT Container try { - e.emit(event) + emit(event) } finally { logging?.[dispose]() } return event - }) -} - -export class Event extends SourceBase { - constructor(displayName: string) { super(displayName, () => false) } - emit(value: T) { this.setValue(value, true) } -} - -export function isEvent(value: any): value is IEvent { - return typeof value == 'function' - && value instanceof Event + }, e => ({ + scope>(scope: Scope) { + return bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, + (value: Scoped) => e(applyScope(value, scope)), + value => matchesScope(value, scope)) + } + })) } export interface IAsyncEvent extends IEvent, [PromiseLike, Context]> { @@ -55,12 +51,11 @@ export interface AsyncError { error: any } -export function asyncEvent(name?: string): IAsyncEvent { - let e = new Event>(name ?? "(anonymous async event)") - let fn = makeEventFunction(e,

>(promise: P, context: Context) => { - let logging = startLogging('⚡⌚ (async event)', e.displayName, [promise, context], () => ({ Promise: promise })) // NOCOMMIT Container +export function asyncEvent(name: string = "(anonymous async event)"): IAsyncEvent { + return makeEventFunction(name,

>(emit: (value: AsyncValue) => void, promise: P, context: Context) => { + let logging = startLogging('⚡⌚ (async event)', name, [promise, context], () => ({ Promise: promise })) // NOCOMMIT Container try { - e.emit({ promise, context }) + emit({ promise, context }) } finally { logging?.[dispose]() } @@ -68,37 +63,75 @@ export function asyncEvent(name?: string): IAsync promise.then( result => { let val = { result, context } - let logging = startLogging('⚡✔ (async resolved)', e.displayName, [val], () => ({ Promise: promise })) // NOCOMMIT Container + let logging = startLogging('⚡✔ (async resolved)', name, [val], () => ({ Promise: promise })) // NOCOMMIT Container try { - e.emit(val) + emit(val) } finally { logging?.[dispose]() } }, error => { let val = { error, context } - let logging = startLogging('⚡✘ (async rejected)', e.displayName, [val], () => ({ Promise: promise })) // NOCOMMIT Container + let logging = startLogging('⚡✘ (async rejected)', name, [val], () => ({ Promise: promise })) // NOCOMMIT Container try { - e.emit(val) + emit(val) } finally { logging?.[dispose]() } }) return promise - }) - - return Object.assign(fn, { + }, e => ({ started: e.filter(v => 'promise' in v, `${e.displayName}.started`), resolved: e.filter(v => 'result' in v, `${e.displayName}.resolved`), - rejected: e.filter(v => 'error' in v, `${e.displayName}.rejected`) - }) + rejected: e.filter(v => 'error' in v, `${e.displayName}.rejected`), + scope>(scope: Scope) { + return bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, + (promise: PromiseLike, context: Scoped) => e(promise, applyScope(context, scope)), + value => matchesScope(value.context, scope)) + } + })) } /** Converts an event class into a callable function */ -export function makeEventFunction(event: Event, eventFn: (...args: Args) => Return) { - Object.setPrototypeOf(eventFn, event); +export function makeEventFunction( + name: string, + fire: (emit: (event: T) => void, ...args: Args) => Return, + getMembers?: (event: IEvent) => Members +) { + let eventBase = new Event(name) + let eventFn = fire.bind(null, eventBase.emit) as IEvent + Object.setPrototypeOf(eventFn, eventBase); eventFn.apply = Function.prototype.apply; eventFn.call = Function.prototype.call; - return eventFn as IEvent; -} \ No newline at end of file + return Object.assign(eventFn, getMembers?.(eventFn as IEvent) ?? {}) as IEvent & Members +} + +class Event extends SourceBase { + emit = (value: T) => this.setValue(value, true) +} + +export function isEvent(value: any): value is IEvent { + return typeof value == 'function' + && value instanceof Event +} + +/** + * Transforms an event by wrapping the function, and filtering the emitted values. + * Useful for scope functions. +*/ +export function bindEvent, ScopedArgs extends any[]>( + baseEvent: E, + displayName: string, + scopedFn: (...args: ScopedArgs) => ReturnType, + eventPredicate: (value: EventValue) => boolean +): IEvent, ScopedArgs> { + let scopedEvent = scopedFn as IEvent, ScopedArgs> + Object.setPrototypeOf(scopedEvent, Object.create(baseEvent, { + skipUpdate: { value: (next: EventValue) => (baseEvent as any).skipUpdate(next) || !eventPredicate(next) } + })); + scopedEvent.displayName = displayName + return scopedEvent +} + +type EventValue> = Event extends IEvent ? T : never \ No newline at end of file diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index 68bb96b..d25e663 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -49,7 +49,7 @@ export function model any>(target: Class): enumerable: true, configurable: true }) - } else if (process.env.NODE_ENV !== 'production' + } else if (process.env.NODE_ENV !== 'production' && (isEvent(property.value) || isEventsClass(property.value)) ) { throw new EventsMarkedAsStateError(this, key) @@ -306,6 +306,11 @@ export function isModel(value: unknown): value is Model { && modelProperties in value } +// NOCOMMIT remove - just for compiling temp tests +export function asModel(value: T): T & Model { + return Object.assign(value, { [modelProperties]: {} }) as T & Model +} + export class MissingModelDecoratorError extends Error { constructor( public model: object, diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index d2d61e8..6301960 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -15,7 +15,7 @@ export function reduce(initialValue: T, eventsOrDisplayName?: Events ? [undefined as Events, eventsOrDisplayName] : [eventsOrDisplayName, maybeDisplayName] - return new Reduction(displayName ?? "(anonymous reduction)", Object.is, events) + return new Reduction(displayName ?? "(anonymous reduction)", initialValue, Object.is, events) } export type Reducer = (current: Result, next: Source) => Result @@ -29,14 +29,16 @@ export class Reduction extends SourceObserverBase { constructor( displayName: string, - isEqual = Object.is, + initialValue: T, + private _isEqual = Object.is, private _events?: Events ) { - super(displayName, isEqual) + super(displayName) if (!currentOwnerScope()) throw new OwnerlessReductionError(displayName) + this._value = initialValue this.attach() } @@ -86,6 +88,8 @@ export class Reduction extends SourceObserverBase { this._sourcesLastChecked = globalVersion } + override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue) } + // NOCOMMIT shouldn't be required for reductions protected override attach() { if (this._attached || this._disposed) diff --git a/packages/event-reduce/src/signals/scoping.ts b/packages/event-reduce/src/signals/scoping.ts new file mode 100644 index 0000000..213882b --- /dev/null +++ b/packages/event-reduce/src/signals/scoping.ts @@ -0,0 +1,49 @@ +import { isObject } from "../utils" + +export function applyScope>(target: Scoped, scope: S): T { + if (isObject(target) && isObject(scope)) { + if (Array.isArray(target) && Array.isArray(scope)) { + let result = target.slice() as any + scope.forEach((s, i) => result[i] = applyScope(result[i], s)) + return result + } else { + let result = Object.create(target) as any + Object.entries(scope) + .forEach(([key, scopeValue]) => result[key] = applyScope((target as any)[key], scopeValue as any)) + return result + } + } else { + return target ?? scope as any + } +} + +export function matchesScope(value: unknown, scope: unknown): boolean { + if (isObject(scope)) { + if (Array.isArray(scope)) + return Array.isArray(value) + && scope.every((s, i) => matchesScope((value as any)[i], s)) + else + return isObject(value) + && Object.entries(scope).every(([key, scopeValue]) => matchesScope((value as any)[key], scopeValue)) + } + + return Object.is(scope, value) +} + +export type DeepPartial = { [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] } + +export type Scoped> = + T extends object + ? ( + // Properties not in scope + & { [P in keyof T as P extends keyof Scope ? never : P]: T[P] } + // Non-objects in scope + & { [P in keyof T as P extends keyof Scope ? T[P] extends object ? never : P : never]: T[P] } + // Objects partially specified in scope + & { [P in keyof T as P extends keyof Scope ? T[P] extends object ? Scope[P] extends T[P] ? never : P : never : never]: T[P] extends object ? Scope[P] extends object ? Prettify> : never : never } + // Objects fully specified in scope + & { [P in keyof T as P extends keyof Scope ? T[P] extends object ? Scope[P] extends T[P] ? P : never : never : never]?: T[P] extends object ? Scope[P] extends object ? Prettify> : never : never } + ) + : T + +type Prettify = { [K in keyof T]: T[K]; } & {} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 825336f..974687e 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -93,10 +93,7 @@ export abstract class SourceBase implements ISource { protected _value!: T lastUpdated = -1 - constructor( - public displayName: string, - private _skipUpdate = Object.is - ) { } + constructor(public displayName: string) { } toJSON() { // Lazy init to avoid infinite loop in Derivation constructor @@ -112,7 +109,7 @@ export abstract class SourceBase implements ISource { if (newVersion && reactingTo) throw new SideEffectDuringReactionError(reactingTo, this) - if (!this._skipUpdate(this._value, newValue)) { + if (!this.skipUpdate(newValue)) { let previousValue = this._value this._value = newValue updateOwnedValues(this, previousValue, newValue) @@ -131,6 +128,8 @@ export abstract class SourceBase implements ISource { } } + protected skipUpdate(nextValue: T) { return Object.is(this._value, nextValue) } + map(transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`) { return derive(() => transform(this.value), `${displayName}.map(${nameOfCallback(transform)})`) } @@ -152,13 +151,15 @@ class Atom extends SourceBase { constructor( displayName: string, initialValue: T, - _isEqual = Object.is + private _isEqual = Object.is ) { - super(displayName, _isEqual) + super(displayName) this.setValue(initialValue) } override setValue(value: T) { super.setValue(value, true) } + + override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue) } } export abstract class SourceObserverBase extends SourceBase implements IObserver { diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index dae5edb..c0e9e4b 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -30,6 +30,8 @@ export function removeOwner(trackable: Trackable, owner: Owner) { owners.delete(owner) if (!owners.size) { + // NOCOMMIT register to be disposed at the end of the current operation + // In case an owner is added again. Also handle models. ownerRegistry.delete(trackable) if (trackable instanceof Reduction) trackable[dispose]() From a7bd6a03977b5c3cfe277b130c11259caddc35e1 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Fri, 6 Mar 2026 21:56:08 +1000 Subject: [PATCH 14/83] Tracking refactor Better naming Throwing for loose Reductions Unowned objects disposed at end of transition --- .../event-reduce/src/signals/derivation.ts | 11 +- packages/event-reduce/src/signals/effect.ts | 8 +- packages/event-reduce/src/signals/models.ts | 78 ++++--- packages/event-reduce/src/signals/reaction.ts | 8 +- .../event-reduce/src/signals/reduction.ts | 22 +- packages/event-reduce/src/signals/signals.ts | 94 +------- packages/event-reduce/src/signals/state.ts | 13 +- packages/event-reduce/src/signals/tracking.ts | 205 +++++++++++++----- 8 files changed, 238 insertions(+), 201 deletions(-) diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index c421661..1b42e34 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -1,7 +1,7 @@ import { dispose, nameOfFunction } from "../utils" import { Effect } from "./effect" -import { ISource, SourceObserverBase, trackSourceAccessed } from "./signals" -import { withOwner } from "./tracking" +import { ISource, SourceObserverBase } from "./signals" +import { sourceWasAccessed, trackManagedSourceCreation } from "./tracking" export interface IDerivation extends ISource { readonly value: T @@ -39,7 +39,7 @@ export class Derivation extends SourceObserverBase { override get value() { // Notifying tracker before checking for updates so circular reference is detected early - trackSourceAccessed(this) + sourceWasAccessed(this) if (this._derive.sourceLastUpdated > this._derive.lastRun) this.update() @@ -50,11 +50,12 @@ export class Derivation extends SourceObserverBase { protected override skipUpdate(nextValue: T) { return this._skipUpdate(this._value, nextValue) } private update() { - let ownerScope = withOwner(this) + let sourceCreation = trackManagedSourceCreation() try { this.setValue(this._derive.run()) + sourceCreation.verifySourceManagement() } finally { - ownerScope[dispose]() + sourceCreation[dispose]() } } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/effect.ts b/packages/event-reduce/src/signals/effect.ts index 24d069c..c3d1ea7 100644 --- a/packages/event-reduce/src/signals/effect.ts +++ b/packages/event-reduce/src/signals/effect.ts @@ -1,6 +1,6 @@ import { dispose } from "../utils" -import { globalVersion, IObserver, ISource, SourceTracker } from "./signals" -import { Owner, withOwner } from "./tracking" +import { IObserver, ISource } from "./signals" +import { currentIteration, trackAccessedSources } from "./tracking" export class Effect implements IObserver { readonly sources = new Set>() @@ -31,10 +31,10 @@ export class Effect implements IObserver { } run() { - this.lastRun = globalVersion + this.lastRun = currentIteration let result: T; - let tracker = new SourceTracker(this) + let tracker = trackAccessedSources(this) try { result = this._effect() diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index d25e663..87484fa 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -1,11 +1,14 @@ -import { argsAre, constant, getAllPropertyDescriptors, isFunction, isObject, isString, isUndefined, nameOfFunction } from "../utils" +import { argsAre, constant, dispose, getAllPropertyDescriptors, isFunction, isObject, isString, isUndefined, nameOfFunction } from "../utils" import { Derivation, derive } from "./derivation" import { isEvent } from "./events" import { Reduction } from "./reduction" -import { getUnderlyingSource, ISource, isSource } from "./signals" +import { ISource, isSource } from "./signals" import { getState } from "./state" +import { getUnderlyingSource, trackAccessedSources, trackManagedSourceCreation, updateOwnedObjects } from "./tracking" -export type Model = { [modelProperties]: Record } +export interface Model extends Disposable { + [modelProperties]: Record +} type UninitializedModel = { [modelPropertyFactories]?: Record ModelProperty> } type ModelPrototype = { [prototypePropertyFactories]?: Record ModelProperty> } @@ -29,40 +32,55 @@ export function model any>(target: Class): [className]: class extends target { [modelProperties] = {} as Record constructor(...args: any[]) { - super(...args) + let sourceCreation = trackManagedSourceCreation() + let accesses = trackAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this + try { + + super(...args) + + let prototypeFactories = (target.prototype as ModelPrototype)?.[prototypePropertyFactories] + if (prototypeFactories) + for (let key of Object.keys(prototypeFactories)) + this[modelProperties][key] = prototypeFactories[key](this) + + let instanceFactories = (this as UninitializedModel)[modelPropertyFactories] + if (instanceFactories) + for (let key of Object.keys(instanceFactories)) + this[modelProperties][key] = instanceFactories[key]() - let prototypeFactories = (target.prototype as ModelPrototype)?.[prototypePropertyFactories] - if (prototypeFactories) - for (let key of Object.keys(prototypeFactories)) - this[modelProperties][key] = prototypeFactories[key](this) - - let instanceFactories = (this as UninitializedModel)[modelPropertyFactories] - if (instanceFactories) - for (let key of Object.keys(instanceFactories)) - this[modelProperties][key] = instanceFactories[key]() - - for (let [key, property] of Object.entries(this[modelProperties])) { - if (isSource(property)) { // No need to do this for state properties - Object.defineProperty(this, key, { - // Bind directly to the property for cleaner call stack (NOCOMMIT check this works) - get: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(property), 'value')!.get!.bind(property), - enumerable: true, - configurable: true - }) - } else if (process.env.NODE_ENV !== 'production' - && (isEvent(property.value) || isEventsClass(property.value)) - ) { - throw new EventsMarkedAsStateError(this, key) + for (let property of Object.values(this[modelProperties])) + updateOwnedObjects(this, null, property) + + sourceCreation.verifySourceManagement() + + for (let [key, property] of Object.entries(this[modelProperties])) { + if (isSource(property)) { // No need to do this for state properties + Object.defineProperty(this, key, { + // Bind directly to the property for cleaner call stack (NOCOMMIT check this works) + get: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(property), 'value')!.get!.bind(property), + enumerable: true, + configurable: true + }) + } else if (process.env.NODE_ENV !== 'production' + && (isEvent(property.value) || isEventsClass(property.value)) + ) { + throw new EventsMarkedAsStateError(this, key) + } } + } finally { + accesses[dispose]() + sourceCreation[dispose]() } } #json = derive(() => getState(this[modelProperties]), `toJSON(${className})`) - toJSON() { - return 'toJSON' in target - ? super.toJSON() - : this.#json.value + toJSON() { return super.toJSON?.() ?? this.#json.value } + + [dispose]() { + Object.values(this[modelProperties]) + .forEach(p => (p as any as Disposable)[dispose]?.()) + super[dispose]?.() } } }[className] diff --git a/packages/event-reduce/src/signals/reaction.ts b/packages/event-reduce/src/signals/reaction.ts index be5a804..e1218fc 100644 --- a/packages/event-reduce/src/signals/reaction.ts +++ b/packages/event-reduce/src/signals/reaction.ts @@ -1,6 +1,6 @@ import { dispose } from "../utils" import { Effect } from "./effect" -import { reactingTo, startReactingTo } from "./signals" +import { beginTransition, iterationSource } from "./tracking" // NOCOMMIT not sure I have a use for this export class Reaction extends Effect { @@ -11,13 +11,13 @@ export class Reaction extends Effect { ) { super(displayName, reaction, () => { if (this._schedule) { - let wasReactingTo = reactingTo + let reactionSource = iterationSource this._schedule(() => { - let reaction = wasReactingTo ? startReactingTo(wasReactingTo) : null + let transition = reactionSource ? beginTransition(reactionSource) : null try { this.run() } finally { - reaction?.[dispose]() + transition?.[dispose]() } }) } else { diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index 6301960..17658a6 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,6 +1,6 @@ import { dispose } from "../utils" -import { globalVersion, ISource, SourceBase, SourceObserverBase, SourceTracker, trackSourceAccessed } from "./signals" -import { currentOwnerScope } from "./tracking" +import { ISource, SourceBase, SourceObserverBase } from "./signals" +import { currentIteration, managedSourceCreated, sourceWasAccessed, trackAccessedSources } from "./tracking" export interface IReduction extends ISource { readonly value: T @@ -20,7 +20,7 @@ export function reduce(initialValue: T, eventsOrDisplayName?: Events export type Reducer = (current: Result, next: Source) => Result -export class Reduction extends SourceObserverBase { +export class Reduction extends SourceObserverBase implements Disposable { sources = new Set>() private _reducers = new Map, (current: T, next: any) => T>() private _sourcesLastChecked = -1 @@ -35,8 +35,7 @@ export class Reduction extends SourceObserverBase { ) { super(displayName) - if (!currentOwnerScope()) - throw new OwnerlessReductionError(displayName) + managedSourceCreated(this) this._value = initialValue this.attach() @@ -61,7 +60,7 @@ export class Reduction extends SourceObserverBase { if (source.lastUpdated > this.lastUpdated) { let reduce = this._reducers.get(source) if (reduce) { - let tracker = new SourceTracker(this) // Required for detecting circular references in reducers + let tracker = trackAccessedSources(this) // Required for detecting circular references in reducers try { let newValue = reduce(this._value, value) this.setValue(newValue) @@ -73,10 +72,9 @@ export class Reduction extends SourceObserverBase { } override get value() { - // Notifying tracker before checking for updates so circular reference is detected early - trackSourceAccessed(this) + sourceWasAccessed(this) // Notifying tracker before checking for updates so circular reference is detected early - if (this._sourcesLastChecked < globalVersion) + if (this._sourcesLastChecked < currentIteration) for (let source of this.sources) // At most one source should be newer this.sourceUpdated(source, (source as SourceBase).value) @@ -84,8 +82,8 @@ export class Reduction extends SourceObserverBase { } override setValue(value: T) { + this._sourcesLastChecked = currentIteration super.setValue(value) - this._sourcesLastChecked = globalVersion } override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue) } @@ -118,8 +116,4 @@ export class Reduction extends SourceObserverBase { this._disposed = true this.detach() } -} - -export class OwnerlessReductionError extends Error { - constructor(reductionName: string) { super(`Reduction '${reductionName}' created without an owner.`) } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 974687e..dd501e9 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,76 +1,21 @@ import { dispose, nameOfCallback } from "../utils" import { derive, IDerivation } from "./derivation" import { getState, State } from "./state" -import { updateOwnedValues } from "./tracking" +import { beginTransition, currentIteration, sourceWasAccessed, updateOwnedObjects } from "./tracking" /** * TODO * - Logging in derivations and reductions * - Source "path" built by ownership (include derivations?) * - extend + * - Some way to create top-level reductions */ -export class SourceTracker { - private readonly _previous: SourceTracker | null - readonly accessed = new Set>() - - constructor(private readonly _observer: IObserver | null) { - this._previous = globalTracker - globalTracker = this - } - - sourceAccessed(source: ISource) { - if (this._observer) { - let circularDependency = this.findCircularDependency(source) - if (circularDependency) - throw new CircularDependencyError([...circularDependency, source]) - } - - this.accessed.add(source) - } - - private findCircularDependency(source: ISource): ISource[] | null { - if (this._observer == source as any as IObserver) - return [source] - let circularDependency = this._previous?.findCircularDependency(source) - return circularDependency - ? [...circularDependency, this._observer as any as ISource] - : null - } - - [dispose]() { - globalTracker = this._previous - } -} - -let globalTracker: SourceTracker | null = null -let lastSourceAccessed: ISource | null = null -export let globalVersion = 0 -export let reactingTo: ISource | null = null - -export function trackSourceAccessed(source: ISource) { - globalTracker?.sourceAccessed(source) - lastSourceAccessed = source -} - -export function getUnderlyingSource(value: T): ISource | null { - return lastSourceAccessed - && lastSourceAccessed instanceof SourceBase - && Object.is(lastSourceAccessed.value, value) - ? lastSourceAccessed as ISource - : null -} export function isSource(value: any): value is ISource { return value instanceof SourceBase } -export function startReactingTo(source: ISource) { - let previouslyReactingTo = reactingTo - reactingTo = source - return { [dispose]() { reactingTo = previouslyReactingTo } } -} - export interface ISource { displayName: string readonly lastUpdated: number @@ -101,29 +46,23 @@ export abstract class SourceBase implements ISource { } get value() { - trackSourceAccessed(this) + sourceWasAccessed(this) return this._value } - protected setValue(newValue: T, newVersion = false) { // TODO better name for newVersion - creates new global version - if (newVersion && reactingTo) - throw new SideEffectDuringReactionError(reactingTo, this) - + protected setValue(newValue: T, isTriggerEvent = false) { if (!this.skipUpdate(newValue)) { - let previousValue = this._value - this._value = newValue - updateOwnedValues(this, previousValue, newValue) - this.lastUpdated = newVersion ? ++globalVersion : globalVersion - - if (newVersion) - reactingTo = this - + let transition = isTriggerEvent ? beginTransition(this) : undefined try { + this.lastUpdated = currentIteration + let previousValue = this._value + this._value = newValue + updateOwnedObjects(this, previousValue, newValue) + for (let observer of this._observers) observer.sourceUpdated(this, this._value) } finally { - if (newVersion) - reactingTo = null + transition?.[dispose]() } } } @@ -192,17 +131,6 @@ export abstract class SourceObserverBase extends SourceBase abstract sourceUpdated(source: ISource, value: S): void } -class CircularDependencyError extends Error { - constructor(path: ISource[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}`) } -} - -class SideEffectDuringReactionError extends Error { - constructor( - public reactingTo: ISource, - public causing: ISource - ) { super(`Side effect during reaction to ${reactingTo.displayName} in ${causing.displayName}`) } -} - export class NotASourceValueError extends Error { constructor( public value: unknown diff --git a/packages/event-reduce/src/signals/state.ts b/packages/event-reduce/src/signals/state.ts index 45e0fd0..ee12ae4 100644 --- a/packages/event-reduce/src/signals/state.ts +++ b/packages/event-reduce/src/signals/state.ts @@ -1,16 +1,9 @@ -import { dispose, isObject } from "../utils" +import { isObject } from "../utils" -export type State = T - -export let serializeDerivedValues = false - -export function derivedValuedSerialization() { - let previous = serializeDerivedValues - return { [dispose]() { serializeDerivedValues = previous } } -} +export type State = T extends { toJSON(): infer S } ? S : T export function getState(model: T): State { return isObject(model) && 'toJSON' in model ? (model as any).toJSON() - : model + : model as State } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index c0e9e4b..5b5f17a 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -1,87 +1,190 @@ -import { dispose, emptySet } from "../utils" +import { dispose, emptySet, isObject, nameOfFunction } from "../utils" import { isModel, Model } from "./models" import { Reduction } from "./reduction" -import { ISource } from "./signals" +import { IObserver, ISource, SourceBase } from "./signals" -export type Trackable = Reduction | Model +type ManagedObject = Reduction | Model +type Owner = ISource | Model -export type Owner = ISource | Model +export let currentIteration = 0 +export let iterationSource: ISource | null = null -let ownerStack: Owner[] = [] +let currentAccessTracker: AccessTracker | null = null +let lastSourceAccessed: ISource | null = null -let ownerRegistry = new WeakMap>() +let ownerRegistry = new WeakMap>() +let ownedObjectsSnapshots = new WeakMap>() +let garbage = new Set() -let ownedValuesSnapshots = new WeakMap>() +let currentCreationScope: Set> | null = null -export function addOwner(trackable: Trackable, owner: Owner) { - let owners = ownerRegistry.get(trackable) +export function beginTransition(source: ISource) { + if (iterationSource) + throw new SideEffectDuringReactionError(iterationSource, source) - if (!owners) { - owners = new Set() - ownerRegistry.set(trackable, owners) + currentIteration++ + iterationSource = source + + return { + [dispose]() { + iterationSource = null + disposeGarbage() + } } +} + +export function trackAccessedSources(observer: IObserver | null) { + return new AccessTracker(observer) +} + +export function sourceWasAccessed(source: ISource) { + currentAccessTracker?.addAccessed(source) + lastSourceAccessed = source +} + +export function getUnderlyingSource(value: T): ISource | null { + return lastSourceAccessed + && lastSourceAccessed instanceof SourceBase + && Object.is(lastSourceAccessed.value, value) + ? lastSourceAccessed + : null +} + +export function updateOwnedObjects(owner: Owner, previousValue: unknown, nextValue: unknown) { + if (Object.is(previousValue, nextValue)) + return + + let previouslyOwned = emptySet + if (isObject(previousValue)) { + previouslyOwned = ownedObjectsSnapshots.get(previousValue) ?? emptySet + ownedObjectsSnapshots.delete(previousValue) + } + + let newlyOwned = emptySet + if (isObject(nextValue)) { + newlyOwned = getManagedObjects(nextValue) + ownedObjectsSnapshots.set(nextValue, newlyOwned) + } + + for (let removed of previouslyOwned) + if (!newlyOwned.has(removed)) + removeOwner(removed, owner) + + for (let added of newlyOwned) + if (!previouslyOwned.has(added)) + addOwner(added, owner) +} + +function getManagedObjects(value: object): ReadonlySet { + return isManagedObject(value) ? new Set([value]) + : Array.isArray(value) ? new Set(value.filter(isManagedObject)) + : new Set(Object.values(value).filter(isManagedObject)) +} + +export function isManagedObject(value: unknown): value is ManagedObject { + return isModel(value) + || value instanceof Reduction +} + +function addOwner(object: ManagedObject, owner: Owner) { + let owners = ownerRegistry.get(object) + + if (!owners) + ownerRegistry.set(object, owners = new Set()) owners.add(owner) } -export function removeOwner(trackable: Trackable, owner: Owner) { - let owners = ownerRegistry.get(trackable) +function removeOwner(object: ManagedObject, owner: Owner) { + let owners = ownerRegistry.get(object) if (owners) { owners.delete(owner) if (!owners.size) { - // NOCOMMIT register to be disposed at the end of the current operation - // In case an owner is added again. Also handle models. - ownerRegistry.delete(trackable) - if (trackable instanceof Reduction) - trackable[dispose]() + ownerRegistry.delete(object) + garbage.add(object) } } } -// TODO may not need this if we're just going to check values anyway, although still want to avoid loose reductions -export function withOwner(owner: Owner) { - ownerStack.push(owner) - return { [dispose]() { ownerStack.pop() } } as Disposable +function disposeGarbage() { + for (let object of garbage) + if (!ownerRegistry.has(object)) // In case a new owner was added + object[dispose]() + garbage.clear() } -export function currentOwnerScope() { - return ownerStack.at(-1) +export interface IManagedSourceTracker extends Disposable { + verifySourceManagement(): void } -export function updateOwnedValues(owner: Owner, previousValue: unknown, nextValue: unknown) { - if (Object.is(previousValue, nextValue)) - return +export function trackManagedSourceCreation(): IManagedSourceTracker { + let previousScope = currentCreationScope + let currentScope = currentCreationScope = new Set() + return { + verifySourceManagement() { + for (let object of currentScope) + if (!ownerRegistry.has(object)) + throw new UntrackedSourceError(object) + }, + [dispose]() { currentCreationScope = previousScope } + } +} - let previouslyTracked = canContainTrackableValues(previousValue) - ? ownedValuesSnapshots.get(previousValue) ?? collectTrackableValues(previousValue) - : emptySet +export function managedSourceCreated(source: ManagedObject & ISource) { + if (!currentCreationScope) + throw new UntrackedSourceError(source) + currentCreationScope.add(source) +} - let newlyTracked = canContainTrackableValues(nextValue) - ? collectTrackableValues(nextValue) - : emptySet +export interface IAccessTracker extends Disposable { + readonly accessed: ReadonlySet> +} - for (let removed of previouslyTracked) - if (!newlyTracked.has(removed)) - removeOwner(removed, owner) +class AccessTracker implements IAccessTracker { + private readonly _previous: AccessTracker | null + readonly accessed = new Set>() - for (let added of newlyTracked) - if (!previouslyTracked.has(added)) - addOwner(added, owner) -} + constructor(private readonly _observer: IObserver | null) { + this._previous = currentAccessTracker + currentAccessTracker = this + } + + addAccessed(source: ISource) { + if (this._observer) { + let circularDependency = this.findCircularDependency(source) + if (circularDependency) + throw new CircularDependencyError([...circularDependency, source]) + } + + this.accessed.add(source) + } -function collectTrackableValues(value: object): ReadonlySet { - return isTrackable(value) ? new Set([value]) - : Array.isArray(value) ? new Set(value.filter(isTrackable)) - : new Set(Object.values(value).filter(isTrackable)) + private findCircularDependency(source: ISource): ISource[] | null { + if (this._observer == source as any as IObserver) + return [source] + let circularDependency = this._previous?.findCircularDependency(source) + return circularDependency + ? [...circularDependency, this._observer as any as ISource] + : null + } + + [dispose]() { + currentAccessTracker = this._previous + } } -function canContainTrackableValues(value: unknown): value is object { - return !!value - && typeof value == 'object' +class CircularDependencyError extends Error { + constructor(path: ISource[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}`) } } -export function isTrackable(value: unknown): value is Trackable { - return isModel(value) - || value instanceof Reduction +class SideEffectDuringReactionError extends Error { + constructor( + public reactingTo: ISource, + public causing: ISource + ) { super(`Side effect during reaction to ${reactingTo.displayName} in ${causing.displayName}`) } } + +class UntrackedSourceError extends Error { + constructor(source: ISource) { super(`Leak detected: ${nameOfFunction(source.constructor)} '${source.displayName}' created without an owner.`) } // NOCOMMIT say what to do about it +} \ No newline at end of file From b84b4df0d88bb8b1033c16dc98a9a86b12a7210b Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 7 Mar 2026 10:13:17 +1000 Subject: [PATCH 15/83] Hooks --- .../event-reduce-react/src/signals/hooks.ts | 84 +++++++++++++++++++ .../event-reduce/src/signals/derivation.ts | 11 ++- packages/event-reduce/src/signals/effect.ts | 8 +- packages/event-reduce/src/signals/index.ts | 4 + .../event-reduce/src/signals/reduction.ts | 2 +- packages/event-reduce/src/signals/signals.ts | 5 +- 6 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 packages/event-reduce-react/src/signals/hooks.ts create mode 100644 packages/event-reduce/src/signals/index.ts diff --git a/packages/event-reduce-react/src/signals/hooks.ts b/packages/event-reduce-react/src/signals/hooks.ts new file mode 100644 index 0000000..7029487 --- /dev/null +++ b/packages/event-reduce-react/src/signals/hooks.ts @@ -0,0 +1,84 @@ +import { asyncEvent, Atom, derive, event, IReduction, ISource, reduce } from "event-reduce/lib/signals" +import { isModel, Model } from "event-reduce/lib/signals/models" +import { Reduction } from "event-reduce/lib/signals/reduction" +import { dispose, emptyArray } from "event-reduce/lib/utils" +import { DependencyList, useEffect, useMemo, useRef, useState } from "react" + +/** + * Creates a model that persists across renders of the component. + * @param unobservableDependencies - A list of dependencies that are not observable. The model will be re-created when any of these change. +*/ +export function useModel(createModel: () => T, unobservableDependencies?: DependencyList) { + let model = useMemo(createModel, unobservableDependencies ?? emptyArray) as Model + if (!isModel(model)) + throw new InvalidModelError() + useEffect(() => () => model[dispose]?.(), unobservableDependencies ?? emptyArray) + return model +} + +export function useEvent(name?: string) { + return useState(() => event(name))[0] +} + +export function useAsyncEvent(name?: string) { + return useState(() => asyncEvent(name))[0] +} + +/** + * Creates a derived value that persists across renders of the component. + * @param unobservableDependencies - A list of dependencies that are not observable. The derived value will be updated when any of these change. + * If not specified, the derived value will be updated every render, but will still only *trigger* a re-render inside a reactive component if the derived value changes. + * NOCOMMIT - not sure this last line is correct + */ +export function useDerived(getValue: () => T, name?: string): ISource +export function useDerived(getValue: () => T, unobservableDependencies?: DependencyList, name?: string): ISource +export function useDerived(getValue: () => T, nameOrUnobservableDependencies?: string | DependencyList, name?: string): ISource { + let unobservableDependencies: DependencyList | undefined + [name, unobservableDependencies] = typeof nameOrUnobservableDependencies == 'string' + ? [nameOrUnobservableDependencies, undefined] + : [name, nameOrUnobservableDependencies] + + let derived = useState(() => derive(getValue, name))[0] + + useMemo(() => derived.update(getValue, 'render'), unobservableDependencies) + + return derived +} + +export function useReduced(initial: T, name?: string): IReduction { + let reduction = useState(() => reduce(initial, name))[0] + useEffect(() => () => (reduction as Reduction)[dispose]?.(), []) + return reduction +} + +export function useObservedProps(values: T, name: string = '(anonymous observed values)') { + let observableValues = useState(() => ({} as { [K in keyof T]?: Atom }))[0] + let nameBase = (name || '') + '.' + + // Update any values that are already being observed + for (let [key, atom] of Object.entries(observableValues) as [keyof T, Atom][]) + atom.setValue(values[key]) + + let latestValues = useRef(values) + latestValues.current = values + + // Create observable values as properties are accessed + return new Proxy({} as T, { + get(_, key) { + return (observableValues[key as keyof T] + ??= new Atom(nameBase + String(key), + (latestValues.current)[key as keyof T])) + .value + } + }) +} + +export function useAtom(value: T, name: string = '(anonymous observed value)') { + let observableValue = useState(() => new Atom(name, value))[0] + observableValue.setValue(value) + return observableValue +} + +class InvalidModelError extends Error { + constructor() { super("Invalid model created in useModel. A model must be an instance of a class decorated with @model.") } +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 1b42e34..27b2444 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -5,6 +5,13 @@ import { sourceWasAccessed, trackManagedSourceCreation } from "./tracking" export interface IDerivation extends ISource { readonly value: T + + /** + * Forces the value to be re-calculated. + * @param deriveValue Optional function to use to derive the value. If not provided, the original derivation function will be used. + * @param reason Optional reason for the update. If not invalidated by a source change, this will be used as the reason for the update. + **/ + update(deriveValue?: () => T, reason?: string): void } export function derive(derive: () => T, displayName?: string): IDerivation @@ -49,10 +56,10 @@ export class Derivation extends SourceObserverBase { protected override skipUpdate(nextValue: T) { return this._skipUpdate(this._value, nextValue) } - private update() { + update(deriveValue?: () => T, reason?: string) { let sourceCreation = trackManagedSourceCreation() try { - this.setValue(this._derive.run()) + this.setValue(this._derive.run(deriveValue)) sourceCreation.verifySourceManagement() } finally { sourceCreation[dispose]() diff --git a/packages/event-reduce/src/signals/effect.ts b/packages/event-reduce/src/signals/effect.ts index c3d1ea7..647f0fe 100644 --- a/packages/event-reduce/src/signals/effect.ts +++ b/packages/event-reduce/src/signals/effect.ts @@ -30,14 +30,18 @@ export class Effect implements IObserver { } } - run() { + /** + * Forces the effect to run. + * @param effect Optional function to use to run the effect. If not provided, the original effect function will be used. + **/ + run(effect?: () => T) { this.lastRun = currentIteration let result: T; let tracker = trackAccessedSources(this) try { - result = this._effect() + result = (effect ?? this._effect)() } finally { tracker[dispose]() } diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts new file mode 100644 index 0000000..36fc2c7 --- /dev/null +++ b/packages/event-reduce/src/signals/index.ts @@ -0,0 +1,4 @@ +export { ISource, Atom } from "./signals" +export { IReduction, Reducer, reduce } from "./reduction" +export { asyncEvent, event } from "./events" +export { derive } from "./derivation" diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index 17658a6..70268a8 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -2,7 +2,7 @@ import { dispose } from "../utils" import { ISource, SourceBase, SourceObserverBase } from "./signals" import { currentIteration, managedSourceCreated, sourceWasAccessed, trackAccessedSources } from "./tracking" -export interface IReduction extends ISource { +export interface IReduction extends ISource { readonly value: T on(source: ISource, reduce: Reducer): this on(getSource: (events: Events) => ISource, reduce: Reducer): this diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index dd501e9..97ca94a 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -8,7 +8,7 @@ import { beginTransition, currentIteration, sourceWasAccessed, updateOwnedObject * - Logging in derivations and reductions * - Source "path" built by ownership (include derivations?) * - extend - * - Some way to create top-level reductions + * - Some way to create top-level reductions - maybe a case for a "createModel" that marks a plain object as a model and adds a dispose */ @@ -85,8 +85,7 @@ export abstract class SourceBase implements ISource { unsubscribe(observer: IObserver) { this._observers.delete(observer) } } -// NOCOMMIT not sure I have a use for this (maybe replacement for Subject?) -class Atom extends SourceBase { +export class Atom extends SourceBase { constructor( displayName: string, initialValue: T, From 190b1a2d7aadd9f748a2755d9e1b2f57904a9cd0 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 7 Mar 2026 11:07:43 +1000 Subject: [PATCH 16/83] Rendering --- .../event-reduce-react/src/signals/hooks.ts | 2 - .../src/signals/rendering.ts | 111 ++++++++++++++++++ packages/event-reduce/src/signals/effect.ts | 4 +- packages/event-reduce/src/signals/index.ts | 2 + 4 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 packages/event-reduce-react/src/signals/rendering.ts diff --git a/packages/event-reduce-react/src/signals/hooks.ts b/packages/event-reduce-react/src/signals/hooks.ts index 7029487..5a86b1d 100644 --- a/packages/event-reduce-react/src/signals/hooks.ts +++ b/packages/event-reduce-react/src/signals/hooks.ts @@ -27,8 +27,6 @@ export function useAsyncEvent(name?: string) { /** * Creates a derived value that persists across renders of the component. * @param unobservableDependencies - A list of dependencies that are not observable. The derived value will be updated when any of these change. - * If not specified, the derived value will be updated every render, but will still only *trigger* a re-render inside a reactive component if the derived value changes. - * NOCOMMIT - not sure this last line is correct */ export function useDerived(getValue: () => T, name?: string): ISource export function useDerived(getValue: () => T, unobservableDependencies?: DependencyList, name?: string): ISource diff --git a/packages/event-reduce-react/src/signals/rendering.ts b/packages/event-reduce-react/src/signals/rendering.ts new file mode 100644 index 0000000..f57f4d0 --- /dev/null +++ b/packages/event-reduce-react/src/signals/rendering.ts @@ -0,0 +1,111 @@ +import { LogValue } from "event-reduce/lib/logging" +import { Effect, log, startLogging } from "event-reduce/lib/signals" +import { ISource } from "event-reduce/lib/signals/signals" +import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/lib/utils" +import { Children, ReactNode, isValidElement, useState } from "react" +import { useSyncExternalStore } from "use-sync-external-store/shim" + +export function Reactive(props: { name?: string; children: () => ReactNode; }): ReactNode { + return useReactive(props.name || 'Derived', () => props.children()) +} + +export function reactive(component: (...args: Args) => ReactNode | null) { + let componentName = nameOfFunction(component) || 'ReactiveComponent' + + const reactiveComponentName = `reactive(${componentName})` + return { + [reactiveComponentName]: (...args: Args) => useReactive(componentName, () => component(...args)) + }[reactiveComponentName] +} + +export function useReactive(deriveValue: () => T): T +export function useReactive(name: string, deriveValue: () => T): T +export function useReactive(nameOrDeriveValue: string | (() => T), maybeDeriveValue?: () => T): T { + let [name, deriveValue] = typeof nameOrDeriveValue == 'string' + ? [nameOrDeriveValue, maybeDeriveValue!] + : ['ReactiveValue', nameOrDeriveValue] + + let renderEffect = useState(() => new RenderEffect(name))[0] + useSyncExternalStore(renderEffect.subscribe.bind(renderEffect), () => renderEffect.lastRun) + return renderEffect.run(deriveValue) +} + +class RenderEffect extends Effect { + private _observers = new Set<() => void>() + private _trigger: WeakRef> | null = null + + constructor(displayName: string) { + super(displayName, constant(undefined!), (source) => { + log('⚛️🚩 (render invalidated)', this.displayName, [source]) + this._trigger = new WeakRef(source) + for (let subscriber of this._observers) + subscriber() + }) + } + + subscribe(observer: () => void) { + this._observers.add(observer) + return () => this._observers.delete(observer) + } + + override run(deriveValue: () => T) { + let value!: T + let trigger = this._trigger + + let logging = startLogging('⚛️ (render)', this.displayName, emptyArray, () => ({ + // Previous: this.loggedValue(previousValue), + Current: loggedReactValue(value), + // Container: this.container, + // Sources: sourceTree(this.sources), + TriggeredBy: trigger + ? { name: trigger.deref()?.displayName ?? "(unknown)", get observable() { return trigger.deref() ?? "No longer in memory"; } } + : 'render' + })) + + try { + return value = super.run(deriveValue) + } finally { + logging?.[dispose]() + } + } +} + +function loggedReactValue(value: unknown) { + if (process.env.NODE_ENV !== 'production' && isValidElement(value)) { + let xmlDoc = document.implementation.createDocument(null, null) + return new LogValue([ + xmlTree(value), + { ['React element']: value } + ]) + + function xmlTree(node: unknown): Node { + if (isValidElement(node)) { + let type = reactElementName(node.type) + .split('(').at(-1)!.split(')')[0] // Unwrap HOC names + .replace(/^[0-9-]/, '_$&') // Escape leading number or dash + .replace(/[^a-zA-Z0-9:_.-]/g, '_'); // Escape rest of element name + let el = xmlDoc.createElement(type || ':unknown:') + for (let child of Children.toArray((node.props as any).children)) + el.appendChild(xmlTree(child)) + return el + } + return document.createTextNode(String(node)) + } + + function reactElementName(elementType: any) { + switch (typeof elementType) { + case 'string': + return elementType + case 'function': + return nameOfFunction(elementType) + case 'symbol': + return String(elementType) + case 'object': + if ('_context' in elementType) return 'Context.Provider' + if (elementType.displayName) return elementType.displayName + if (elementType.type?.displayName) return elementType.type.displayName + } + } + } + return value +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/effect.ts b/packages/event-reduce/src/signals/effect.ts index 647f0fe..564cc5a 100644 --- a/packages/event-reduce/src/signals/effect.ts +++ b/packages/event-reduce/src/signals/effect.ts @@ -10,7 +10,7 @@ export class Effect implements IObserver { constructor( public readonly displayName: string, private _effect: () => T, - private _onSourceUpdatedSinceLastRun: () => void + private _onSourceUpdatedSinceLastRun: (source: ISource) => void ) { } attach() { @@ -26,7 +26,7 @@ export class Effect implements IObserver { sourceUpdated(source: ISource, value: S): void { if (source.lastUpdated > this.lastRun) { this.sourceLastUpdated = source.lastUpdated - this._onSourceUpdatedSinceLastRun() + this._onSourceUpdatedSinceLastRun(source) } } diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts index 36fc2c7..ab9a246 100644 --- a/packages/event-reduce/src/signals/index.ts +++ b/packages/event-reduce/src/signals/index.ts @@ -2,3 +2,5 @@ export { ISource, Atom } from "./signals" export { IReduction, Reducer, reduce } from "./reduction" export { asyncEvent, event } from "./events" export { derive } from "./derivation" +export { Effect } from "./effect" +export { log, startLogging } from "./logging" \ No newline at end of file From 97db5dc561f72bc0b94ea3d1891084d260fdf049 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 7 Mar 2026 17:11:48 +1000 Subject: [PATCH 17/83] Building paths to nodes --- packages/event-reduce/src/signals/models.ts | 6 +- packages/event-reduce/src/signals/signals.ts | 5 +- packages/event-reduce/src/signals/tracking.ts | 108 ++++++++++-------- packages/event-reduce/src/utils.ts | 3 +- 4 files changed, 74 insertions(+), 48 deletions(-) diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index 87484fa..d2d93bc 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -235,6 +235,10 @@ export function state(...args: } } +export interface EventsClass { + [eventsBrand]: true +} + /** * Marks a class as an events class. * Automatically populates the names of event properties. @@ -265,7 +269,7 @@ export let events = (target: T): T => { }[className] } -export function isEventsClass(value: unknown): value is Object { +export function isEventsClass(value: unknown): value is EventsClass { return value instanceof Object && eventsBrand in value } diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 97ca94a..1d5c6ee 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,7 +1,7 @@ import { dispose, nameOfCallback } from "../utils" import { derive, IDerivation } from "./derivation" import { getState, State } from "./state" -import { beginTransition, currentIteration, sourceWasAccessed, updateOwnedObjects } from "./tracking" +import { beginTransition, currentIteration, getPath, sourceWasAccessed, updateOwnedObjects } from "./tracking" /** * TODO @@ -18,6 +18,7 @@ export function isSource(value: any): value is ISource { export interface ISource { displayName: string + readonly path: string readonly lastUpdated: number map(transform: (value: T) => Out, displayName?: string): ISource filter(predicate: (value: T) => value is Out, displayName?: string): ISource @@ -40,6 +41,8 @@ export abstract class SourceBase implements ISource { constructor(public displayName: string) { } + get path(): string { return getPath(this) } + toJSON() { // Lazy init to avoid infinite loop in Derivation constructor return (this._json ??= derive(() => getState(this.value), `toJSON(${this.displayName})`)).value diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index 5b5f17a..18f23bf 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -1,10 +1,8 @@ -import { dispose, emptySet, isObject, nameOfFunction } from "../utils" -import { isModel, Model } from "./models" -import { Reduction } from "./reduction" -import { IObserver, ISource, SourceBase } from "./signals" +import { dispose, emptyMap, isObject, nameOfFunction } from "../utils" +import { EventsClass, Model, isEventsClass, isModel } from "./models" +import { IObserver, ISource, isSource, SourceBase } from "./signals" -type ManagedObject = Reduction | Model -type Owner = ISource | Model +type GraphNode = ISource | Model | EventsClass export let currentIteration = 0 export let iterationSource: ISource | null = null @@ -12,11 +10,13 @@ export let iterationSource: ISource | null = null let currentAccessTracker: AccessTracker | null = null let lastSourceAccessed: ISource | null = null -let ownerRegistry = new WeakMap>() -let ownedObjectsSnapshots = new WeakMap>() -let garbage = new Set() +/** Child -> parent, path. */ +let parentRegistry = new WeakMap>() +/** Value -> node, path. */ +let childSnapshots = new WeakMap>() +let garbage = new Set() -let currentCreationScope: Set> | null = null +let currentCreationScope: Set> | null = null export function beginTransition(source: ISource) { if (iterationSource) @@ -50,67 +50,76 @@ export function getUnderlyingSource(value: T): ISource | null { : null } -export function updateOwnedObjects(owner: Owner, previousValue: unknown, nextValue: unknown) { +export function updateOwnedObjects(parent: GraphNode, previousValue: unknown, nextValue: unknown) { if (Object.is(previousValue, nextValue)) return - let previouslyOwned = emptySet + let previousChildren: ReadonlyMap = emptyMap if (isObject(previousValue)) { - previouslyOwned = ownedObjectsSnapshots.get(previousValue) ?? emptySet - ownedObjectsSnapshots.delete(previousValue) + previousChildren = childSnapshots.get(previousValue) ?? emptyMap + childSnapshots.delete(previousValue) } - let newlyOwned = emptySet + let newChildren: ReadonlyMap = emptyMap if (isObject(nextValue)) { - newlyOwned = getManagedObjects(nextValue) - ownedObjectsSnapshots.set(nextValue, newlyOwned) + newChildren = getOwnedNodes(nextValue) + childSnapshots.set(nextValue, newChildren) } - for (let removed of previouslyOwned) - if (!newlyOwned.has(removed)) - removeOwner(removed, owner) + for (let [removed, _] of previousChildren) + if (!newChildren.has(removed)) + removeParent(removed, parent) - for (let added of newlyOwned) - if (!previouslyOwned.has(added)) - addOwner(added, owner) + for (let [added, path] of newChildren) + if (!previousChildren.has(added)) + addParent(added, parent, path) } -function getManagedObjects(value: object): ReadonlySet { - return isManagedObject(value) ? new Set([value]) - : Array.isArray(value) ? new Set(value.filter(isManagedObject)) - : new Set(Object.values(value).filter(isManagedObject)) +function getOwnedNodes(value: object): ReadonlyMap { + if (isGraphNode(value)) + return new Map([[value, '']]) + + if (Array.isArray(value)) + return new Map(value + .filter(isGraphNode) + .map((obj, i) => [obj, `[${i}]`])) + + return new Map(Object.entries(value) + .filter(([_, val]) => isGraphNode(val)) + .map(([key, val]) => [val, `.${key}`])) } -export function isManagedObject(value: unknown): value is ManagedObject { - return isModel(value) - || value instanceof Reduction +function isGraphNode(value: unknown): value is GraphNode { + return isSource(value) + || isModel(value) + || isEventsClass(value) } -function addOwner(object: ManagedObject, owner: Owner) { - let owners = ownerRegistry.get(object) +function addParent(object: GraphNode, owner: GraphNode, path: string) { + let owners = parentRegistry.get(object) if (!owners) - ownerRegistry.set(object, owners = new Set()) + parentRegistry.set(object, owners = new Map()) - owners.add(owner) + owners.set(owner, '') } -function removeOwner(object: ManagedObject, owner: Owner) { - let owners = ownerRegistry.get(object) +function removeParent(node: GraphNode, parent: GraphNode) { + let owners = parentRegistry.get(node) if (owners) { - owners.delete(owner) + owners.delete(parent) - if (!owners.size) { - ownerRegistry.delete(object) - garbage.add(object) + if (!owners.size && dispose in node) { + parentRegistry.delete(node) + garbage.add(node) } } } function disposeGarbage() { - for (let object of garbage) - if (!ownerRegistry.has(object)) // In case a new owner was added - object[dispose]() + for (let node of garbage) + if (!parentRegistry.has(node)) // In case a new owner was added + node[dispose]() garbage.clear() } @@ -124,14 +133,14 @@ export function trackManagedSourceCreation(): IManagedSourceTracker { return { verifySourceManagement() { for (let object of currentScope) - if (!ownerRegistry.has(object)) + if (!parentRegistry.has(object)) throw new UntrackedSourceError(object) }, [dispose]() { currentCreationScope = previousScope } } } -export function managedSourceCreated(source: ManagedObject & ISource) { +export function managedSourceCreated(source: GraphNode & ISource) { if (!currentCreationScope) throw new UntrackedSourceError(source) currentCreationScope.add(source) @@ -174,6 +183,15 @@ class AccessTracker implements IAccessTracker { } } +export function getPath(node: GraphNode): string { + let primaryOwner = parentRegistry.get(node)?.entries().next().value + return primaryOwner + ? getPath(primaryOwner[0]) + primaryOwner[1] + : isSource(node) ? node.displayName + : isObject(node) ? nameOfFunction(node.constructor) || '$' + : '$' +} + class CircularDependencyError extends Error { constructor(path: ISource[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}`) } } diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index 9917ea3..4235a13 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -2,7 +2,8 @@ import { Unsubscribe } from "./types"; export const emptyArray = Object.freeze([]) as readonly any[]; -export const emptySet = Object.freeze(new Set()) as ReadonlySet; +export const emptySet = Object.freeze(new Set()) as ReadonlySet +export const emptyMap = Object.freeze(new Map()) as ReadonlyMap export function constant(value: T) { return identity.bind(null, value) as () => T; } export function identity(value: T) { return value; } From 260a1bf33b2c1a2ba4b836735113510c2d73ae68 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 7 Mar 2026 18:29:13 +1000 Subject: [PATCH 18/83] More logging --- .../event-reduce/src/signals/derivation.ts | 24 ++++++++++++++++--- .../event-reduce/src/signals/reduction.ts | 16 ++++++++++--- packages/event-reduce/src/signals/signals.ts | 2 -- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 27b2444..842d720 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -1,5 +1,6 @@ -import { dispose, nameOfFunction } from "../utils" +import { dispose, emptyArray, nameOfFunction } from "../utils" import { Effect } from "./effect" +import { startLogging } from "./logging" import { ISource, SourceObserverBase } from "./signals" import { sourceWasAccessed, trackManagedSourceCreation } from "./tracking" @@ -25,6 +26,7 @@ export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, export class Derivation extends SourceObserverBase { private _derive: Effect + private _invalidatingSource?: ISource constructor( displayName: string, @@ -32,7 +34,8 @@ export class Derivation extends SourceObserverBase { private _skipUpdate = Object.is ) { super(displayName) - this._derive = new Effect(displayName, derive, () => { + this._derive = new Effect(displayName, derive, (source) => { + this._invalidatingSource = source if (this._observers.size) this.update() }) @@ -57,11 +60,26 @@ export class Derivation extends SourceObserverBase { protected override skipUpdate(nextValue: T) { return this._skipUpdate(this._value, nextValue) } update(deriveValue?: () => T, reason?: string) { + let previousValue = this._value + let nextValue!: T + let trigger = this._invalidatingSource ? new WeakRef(this._invalidatingSource) : undefined + delete this._invalidatingSource + let sourceCreation = trackManagedSourceCreation() + let logging = startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ + Previous: previousValue, + Current: nextValue, + // Container: this.container, + // Sources: sourceTree(this.sources), + TriggeredBy: trigger + ? { name: trigger.deref()?.displayName ?? "(unknown)", get observable() { return trigger.deref() ?? "No longer in memory"; } } + : reason + })) try { - this.setValue(this._derive.run(deriveValue)) + this.setValue(nextValue = this._derive.run(deriveValue)) sourceCreation.verifySourceManagement() } finally { + logging?.[dispose]() sourceCreation[dispose]() } } diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index 70268a8..b06bb71 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,4 +1,5 @@ -import { dispose } from "../utils" +import { dispose, emptyArray } from "../utils" +import { startLogging } from "./logging" import { ISource, SourceBase, SourceObserverBase } from "./signals" import { currentIteration, managedSourceCreated, sourceWasAccessed, trackAccessedSources } from "./tracking" @@ -61,10 +62,19 @@ export class Reduction extends SourceObserverBase implem let reduce = this._reducers.get(source) if (reduce) { let tracker = trackAccessedSources(this) // Required for detecting circular references in reducers + let previousValue = this._value + let nextValue!: T + let logging = startLogging('🧪 (reduction)', this.displayName, emptyArray, () => ({ + Previous: previousValue, + Current: nextValue, + // Container: this.container, + // Sources: sourceTree(this.sources) + })) + try { - let newValue = reduce(this._value, value) - this.setValue(newValue) + this.setValue(nextValue = reduce(this._value, value)) } finally { + logging?.[dispose]() tracker[dispose]() } } diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 1d5c6ee..46a0fec 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -5,8 +5,6 @@ import { beginTransition, currentIteration, getPath, sourceWasAccessed, updateOw /** * TODO - * - Logging in derivations and reductions - * - Source "path" built by ownership (include derivations?) * - extend * - Some way to create top-level reductions - maybe a case for a "createModel" that marks a plain object as a model and adds a dispose */ From 37f4759e3d4e15b46d3c36cd34ca3eff0c70dd6e Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Mon, 9 Mar 2026 19:26:33 +1000 Subject: [PATCH 19/83] Hooking tests up to signals implementation --- packages/event-reduce/src/signals/index.ts | 3 +- packages/event-reduce/src/signals/models.ts | 19 ++++--- packages/event-reduce/src/signals/signals.ts | 4 +- packages/event-reduce/src/signals/tracking.ts | 8 +-- tests/Cleanup.test.ts | 51 ++++++++++--------- tests/SignalsOwnership.test.ts | 32 ++++++------ 6 files changed, 62 insertions(+), 55 deletions(-) diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts index ab9a246..bf373c1 100644 --- a/packages/event-reduce/src/signals/index.ts +++ b/packages/event-reduce/src/signals/index.ts @@ -3,4 +3,5 @@ export { IReduction, Reducer, reduce } from "./reduction" export { asyncEvent, event } from "./events" export { derive } from "./derivation" export { Effect } from "./effect" -export { log, startLogging } from "./logging" \ No newline at end of file +export { log, startLogging } from "./logging" +export { derived, reduced, state } from "./models" \ No newline at end of file diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index d2d93bc..3e13049 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -4,7 +4,7 @@ import { isEvent } from "./events" import { Reduction } from "./reduction" import { ISource, isSource } from "./signals" import { getState } from "./state" -import { getUnderlyingSource, trackAccessedSources, trackManagedSourceCreation, updateOwnedObjects } from "./tracking" +import { getUnderlyingSource, trackAccessedSources, trackManagedSourceCreation, updateChildren } from "./tracking" export interface Model extends Disposable { [modelProperties]: Record @@ -35,25 +35,24 @@ export function model any>(target: Class): let sourceCreation = trackManagedSourceCreation() let accesses = trackAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this try { - super(...args) let prototypeFactories = (target.prototype as ModelPrototype)?.[prototypePropertyFactories] if (prototypeFactories) for (let key of Object.keys(prototypeFactories)) - this[modelProperties][key] = prototypeFactories[key](this) + getModelProperties(this)[key] = prototypeFactories[key](this) let instanceFactories = (this as UninitializedModel)[modelPropertyFactories] if (instanceFactories) for (let key of Object.keys(instanceFactories)) - this[modelProperties][key] = instanceFactories[key]() + getModelProperties(this)[key] = instanceFactories[key]() - for (let property of Object.values(this[modelProperties])) - updateOwnedObjects(this, null, property) + for (let property of Object.values(getModelProperties(this))) + updateChildren(this, null, property) sourceCreation.verifySourceManagement() - for (let [key, property] of Object.entries(this[modelProperties])) { + for (let [key, property] of Object.entries(getModelProperties(this))) { if (isSource(property)) { // No need to do this for state properties Object.defineProperty(this, key, { // Bind directly to the property for cleaner call stack (NOCOMMIT check this works) @@ -78,7 +77,7 @@ export function model any>(target: Class): toJSON() { return super.toJSON?.() ?? this.#json.value } [dispose]() { - Object.values(this[modelProperties]) + Object.values(getModelProperties(this)) .forEach(p => (p as any as Disposable)[dispose]?.()) super[dispose]?.() } @@ -328,6 +327,10 @@ export function isModel(value: unknown): value is Model { && modelProperties in value } +export function getModelProperties(model: Model): Record { + return model[modelProperties] +} + // NOCOMMIT remove - just for compiling temp tests export function asModel(value: T): T & Model { return Object.assign(value, { [modelProperties]: {} }) as T & Model diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 46a0fec..b3fa8cf 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,7 +1,7 @@ import { dispose, nameOfCallback } from "../utils" import { derive, IDerivation } from "./derivation" import { getState, State } from "./state" -import { beginTransition, currentIteration, getPath, sourceWasAccessed, updateOwnedObjects } from "./tracking" +import { beginTransition, currentIteration, getPath, sourceWasAccessed, updateChildren } from "./tracking" /** * TODO @@ -58,7 +58,7 @@ export abstract class SourceBase implements ISource { this.lastUpdated = currentIteration let previousValue = this._value this._value = newValue - updateOwnedObjects(this, previousValue, newValue) + updateChildren(this, previousValue, newValue) for (let observer of this._observers) observer.sourceUpdated(this, this._value) diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index 18f23bf..e76c482 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -50,7 +50,7 @@ export function getUnderlyingSource(value: T): ISource | null { : null } -export function updateOwnedObjects(parent: GraphNode, previousValue: unknown, nextValue: unknown) { +export function updateChildren(parent: GraphNode, previousValue: unknown, nextValue: unknown) { if (Object.is(previousValue, nextValue)) return @@ -192,17 +192,17 @@ export function getPath(node: GraphNode): string { : '$' } -class CircularDependencyError extends Error { +export class CircularDependencyError extends Error { constructor(path: ISource[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}`) } } -class SideEffectDuringReactionError extends Error { +export class SideEffectDuringReactionError extends Error { constructor( public reactingTo: ISource, public causing: ISource ) { super(`Side effect during reaction to ${reactingTo.displayName} in ${causing.displayName}`) } } -class UntrackedSourceError extends Error { +export class UntrackedSourceError extends Error { constructor(source: ISource) { super(`Leak detected: ${nameOfFunction(source.constructor)} '${source.displayName}' created without an owner.`) } // NOCOMMIT say what to do about it } \ No newline at end of file diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index c620102..c717dea 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,8 +1,7 @@ import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test"; -import { derive, derived, event, reduce, reduced, state } from "event-reduce"; -import { changeOwnedValue } from "event-reduce/lib/cleanup"; -import { getObservableValue, model } from "event-reduce/lib/models"; -import { ObservableValue } from "event-reduce/lib/observableValue"; +import { Atom, derive, derived, event, reduce, reduced, state } from "event-reduce/lib/signals"; +import { getModelProperties, isModel, model } from "event-reduce/lib/signals/models"; +import { updateChildren } from "event-reduce/lib/signals/tracking"; import { StringKey } from "event-reduce/lib/types"; import { dispose } from "event-reduce/lib/utils"; @@ -40,7 +39,7 @@ describe("subscription cleanup", () => { }); test("when derivation returns a new model, disposes old model", () => { - let source = new ObservableValue(() => 'test source', 0); + let source = new Atom('test source', 0); let derivation = derive(() => new Model(source.value)); let modelInstance = derivation.value; let unsubscribe = spyOnDispose(modelInstance, 'reducedProp'); @@ -52,7 +51,7 @@ describe("subscription cleanup", () => { }); test("when derivation returns the same model, doesn't dispose model", () => { - let source = new ObservableValue(() => 'test source', 0); + let source = new Atom('test source', 0); let modelInstance = new Model(0); let derivation = derive(() => { source.value; @@ -68,17 +67,17 @@ describe("subscription cleanup", () => { }); describe("when derivation returns a model from another observable value", () => { - let model1: ObservableValue; - let model2: ObservableValue; - let useModel2: ObservableValue; + let model1: Atom; + let model2: Atom; + let useModel2: Atom; let derivation: ReturnType>; let modelInstance: Model; let disposeModelSpy: Mock<() => void>; beforeEach(() => { - model1 = new ObservableValue(() => 'model 1', new Model(1)); - model2 = new ObservableValue(() => 'model 2', new Model(2)); - useModel2 = new ObservableValue(() => 'switch', false); + model1 = new Atom('model 1', new Model(1)); + model2 = new Atom('model 2', new Model(2)); + useModel2 = new Atom('switch', false); derivation = derive(() => useModel2.value ? model2.value : model1.value); modelInstance = derivation.value; disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp'); @@ -143,7 +142,7 @@ describe("subscription cleanup", () => { reduce([model1, model2]) .on(removeModel, ms => ms.slice(0, -1)); - new ObservableValue(() => 'other value', model2); + new Atom('other value', model2); removeModel(); @@ -207,7 +206,7 @@ describe("subscription cleanup", () => { return remaining; }); - new ObservableValue(() => 'other value', model2); + new Atom('other value', model2); removeModel(); @@ -243,10 +242,10 @@ describe("subscription cleanup", () => { expect(disposeModelSpy).toHaveBeenCalled(); }); - describe("when array items are also in another observable value", () => { + /* describe("when array items are also in another observable value", () => { test("when array value is disposed, doesn't dispose items", () => { - let arrayValue = new ObservableValue(() => 'array value', [new Model(0)]); - new ObservableValue(() => 'other value', arrayValue.value[0]); + let arrayValue = new Atom('array value', [new Model(0)]); + new Atom('other value', arrayValue.value[0]); let disposeModelSpy = spyOnDispose(arrayValue.value[0], 'reducedProp'); arrayValue[dispose](); @@ -257,15 +256,15 @@ describe("subscription cleanup", () => { describe("when object values are also in another observable value", () => { test("when object value is disposed, doesn't dispose values", () => { - let objectValue = new ObservableValue(() => 'object value', { model: new Model(0) }); - new ObservableValue(() => 'other value', objectValue.value.model); + let objectValue = new Atom('object value', { model: new Model(0) }); + new Atom('other value', objectValue.value.model); let disposeModelSpy = spyOnDispose(objectValue.value.model, 'reducedProp'); objectValue[dispose](); expect(disposeModelSpy).not.toHaveBeenCalled(); }); - }); + }); */ test("when model disposed, disposes all properties correctly", () => { @model @@ -284,7 +283,7 @@ describe("subscription cleanup", () => { @derived get externallyOwned() { return this._ownedModel; } } - let externalOwner = new ObservableValue(() => 'external owner', new Model(3)); + let externalOwner = new Atom('external owner', new Model(3)); let outerModel = new OuterModel(externalOwner.value); let disposeReducedPropValue = spyOnDispose(outerModel.reducedProp, 'reducedProp'); let disposeDerivedPropValue = spyOnDispose(outerModel.derivedProp, 'reducedProp'); @@ -294,7 +293,7 @@ describe("subscription cleanup", () => { let disposeDerivedProp = spyOnDispose(outerModel, 'derivedProp'); let disposeExternallyOwnedProp = spyOnDispose(outerModel, 'externallyOwned'); - changeOwnedValue(undefined!, outerModel, undefined); + updateChildren(undefined!, outerModel, undefined); expect(disposeReducedProp).toHaveBeenCalled(); expect(disposeDerivedProp).toHaveBeenCalled(); @@ -308,7 +307,11 @@ describe("subscription cleanup", () => { }); function spyOnDispose(model: T, property: StringKey) { - let observableValue = getObservableValue(model, property)!; - return spyOn(observableValue, dispose); + if (isModel(model)) { + let observableValue = getModelProperties(model)[property]!; + if (dispose in observableValue) + return spyOn(observableValue, dispose); + } + throw new Error(`Property ${String(property)} is not a disposable observable value on the provided model`); } }); diff --git a/tests/SignalsOwnership.test.ts b/tests/SignalsOwnership.test.ts index d16f879..86ff9f4 100644 --- a/tests/SignalsOwnership.test.ts +++ b/tests/SignalsOwnership.test.ts @@ -1,17 +1,17 @@ import { describe, expect, test } from "bun:test" import { asModel } from "event-reduce/lib/signals/models" -import { OwnerlessReductionError, Reduction } from "event-reduce/lib/signals/reduction" +import { Reduction } from "event-reduce/lib/signals/reduction" import { SourceBase } from "event-reduce/lib/signals/signals" -import { addOwner, removeOwner, withOwner } from "event-reduce/lib/signals/tracking" +import { trackManagedSourceCreation, UntrackedSourceError, updateChildren } from "event-reduce/lib/signals/tracking" class TestSource extends SourceBase { - constructor(name: string) { super(name, () => false) } + constructor(name: string) { super(name) } emit(value: T) { this.setValue(value, true) } } describe("signal reduction ownership", () => { test("ownerless reduction throws", () => { - expect(() => new Reduction("r")).toThrow(OwnerlessReductionError) + expect(() => new Reduction("r", 0)).toThrow(UntrackedSourceError) }) test("disposes after last owner removed", () => { @@ -20,8 +20,8 @@ describe("signal reduction ownership", () => { let reduction: Reduction { - using _ = withOwner(owner) - reduction = new Reduction("r").on(updates, (current = 0, next) => current + next) + using _ = trackManagedSourceCreation() + reduction = new Reduction("r", 0).on(updates, (current, next) => current + next) } updates.emit(1) @@ -39,9 +39,9 @@ describe("signal reduction ownership", () => { let updates = new TestSource("updates") let r1, r2: Reduction { - using _ = withOwner(holder) - r1 = new Reduction("r1").on(updates, (current = 0, next) => current + next) - r2 = new Reduction("r2").on(updates, (current = 0, next) => current + next) + using _ = trackManagedSourceCreation() + r1 = new Reduction("r1", 0).on(updates, (current, next) => current + next) + r2 = new Reduction("r2", 0).on(updates, (current, next) => current + next) } holder.emit([r1, r2]) @@ -67,8 +67,8 @@ describe("signal reduction ownership", () => { let reduction: Reduction { - using _ = withOwner(bootstrapOwner) - reduction = new Reduction("r").on(updates, (current = 0, next) => current + next) + using _ = trackManagedSourceCreation() + reduction = new Reduction("r", 0).on(updates, (current, next) => current + next) } holder.emit(reduction) @@ -92,16 +92,16 @@ describe("signal reduction ownership", () => { let reduction: Reduction { - using _ = withOwner(owner) - reduction = new Reduction("r").on(updates, (current = 0, next) => current + next) + using _ = trackManagedSourceCreation() + reduction = new Reduction("r", 0).on(updates, (current, next) => current + next) } updates.emit(1) expect(reduction.value).toBe(1) - addOwner(reduction, owner) - removeOwner(reduction, owner) - addOwner(reduction, owner) + updateChildren(owner, undefined, reduction) + updateChildren(owner, reduction, undefined) + updateChildren(owner, undefined, reduction) updates.emit(1) expect(reduction.value).toBe(1) From ef079edb44a463989ab8550eb9cbcd2b7f6d4522 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 11 Mar 2026 20:57:12 +1000 Subject: [PATCH 20/83] Getting Cleanup tests working with signals implementation Moving some things around to get around circular module dependencies Every ISource is Disposable Removed SourceObserverBase Effect correctly detects it's stale even when detached Fixed events firing a reference to the inner function instead of the proper event function Legacy @state decorator is bound to correct instance Model prototype factories inherited from base prototype Added createModel function for top-level model Graph nodes in state properties are tracked --- .../event-reduce-react/src/signals/hooks.ts | 2 +- packages/event-reduce/src/signals/atom.ts | 23 + .../event-reduce/src/signals/derivation.ts | 50 +- packages/event-reduce/src/signals/effect.ts | 23 +- packages/event-reduce/src/signals/events.ts | 17 +- packages/event-reduce/src/signals/index.ts | 9 +- packages/event-reduce/src/signals/models.ts | 54 +-- packages/event-reduce/src/signals/nodes.ts | 75 +++ .../event-reduce/src/signals/reduction.ts | 48 +- packages/event-reduce/src/signals/signals.ts | 67 +-- packages/event-reduce/src/signals/tracking.ts | 21 +- packages/event-reduce/src/utils.ts | 5 + tests/Cleanup.test.ts | 431 +++++++++--------- tests/package.json | 4 +- 14 files changed, 451 insertions(+), 378 deletions(-) create mode 100644 packages/event-reduce/src/signals/atom.ts create mode 100644 packages/event-reduce/src/signals/nodes.ts diff --git a/packages/event-reduce-react/src/signals/hooks.ts b/packages/event-reduce-react/src/signals/hooks.ts index 5a86b1d..70412ef 100644 --- a/packages/event-reduce-react/src/signals/hooks.ts +++ b/packages/event-reduce-react/src/signals/hooks.ts @@ -1,5 +1,5 @@ import { asyncEvent, Atom, derive, event, IReduction, ISource, reduce } from "event-reduce/lib/signals" -import { isModel, Model } from "event-reduce/lib/signals/models" +import { isModel, Model } from "event-reduce/lib/signals/nodes" import { Reduction } from "event-reduce/lib/signals/reduction" import { dispose, emptyArray } from "event-reduce/lib/utils" import { DependencyList, useEffect, useMemo, useRef, useState } from "react" diff --git a/packages/event-reduce/src/signals/atom.ts b/packages/event-reduce/src/signals/atom.ts new file mode 100644 index 0000000..04a2b95 --- /dev/null +++ b/packages/event-reduce/src/signals/atom.ts @@ -0,0 +1,23 @@ +import { derive, IDerivation } from "./derivation"; +import { SourceBase } from "./signals"; +import { getState, State } from "./state"; + +export class Atom extends SourceBase { + private _json: IDerivation> + + constructor( + displayName: string, + initialValue: T, + private _isEqual = Object.is + ) { + super(displayName); + this._json = derive(() => getState(this.value), `toJSON(${this.displayName})`) + this.setValue(initialValue); + } + + toJSON() { return this._json.value; } + + override setValue(value: T) { super.setValue(value, true); } + + override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue); } +} diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 842d720..90d35c5 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -1,10 +1,11 @@ import { dispose, emptyArray, nameOfFunction } from "../utils" import { Effect } from "./effect" import { startLogging } from "./logging" -import { ISource, SourceObserverBase } from "./signals" +import { IObserver, ISource, SourceBase } from "./signals" +import { getState, State } from "./state" import { sourceWasAccessed, trackManagedSourceCreation } from "./tracking" -export interface IDerivation extends ISource { +export interface IDerivation extends ISource, IObserver { readonly value: T /** @@ -24,9 +25,9 @@ export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, return new Derivation(displayName ?? nameOfFunction(derive), derive, skipUpdate) } -export class Derivation extends SourceObserverBase { +export class Derivation extends SourceBase implements IDerivation { private _derive: Effect - private _invalidatingSource?: ISource + private _json?: IDerivation> constructor( displayName: string, @@ -34,16 +35,32 @@ export class Derivation extends SourceObserverBase { private _skipUpdate = Object.is ) { super(displayName) - this._derive = new Effect(displayName, derive, (source) => { - this._invalidatingSource = source + this._derive = new Effect(displayName, derive, () => { if (this._observers.size) this.update() }) } + toJSON() { + // Lazy init to avoid infinite loop in Derivation constructor + return (this._json ??= derive(() => getState(this.value), `toJSON(${this.displayName})`)).value + } + get sources() { return this._derive.sources } - protected override attach() { this._derive.attach() } - protected override detach() { this._derive.detach() } + + override subscribe(observer: IObserver): void { + if (!this._observers.size) + this._derive.attach() + + super.subscribe(observer) + } + + override unsubscribe(observer: IObserver): void { + super.unsubscribe(observer) + + if (!this._observers.size) + this._derive.detach() + } sourceUpdated(source: ISource, value: S) { this._derive.sourceUpdated(source, value) } @@ -51,19 +68,18 @@ export class Derivation extends SourceObserverBase { // Notifying tracker before checking for updates so circular reference is detected early sourceWasAccessed(this) - if (this._derive.sourceLastUpdated > this._derive.lastRun) - this.update() + if (this._derive.isStale) + this.update(undefined, undefined, true) return this._value // Not using super.value to avoid duplicate tracker notification } protected override skipUpdate(nextValue: T) { return this._skipUpdate(this._value, nextValue) } - update(deriveValue?: () => T, reason?: string) { + update(deriveValue?: () => T, reason?: string, isTriggerEvent = false) { let previousValue = this._value let nextValue!: T - let trigger = this._invalidatingSource ? new WeakRef(this._invalidatingSource) : undefined - delete this._invalidatingSource + let trigger = this._derive.invalidatingSource ? new WeakRef(this._derive.invalidatingSource) : undefined let sourceCreation = trackManagedSourceCreation() let logging = startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ @@ -76,11 +92,17 @@ export class Derivation extends SourceObserverBase { : reason })) try { - this.setValue(nextValue = this._derive.run(deriveValue)) + this.setValue(nextValue = this._derive.run(deriveValue), isTriggerEvent) sourceCreation.verifySourceManagement() } finally { logging?.[dispose]() sourceCreation[dispose]() } } + + override[dispose]() { + this._derive[dispose]() + this._json?.[dispose]() + super[dispose]() + } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/effect.ts b/packages/event-reduce/src/signals/effect.ts index 564cc5a..b071797 100644 --- a/packages/event-reduce/src/signals/effect.ts +++ b/packages/event-reduce/src/signals/effect.ts @@ -1,11 +1,12 @@ import { dispose } from "../utils" -import { IObserver, ISource } from "./signals" +import type { IObserver, ISource } from "./signals" import { currentIteration, trackAccessedSources } from "./tracking" export class Effect implements IObserver { + private _attached = false readonly sources = new Set>() - sourceLastUpdated = -1 - lastRun = -1 + invalidatingSource?: ISource + lastRun = -2 // Stale from the start constructor( public readonly displayName: string, @@ -14,30 +15,37 @@ export class Effect implements IObserver { ) { } attach() { + this._attached = true for (let source of this.sources) source.subscribe(this) } detach() { + this._attached = false for (let source of this.sources) source.unsubscribe(this) } sourceUpdated(source: ISource, value: S): void { if (source.lastUpdated > this.lastRun) { - this.sourceLastUpdated = source.lastUpdated + this.invalidatingSource = source this._onSourceUpdatedSinceLastRun(source) } } + get isStale() { return this.sourceLastUpdated > this.lastRun } + + get sourceLastUpdated() { return Math.max(-1, ...[...this.sources].map(s => s.lastUpdated)) } + /** * Forces the effect to run. * @param effect Optional function to use to run the effect. If not provided, the original effect function will be used. **/ run(effect?: () => T) { this.lastRun = currentIteration + delete this.invalidatingSource - let result: T; + let result: T let tracker = trackAccessedSources(this) try { @@ -53,10 +61,11 @@ export class Effect implements IObserver { for (let newSource of tracker.accessed.difference(this.sources)) { this.sources.add(newSource) - newSource.subscribe(this) + if (this._attached) + newSource.subscribe(this) } - return result; + return result } [dispose]() { diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index 7d320b8..35c766b 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -1,7 +1,7 @@ import { dispose } from "../utils" import { startLogging } from "./logging" import { applyScope, DeepPartial, matchesScope, Scoped } from "./scoping" -import { ISource, SourceBase } from "./signals" +import { type ISource, SourceBase } from "./signals" export interface IEvent extends ISource { (...args: Args): Args[0] @@ -98,17 +98,24 @@ export function makeEventFunction void, ...args: Args) => Return, getMembers?: (event: IEvent) => Members -) { +): IEvent & Members { let eventBase = new Event(name) - let eventFn = fire.bind(null, eventBase.emit) as IEvent + let eventFn = event as Event & IEvent Object.setPrototypeOf(eventFn, eventBase); eventFn.apply = Function.prototype.apply; eventFn.call = Function.prototype.call; - return Object.assign(eventFn, getMembers?.(eventFn as IEvent) ?? {}) as IEvent & Members + return Object.assign(eventFn, getMembers?.(eventFn) ?? {} as Members) + + function event(...args: Args) { return fire(eventFn.emit.bind(eventFn), ...args) } } class Event extends SourceBase { - emit = (value: T) => this.setValue(value, true) + emit(value: T) { + this.setValue(value, true) + this._value = undefined! // Events shouldn't hold on to their values + } + override skipUpdate(nextValue: T) { return false } + protected override updateChildren(previousValue: T, nextValue: T) { } // Value won't be held onto } export function isEvent(value: any): value is IEvent { diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts index bf373c1..c6e9d36 100644 --- a/packages/event-reduce/src/signals/index.ts +++ b/packages/event-reduce/src/signals/index.ts @@ -1,7 +1,8 @@ -export { ISource, Atom } from "./signals" -export { IReduction, Reducer, reduce } from "./reduction" -export { asyncEvent, event } from "./events" +export { Atom } from "./atom" export { derive } from "./derivation" export { Effect } from "./effect" +export { asyncEvent, event } from "./events" export { log, startLogging } from "./logging" -export { derived, reduced, state } from "./models" \ No newline at end of file +export { derived, reduced, state } from "./models" +export { type IReduction, reduce, type Reducer } from "./reduction" +export { type ISource } from "./signals" diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index 3e13049..2517323 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -1,24 +1,17 @@ import { argsAre, constant, dispose, getAllPropertyDescriptors, isFunction, isObject, isString, isUndefined, nameOfFunction } from "../utils" import { Derivation, derive } from "./derivation" import { isEvent } from "./events" +import { eventsBrand, isEventsClass, Model, modelProperties, ModelProperty, StateProperty } from "./nodes" import { Reduction } from "./reduction" import { ISource, isSource } from "./signals" import { getState } from "./state" import { getUnderlyingSource, trackAccessedSources, trackManagedSourceCreation, updateChildren } from "./tracking" -export interface Model extends Disposable { - [modelProperties]: Record -} - type UninitializedModel = { [modelPropertyFactories]?: Record ModelProperty> } type ModelPrototype = { [prototypePropertyFactories]?: Record ModelProperty> } -type ModelProperty = ISource | StateProperty - const modelPropertyFactories = Symbol('model property factories') const prototypePropertyFactories = Symbol('prototype property factories') -const modelProperties = Symbol('model properties') -const eventsBrand = Symbol('events class') /** * Marks a class as a model. @@ -37,22 +30,24 @@ export function model any>(target: Class): try { super(...args) + let properties = getModelProperties(this) + let prototypeFactories = (target.prototype as ModelPrototype)?.[prototypePropertyFactories] if (prototypeFactories) for (let key of Object.keys(prototypeFactories)) - getModelProperties(this)[key] = prototypeFactories[key](this) + properties[key] = prototypeFactories[key](this) let instanceFactories = (this as UninitializedModel)[modelPropertyFactories] if (instanceFactories) for (let key of Object.keys(instanceFactories)) - getModelProperties(this)[key] = instanceFactories[key]() + properties[key] = instanceFactories[key]() - for (let property of Object.values(getModelProperties(this))) + for (let property of Object.values(properties)) updateChildren(this, null, property) sourceCreation.verifySourceManagement() - for (let [key, property] of Object.entries(getModelProperties(this))) { + for (let [key, property] of Object.entries(properties)) { if (isSource(property)) { // No need to do this for state properties Object.defineProperty(this, key, { // Bind directly to the property for cleaner call stack (NOCOMMIT check this works) @@ -77,8 +72,8 @@ export function model any>(target: Class): toJSON() { return super.toJSON?.() ?? this.#json.value } [dispose]() { - Object.values(getModelProperties(this)) - .forEach(p => (p as any as Disposable)[dispose]?.()) + for (let property of Object.values(this[modelProperties])) + updateChildren(this, property, null) super[dispose]?.() } } @@ -211,8 +206,8 @@ export function state(...args: ) { if (argsAre(args, isObject, isString)) { // Legacy decorator - let [instance, key] = args - getModelPropertyFactories(instance)[key] = constant(new StateProperty(instance, key)) + let [prototype, key] = args + getPrototypePropertyFactories(prototype)[key] = instance => new StateProperty(instance, key) } else if (argsAre(args, isUndefined, isClassFieldDecoratorContext)) { // Field decorator let [_, context] = args @@ -234,10 +229,6 @@ export function state(...args: } } -export interface EventsClass { - [eventsBrand]: true -} - /** * Marks a class as an events class. * Automatically populates the names of event properties. @@ -268,17 +259,6 @@ export let events = (target: T): T => { }[className] } -export function isEventsClass(value: unknown): value is EventsClass { - return value instanceof Object - && eventsBrand in value -} - -class StateProperty { - constructor(private _instance: any, private _key: PropertyKey) { } - toJSON() { return this.value } - get value() { return this._instance[this._key] } -} - function isClassFieldDecoratorContext(context: ClassFieldDecoratorContext | unknown): context is ClassFieldDecoratorContext { return isDecoratorContext(context) && context.kind == 'field' } @@ -301,8 +281,11 @@ function getModelPropertyFactories(instance: object) { } /** For class decorators and legacy decorators that don't have access to the instance. */ -function getPrototypePropertyFactories(prototype: object) { - return (prototype as ModelPrototype)[prototypePropertyFactories] ??= {} +function getPrototypePropertyFactories(prototype: object): Record ModelProperty> { + let modelPrototype = prototype as ModelPrototype + return Object.hasOwn(prototype, prototypePropertyFactories) + ? modelPrototype[prototypePropertyFactories]! + : (modelPrototype[prototypePropertyFactories] = Object.create(modelPrototype[prototypePropertyFactories] ?? {})) } function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISource }, errorFactory: () => Error): PropertyDescriptor { @@ -322,11 +305,6 @@ function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISour } } -export function isModel(value: unknown): value is Model { - return isObject(value) - && modelProperties in value -} - export function getModelProperties(model: Model): Record { return model[modelProperties] } diff --git a/packages/event-reduce/src/signals/nodes.ts b/packages/event-reduce/src/signals/nodes.ts new file mode 100644 index 0000000..91a6760 --- /dev/null +++ b/packages/event-reduce/src/signals/nodes.ts @@ -0,0 +1,75 @@ +import { dispose, isDisposable, isObject } from "../utils" +import { ISource, isSource } from "./signals" +import { getChildNodes, trackAccessedSources, trackManagedSourceCreation, updateChildren } from "./tracking" + +export const modelProperties = Symbol('model properties') +export const eventsBrand = Symbol('events class') + +export interface Model extends Disposable { + [modelProperties]: Record +} + +export type ModelProperty = ISource | StateProperty + +export interface EventsClass { + [eventsBrand]: true +} + +export function isModel(value: unknown): value is Model { + return isObject(value) + && modelProperties in value +} + +export function isEventsClass(value: unknown): value is EventsClass { + return value instanceof Object + && eventsBrand in value +} + +export class StateProperty implements Disposable { + constructor( + private _instance: any, + public readonly key: PropertyKey + ) { + updateChildren(_instance, null, this.value) + } + + toJSON() { return this.value } + + get value() { return this._instance[this.key] } + + [dispose]() { + updateChildren(this._instance, this.value, null) + } +} + +export function createModel(create: () => T): T & Model { + let sourceCreation = trackManagedSourceCreation() + let accesses = trackAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this + try { + let instance = create() + if (isModel(instance)) + return instance + let model = Object.create(instance, { + [modelProperties]: { + value: Object.fromEntries( + getChildNodes(instance) + .entries() + .flatMap(([node, path]) => isSource(node) ? [[path, node]] : [])) + }, + [dispose]: { + value() { + Object.values(this[modelProperties]) + .forEach(p => (p as any as Disposable)[dispose]?.()) + if (isDisposable(instance)) + instance[dispose]() + } + } + }) + updateChildren(model, null, instance) + sourceCreation.verifySourceManagement() + return model + } finally { + accesses[dispose]() + sourceCreation[dispose]() + } +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index b06bb71..fdb22cf 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,7 +1,9 @@ import { dispose, emptyArray } from "../utils" +import { derive, IDerivation } from "./derivation" import { startLogging } from "./logging" -import { ISource, SourceBase, SourceObserverBase } from "./signals" -import { currentIteration, managedSourceCreated, sourceWasAccessed, trackAccessedSources } from "./tracking" +import { IObserver, ISource, SourceBase } from "./signals" +import { getState, State } from "./state" +import { currentIteration, managedSourceCreated, sourceWasAccessed, trackAccessedSources, updateChildren } from "./tracking" export interface IReduction extends ISource { readonly value: T @@ -21,12 +23,11 @@ export function reduce(initialValue: T, eventsOrDisplayName?: Events export type Reducer = (current: Result, next: Source) => Result -export class Reduction extends SourceObserverBase implements Disposable { +export class Reduction extends SourceBase implements IObserver { sources = new Set>() private _reducers = new Map, (current: T, next: any) => T>() private _sourcesLastChecked = -1 - private _attached = false - private _disposed = false + private _json: IDerivation> constructor( displayName: string, @@ -36,24 +37,26 @@ export class Reduction extends SourceObserverBase implem ) { super(displayName) + this._json = derive(() => getState(this.value), `toJSON(${this.displayName})`) + managedSourceCreated(this) this._value = initialValue - this.attach() + updateChildren(this, null, initialValue) } + toJSON() { return this._json.value; } + on(source: ISource, reduce: Reducer): this on(getSource: (events: Events) => ISource, reduce: Reducer): this on(source: ISource | ((events: Events) => ISource), reduce: Reducer) { if (!('lastUpdated' in source)) source = source(this._events!) + source.subscribe(this) this.sources.add(source) this._reducers.set(source, reduce) - if (this._attached) - source.subscribe(this) - return this } @@ -98,32 +101,9 @@ export class Reduction extends SourceObserverBase implem override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue) } - // NOCOMMIT shouldn't be required for reductions - protected override attach() { - if (this._attached || this._disposed) - return - - for (let source of this.sources) - source.subscribe(this) - - this._attached = true - } - - protected override detach() { - if (!this._attached) - return - + override[dispose]() { for (let source of this.sources) source.unsubscribe(this) - - this._attached = false - } - - [dispose]() { - if (this._disposed) - return - - this._disposed = true - this.detach() + super[dispose]() } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index b3fa8cf..a773b77 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,10 +1,9 @@ import { dispose, nameOfCallback } from "../utils" -import { derive, IDerivation } from "./derivation" -import { getState, State } from "./state" +import { derive } from "./derivation" import { beginTransition, currentIteration, getPath, sourceWasAccessed, updateChildren } from "./tracking" /** - * TODO + * NOCOMMIT * - extend * - Some way to create top-level reductions - maybe a case for a "createModel" that marks a plain object as a model and adds a dispose */ @@ -14,7 +13,7 @@ export function isSource(value: any): value is ISource { return value instanceof SourceBase } -export interface ISource { +export interface ISource extends Disposable { displayName: string readonly path: string readonly lastUpdated: number @@ -32,7 +31,6 @@ export interface IObserver { } export abstract class SourceBase implements ISource { - private _json?: IDerivation> protected readonly _observers = new Set>() protected _value!: T lastUpdated = -1 @@ -41,11 +39,6 @@ export abstract class SourceBase implements ISource { get path(): string { return getPath(this) } - toJSON() { - // Lazy init to avoid infinite loop in Derivation constructor - return (this._json ??= derive(() => getState(this.value), `toJSON(${this.displayName})`)).value - } - get value() { sourceWasAccessed(this) return this._value @@ -56,9 +49,8 @@ export abstract class SourceBase implements ISource { let transition = isTriggerEvent ? beginTransition(this) : undefined try { this.lastUpdated = currentIteration - let previousValue = this._value + this.updateChildren(this._value, newValue) this._value = newValue - updateChildren(this, previousValue, newValue) for (let observer of this._observers) observer.sourceUpdated(this, this._value) @@ -70,6 +62,10 @@ export abstract class SourceBase implements ISource { protected skipUpdate(nextValue: T) { return Object.is(this._value, nextValue) } + protected updateChildren(previousValue: T, nextValue: T) { updateChildren(this, previousValue, nextValue) } + + + // NOCOMMIT these shouldn't return IDerivation types for events map(transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`) { return derive(() => transform(this.value), `${displayName}.map(${nameOfCallback(transform)})`) } @@ -84,51 +80,12 @@ export abstract class SourceBase implements ISource { subscribe(observer: IObserver) { this._observers.add(observer) } unsubscribe(observer: IObserver) { this._observers.delete(observer) } -} - -export class Atom extends SourceBase { - constructor( - displayName: string, - initialValue: T, - private _isEqual = Object.is - ) { - super(displayName) - this.setValue(initialValue) - } - - override setValue(value: T) { super.setValue(value, true) } - - override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue) } -} - -export abstract class SourceObserverBase extends SourceBase implements IObserver { - abstract readonly sources: ReadonlySet> - - override subscribe(observer: IObserver): void { - if (!this._observers.size) - this.attach() - - super.subscribe(observer) - } - - override unsubscribe(observer: IObserver): void { - super.unsubscribe(observer) - if (!this._observers.size) - this.detach() + [dispose]() { + this.updateChildren(this._value, undefined!) + this._value = undefined! + this._observers.clear() } - - protected attach() { - for (let source of this.sources) - source.subscribe(this) - } - - protected detach() { - for (let source of this.sources) - source.unsubscribe(this) - } - - abstract sourceUpdated(source: ISource, value: S): void } export class NotASourceValueError extends Error { diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index e76c482..c5ba219 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -1,6 +1,6 @@ -import { dispose, emptyMap, isObject, nameOfFunction } from "../utils" -import { EventsClass, Model, isEventsClass, isModel } from "./models" -import { IObserver, ISource, isSource, SourceBase } from "./signals" +import { dispose, emptyMap, isDisposable, isObject, nameOfFunction } from "../utils" +import { type EventsClass, isEventsClass, isModel, type Model, StateProperty } from "./nodes" +import { type IObserver, type ISource, isSource, SourceBase } from "./signals" type GraphNode = ISource | Model | EventsClass @@ -56,13 +56,13 @@ export function updateChildren(parent: GraphNode, previousValue: unknown, nextVa let previousChildren: ReadonlyMap = emptyMap if (isObject(previousValue)) { - previousChildren = childSnapshots.get(previousValue) ?? emptyMap + previousChildren = childSnapshots.get(previousValue) ?? getChildNodes(previousValue) childSnapshots.delete(previousValue) } let newChildren: ReadonlyMap = emptyMap if (isObject(nextValue)) { - newChildren = getOwnedNodes(nextValue) + newChildren = getChildNodes(nextValue) childSnapshots.set(nextValue, newChildren) } @@ -75,10 +75,15 @@ export function updateChildren(parent: GraphNode, previousValue: unknown, nextVa addParent(added, parent, path) } -function getOwnedNodes(value: object): ReadonlyMap { +export function getChildNodes(value: object): ReadonlyMap { if (isGraphNode(value)) return new Map([[value, '']]) + if (value instanceof StateProperty) + return new Map(getChildNodes(value.value) + .entries() + .map(([node, path]) => [node, `.${String(value.key)}${path}`])) + if (Array.isArray(value)) return new Map(value .filter(isGraphNode) @@ -101,7 +106,7 @@ function addParent(object: GraphNode, owner: GraphNode, path: string) { if (!owners) parentRegistry.set(object, owners = new Map()) - owners.set(owner, '') + owners.set(owner, path) } function removeParent(node: GraphNode, parent: GraphNode) { @@ -109,7 +114,7 @@ function removeParent(node: GraphNode, parent: GraphNode) { if (owners) { owners.delete(parent) - if (!owners.size && dispose in node) { + if (!owners.size && isDisposable(node)) { parentRegistry.delete(node) garbage.add(node) } diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index 4235a13..f4b0a7b 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -116,6 +116,11 @@ export function unsubscribeAll(unsubscribes: Unsubscribe[]) { unsubscribe(); } +export function isDisposable(value: any): value is Disposable { + return isObject(value) + && dispose in value +} + export const dispose: typeof Symbol.dispose = Symbol.dispose ?? Symbol('Symbol.dispose'); /** Shim for `using` declaration until it's properly supported. */ diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index c717dea..af669a0 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,9 +1,9 @@ -import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test"; -import { Atom, derive, derived, event, reduce, reduced, state } from "event-reduce/lib/signals"; -import { getModelProperties, isModel, model } from "event-reduce/lib/signals/models"; -import { updateChildren } from "event-reduce/lib/signals/tracking"; -import { StringKey } from "event-reduce/lib/types"; -import { dispose } from "event-reduce/lib/utils"; +import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test" +import { Atom, derive, derived, event, reduce, reduced, state } from "event-reduce/src/signals" +import { getModelProperties, model } from "event-reduce/src/signals/models" +import { createModel, isModel } from "event-reduce/src/signals/nodes" +import { StringKey } from "event-reduce/src/types" +import { dispose } from "event-reduce/src/utils" describe("subscription cleanup", () => { @model @@ -11,259 +11,269 @@ describe("subscription cleanup", () => { constructor(private _initial: number) { } @reduced - get reducedProp() { return reduce(this._initial).value }; + get reducedProp() { return reduce(this._initial).value } } test("when reduction returns a new model, disposes old model", () => { - let model = new Model(0); - let unsubscribe = spyOnDispose(model, 'reducedProp'); - let createNewModel = event(); - reduce(model) - .on(createNewModel, () => new Model(1)); + let model = new Model(0) + let unsubscribe = spyOnDispose(model, 'reducedProp') + let createNewModel = event() + using _ = createModel(() => + reduce(model) + .on(createNewModel, () => new Model(1))) - createNewModel(); + createNewModel() - expect(unsubscribe).toHaveBeenCalled(); - }); + expect(unsubscribe).toHaveBeenCalled() + }) test("when reduction returns the same model, doesn't dispose model", () => { - let modelInstance = new Model(0); - let unsubscribe = spyOnDispose(modelInstance, 'reducedProp'); - let returnSameModel = event(); - reduce(modelInstance) - .on(returnSameModel, () => modelInstance); + let modelInstance = new Model(0) + let unsubscribe = spyOnDispose(modelInstance, 'reducedProp') + let returnSameModel = event() + using _ = createModel(() => reduce(modelInstance) + .on(returnSameModel, () => modelInstance)) - returnSameModel(); + returnSameModel() - expect(unsubscribe).not.toHaveBeenCalled(); - }); + expect(unsubscribe).not.toHaveBeenCalled() + }) test("when derivation returns a new model, disposes old model", () => { - let source = new Atom('test source', 0); - let derivation = derive(() => new Model(source.value)); - let modelInstance = derivation.value; - let unsubscribe = spyOnDispose(modelInstance, 'reducedProp'); + let source = new Atom('test source', 0) + let derivation = derive(() => new Model(source.value)) + let modelInstance = derivation.value + let unsubscribe = spyOnDispose(modelInstance, 'reducedProp') - source.setValue(1); - derivation.value; + source.setValue(1) + derivation.value - expect(unsubscribe).toHaveBeenCalled(); - }); + expect(unsubscribe).toHaveBeenCalled() + }) test("when derivation returns the same model, doesn't dispose model", () => { - let source = new Atom('test source', 0); - let modelInstance = new Model(0); + let source = new Atom('test source', 0) + let modelInstance = new Model(0) let derivation = derive(() => { - source.value; - return modelInstance; - }); - derivation.value; - let unsubscribe = spyOnDispose(modelInstance, 'reducedProp'); + source.value + return modelInstance + }) + derivation.value + let unsubscribe = spyOnDispose(modelInstance, 'reducedProp') - source.setValue(1); - derivation.value; + source.setValue(1) + derivation.value - expect(unsubscribe).not.toHaveBeenCalled(); - }); + expect(unsubscribe).not.toHaveBeenCalled() + }) describe("when derivation returns a model from another observable value", () => { - let model1: Atom; - let model2: Atom; - let useModel2: Atom; - let derivation: ReturnType>; - let modelInstance: Model; - let disposeModelSpy: Mock<() => void>; + let model1: Atom + let model2: Atom + let useModel2: Atom + let derivation: ReturnType> + let modelInstance: Model + let disposeModelSpy: Mock<() => void> beforeEach(() => { - model1 = new Atom('model 1', new Model(1)); - model2 = new Atom('model 2', new Model(2)); - useModel2 = new Atom('switch', false); - derivation = derive(() => useModel2.value ? model2.value : model1.value); - modelInstance = derivation.value; - disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp'); - }); + model1 = new Atom('model 1', new Model(1)) + model2 = new Atom('model 2', new Model(2)) + useModel2 = new Atom('switch', false) + derivation = derive(() => useModel2.value ? model2.value : model1.value) + modelInstance = derivation.value + disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') + }) test("when derived model changes to another model, doesn't dispose model", () => { - useModel2.setValue(true); - derivation.value; + useModel2.setValue(true) + derivation.value - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) test("when source observable value no longer holds onto model, doesn't dispose model", () => { - model1.setValue(new Model(3)); + model1.setValue(new Model(3)) - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) test("when both source observable value and derivation have switched to a different model, model is disposed", () => { - model1.setValue(new Model(3)); - useModel2.setValue(true); - derivation.value; + model1.setValue(new Model(3)) + useModel2.setValue(true) + derivation.value - expect(disposeModelSpy).toHaveBeenCalled(); - }); - }); + expect(disposeModelSpy).toHaveBeenCalled() + }) + }) test("when reduction adds a new model to an array, doesn't dispose model", () => { - let addModel = event(); - let modelInstance = new Model(0); - let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp'); - reduce([modelInstance]) - .on(addModel, ms => ms.concat(new Model(1))); + let addModel = event() + let modelInstance = new Model(0) + let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') + using _ = createModel(() => + reduce([modelInstance]) + .on(addModel, ms => ms.concat(new Model(1)))) - addModel(); + addModel() - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) describe("when reduction removes a model from an array", () => { test("when model is only in the reduction, disposes removed model and doesn't dispose remaining model", () => { - let removeModel = event(); - let model1 = new Model(1); - let model2 = new Model(2); - let dispose1 = spyOnDispose(model1, 'reducedProp'); - let dispose2 = spyOnDispose(model2, 'reducedProp'); - reduce([model1, model2]) - .on(removeModel, ms => ms.slice(0, -1)); + let removeModel = event() + let model1 = new Model(1) + let model2 = new Model(2) + let dispose1 = spyOnDispose(model1, 'reducedProp') + let dispose2 = spyOnDispose(model2, 'reducedProp') + using _ = createModel(() => + reduce([model1, model2]) + .on(removeModel, ms => ms.slice(0, -1))) - removeModel(); + removeModel() - expect(dispose2).toHaveBeenCalled(); - expect(dispose1).not.toHaveBeenCalled(); - }); + expect(dispose2).toHaveBeenCalled() + expect(dispose1).not.toHaveBeenCalled() + }) test("when model is also in another observable value, doesn't dispose removed model", () => { - let removeModel = event(); - let model1 = new Model(1); - let model2 = new Model(2); - let dispose1 = spyOnDispose(model1, 'reducedProp'); - let dispose2 = spyOnDispose(model2, 'reducedProp'); - reduce([model1, model2]) - .on(removeModel, ms => ms.slice(0, -1)); + let removeModel = event() + let model1 = new Model(1) + let model2 = new Model(2) + let dispose1 = spyOnDispose(model1, 'reducedProp') + let dispose2 = spyOnDispose(model2, 'reducedProp') + using _ = createModel(() => + reduce([model1, model2]) + .on(removeModel, ms => ms.slice(0, -1))) - new Atom('other value', model2); + new Atom('other value', model2) - removeModel(); + removeModel() - expect(dispose2).not.toHaveBeenCalled(); - expect(dispose1).not.toHaveBeenCalled(); - }); - }); + expect(dispose2).not.toHaveBeenCalled() + expect(dispose1).not.toHaveBeenCalled() + }) + }) test("when reduction changes an array to a non-array, disposes items in array", () => { - let replaceArray = event(); - let modelInstance = new Model(0); - let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp'); - reduce([modelInstance] as Model[] | null) - .on(replaceArray, () => null); + let replaceArray = event() + let modelInstance = new Model(0) + let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') + using _ = createModel(() => + reduce([modelInstance] as Model[] | null) + .on(replaceArray, () => null)) - replaceArray(); + replaceArray() - expect(disposeModelSpy).toHaveBeenCalled(); - }); + expect(disposeModelSpy).toHaveBeenCalled() + }) test("when reduction adds a model to an object, doesn't dispose model", () => { - let addModel = event(); - let modelInstance = new Model(0); - let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp'); - reduce({ oldModel: modelInstance }) - .on(addModel, ms => ({ ...ms, newModel: new Model(1) })); + let addModel = event() + let modelInstance = new Model(0) + let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') + using _ = createModel(() => + reduce({ oldModel: modelInstance }) + .on(addModel, ms => ({ ...ms, newModel: new Model(1) }))) - addModel(); + addModel() - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) describe("when reduction removes a model from an object", () => { test("when model is only in the reduction, disposes removed model and doesn't dispose remaining model", () => { - let removeModel = event(); - let model1 = new Model(1); - let model2 = new Model(2); - let dispose1 = spyOnDispose(model1, 'reducedProp'); - let dispose2 = spyOnDispose(model2, 'reducedProp'); - reduce({ model1, model2 } as Record) - .on(removeModel, ms => { - let { model2, ...remaining } = ms; - return remaining; - }); - - removeModel(); - - expect(dispose2).toHaveBeenCalled(); - expect(dispose1).not.toHaveBeenCalled(); - }); + let removeModel = event() + let model1 = new Model(1) + let model2 = new Model(2) + let dispose1 = spyOnDispose(model1, 'reducedProp') + let dispose2 = spyOnDispose(model2, 'reducedProp') + using _ = createModel(() => + reduce({ model1, model2 } as Record) + .on(removeModel, ms => { + let { model2, ...remaining } = ms + return remaining + })) + + removeModel() + + expect(dispose2).toHaveBeenCalled() + expect(dispose1).not.toHaveBeenCalled() + }) test("when model is also in another observable value, doesn't dispose models", () => { - let removeModel = event(); - let model1 = new Model(1); - let model2 = new Model(2); - let dispose1 = spyOnDispose(model1, 'reducedProp'); - let dispose2 = spyOnDispose(model2, 'reducedProp'); - reduce({ model1, model2 } as Record) - .on(removeModel, ms => { - let { model2, ...remaining } = ms; - return remaining; - }); - - new Atom('other value', model2); - - removeModel(); - - expect(dispose2).not.toHaveBeenCalled(); - expect(dispose1).not.toHaveBeenCalled(); - }); - }); + let removeModel = event() + let model1 = new Model(1) + let model2 = new Model(2) + let dispose1 = spyOnDispose(model1, 'reducedProp') + let dispose2 = spyOnDispose(model2, 'reducedProp') + using _ = createModel(() => + reduce({ model1, model2 } as Record) + .on(removeModel, ms => { + let { model2, ...remaining } = ms + return remaining + })) + + new Atom('other value', model2) + + removeModel() + + expect(dispose2).not.toHaveBeenCalled() + expect(dispose1).not.toHaveBeenCalled() + }) + }) test("when reduction replaces a model on an object, disposes removed model and doesn't dispose remaining model", () => { - let removeModel = event(); - let model1 = new Model(1); - let model2 = new Model(2); - let dispose1 = spyOnDispose(model1, 'reducedProp'); - let dispose2 = spyOnDispose(model2, 'reducedProp'); - reduce({ model1, model2 } as Record) - .on(removeModel, ms => ({ ...ms, model2: new Model(3) })); + let removeModel = event() + let model1 = new Model(1) + let model2 = new Model(2) + let dispose1 = spyOnDispose(model1, 'reducedProp') + let dispose2 = spyOnDispose(model2, 'reducedProp') + using _ = createModel(() => + reduce({ model1, model2 } as Record) + .on(removeModel, ms => ({ ...ms, model2: new Model(3) }))) - removeModel(); + removeModel() - expect(dispose2).toHaveBeenCalled(); - expect(dispose1).not.toHaveBeenCalled(); - }); + expect(dispose2).toHaveBeenCalled() + expect(dispose1).not.toHaveBeenCalled() + }) test("when reduction changes an object to a non-object, disposes values of object", () => { - let replaceObject = event(); - let modelInstance = new Model(0); - let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp'); - reduce({ model: modelInstance } as Record | null) - .on(replaceObject, () => null); + let replaceObject = event() + let modelInstance = new Model(0) + let disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') + using _ = createModel(() => + reduce({ model: modelInstance } as Record | null) + .on(replaceObject, () => null)) - replaceObject(); + replaceObject() - expect(disposeModelSpy).toHaveBeenCalled(); - }); + expect(disposeModelSpy).toHaveBeenCalled() + }) /* describe("when array items are also in another observable value", () => { test("when array value is disposed, doesn't dispose items", () => { - let arrayValue = new Atom('array value', [new Model(0)]); - new Atom('other value', arrayValue.value[0]); - let disposeModelSpy = spyOnDispose(arrayValue.value[0], 'reducedProp'); + let arrayValue = new Atom('array value', [new Model(0)]) + new Atom('other value', arrayValue.value[0]) + let disposeModelSpy = spyOnDispose(arrayValue.value[0], 'reducedProp') - arrayValue[dispose](); + arrayValue[dispose]() - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) + }) describe("when object values are also in another observable value", () => { test("when object value is disposed, doesn't dispose values", () => { - let objectValue = new Atom('object value', { model: new Model(0) }); - new Atom('other value', objectValue.value.model); - let disposeModelSpy = spyOnDispose(objectValue.value.model, 'reducedProp'); + let objectValue = new Atom('object value', { model: new Model(0) }) + new Atom('other value', objectValue.value.model) + let disposeModelSpy = spyOnDispose(objectValue.value.model, 'reducedProp') - objectValue[dispose](); + objectValue[dispose]() - expect(disposeModelSpy).not.toHaveBeenCalled(); - }); + expect(disposeModelSpy).not.toHaveBeenCalled() + }) }); */ test("when model disposed, disposes all properties correctly", () => { @@ -278,40 +288,41 @@ describe("subscription cleanup", () => { get derivedProp() { return new Model(1); } @state - stateProp = new Model(2); + stateProp = new Model(2) @derived get externallyOwned() { return this._ownedModel; } } - let externalOwner = new Atom('external owner', new Model(3)); - let outerModel = new OuterModel(externalOwner.value); - let disposeReducedPropValue = spyOnDispose(outerModel.reducedProp, 'reducedProp'); - let disposeDerivedPropValue = spyOnDispose(outerModel.derivedProp, 'reducedProp'); - let disposeStatePropValue = spyOnDispose(outerModel.stateProp, 'reducedProp'); - let disposeExternallyOwnedPropValue = spyOnDispose(outerModel.externallyOwned, 'reducedProp'); - let disposeReducedProp = spyOnDispose(outerModel, 'reducedProp'); - let disposeDerivedProp = spyOnDispose(outerModel, 'derivedProp'); - let disposeExternallyOwnedProp = spyOnDispose(outerModel, 'externallyOwned'); - - updateChildren(undefined!, outerModel, undefined); - - expect(disposeReducedProp).toHaveBeenCalled(); - expect(disposeDerivedProp).toHaveBeenCalled(); - expect(disposeExternallyOwnedProp).toHaveBeenCalled(); - - expect(disposeReducedPropValue).toHaveBeenCalled(); - expect(disposeDerivedPropValue).toHaveBeenCalled(); - expect(disposeStatePropValue).toHaveBeenCalled(); - - expect(disposeExternallyOwnedPropValue).not.toHaveBeenCalled(); - }); + let externalOwner = new Atom('external owner', new Model(3)) + let outerModel = new OuterModel(externalOwner.value) as OuterModel & Disposable + let disposeReducedPropValue = spyOnDispose(outerModel.reducedProp, 'reducedProp') + let disposeDerivedPropValue = spyOnDispose(outerModel.derivedProp, 'reducedProp') + let disposeStatePropValue = spyOnDispose(outerModel.stateProp, 'reducedProp') + let disposeExternallyOwnedPropValue = spyOnDispose(outerModel.externallyOwned, 'reducedProp') + let disposeReducedProp = spyOnDispose(outerModel, 'reducedProp') + // let disposeDerivedProp = spyOnDispose(outerModel, 'derivedProp') + // let disposeExternallyOwnedProp = spyOnDispose(outerModel, 'externallyOwned') + + let outerOwner = new Atom('outer owner', outerModel) + outerOwner.setValue(null!) + + expect(disposeReducedProp).toHaveBeenCalled() + // expect(disposeDerivedProp).toHaveBeenCalled() + // expect(disposeExternallyOwnedProp).toHaveBeenCalled() + + expect(disposeReducedPropValue).toHaveBeenCalled() + expect(disposeDerivedPropValue).toHaveBeenCalled() + expect(disposeStatePropValue).toHaveBeenCalled() + + expect(disposeExternallyOwnedPropValue).not.toHaveBeenCalled() + }) function spyOnDispose(model: T, property: StringKey) { if (isModel(model)) { - let observableValue = getModelProperties(model)[property]!; + let observableValue = getModelProperties(model)[property]! if (dispose in observableValue) - return spyOn(observableValue, dispose); + return spyOn(observableValue, dispose) } - throw new Error(`Property ${String(property)} is not a disposable observable value on the provided model`); + throw new Error(`Property ${String(property)} is not a disposable observable value on the provided model`) } -}); +}) diff --git a/tests/package.json b/tests/package.json index 3a6ff68..8ddf07f 100644 --- a/tests/package.json +++ b/tests/package.json @@ -10,8 +10,8 @@ "url": "https://github.com/soxtoby/event-reduce.git" }, "dependencies": { - "event-reduce": "*", - "event-reduce-react": "*" + "event-reduce": "workspace:*", + "event-reduce-react": "workspace:*" }, "devDependencies": { "@happy-dom/global-registrator": "^20.6.2", From eaf724667536e893d6c12db2edae7ed2807d55e2 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Thu, 12 Mar 2026 18:43:20 +1000 Subject: [PATCH 21/83] Getting Derivation tests working with signals implementation SourceBase checks if value is allowed as state Events are not allowed as state --- .../src/signals/rendering.ts | 5 +- packages/event-reduce/src/signals/atom.ts | 11 ++- .../event-reduce/src/signals/derivation.ts | 25 ++++-- packages/event-reduce/src/signals/effect.ts | 4 +- packages/event-reduce/src/signals/events.ts | 84 ++++++++++++------- packages/event-reduce/src/signals/index.ts | 6 +- packages/event-reduce/src/signals/logging.ts | 6 +- packages/event-reduce/src/signals/models.ts | 4 +- packages/event-reduce/src/signals/nodes.ts | 4 +- packages/event-reduce/src/signals/reaction.ts | 6 +- .../event-reduce/src/signals/reduction.ts | 18 ++-- packages/event-reduce/src/signals/signals.ts | 61 +++++++++----- packages/event-reduce/src/signals/tracking.ts | 41 +++++---- packages/event-reduce/src/utils.ts | 2 + tests/Derivation.test.ts | 54 ++++++------ tests/MockObserver.ts | 8 ++ 16 files changed, 214 insertions(+), 125 deletions(-) create mode 100644 tests/MockObserver.ts diff --git a/packages/event-reduce-react/src/signals/rendering.ts b/packages/event-reduce-react/src/signals/rendering.ts index f57f4d0..61b052f 100644 --- a/packages/event-reduce-react/src/signals/rendering.ts +++ b/packages/event-reduce-react/src/signals/rendering.ts @@ -1,6 +1,7 @@ import { LogValue } from "event-reduce/lib/logging" import { Effect, log, startLogging } from "event-reduce/lib/signals" import { ISource } from "event-reduce/lib/signals/signals" +import { beginTransition } from "event-reduce/lib/signals/tracking" import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/lib/utils" import { Children, ReactNode, isValidElement, useState } from "react" import { useSyncExternalStore } from "use-sync-external-store/shim" @@ -52,6 +53,7 @@ class RenderEffect extends Effect { let value!: T let trigger = this._trigger + let transition = beginTransition(this, true) let logging = startLogging('⚛️ (render)', this.displayName, emptyArray, () => ({ // Previous: this.loggedValue(previousValue), Current: loggedReactValue(value), @@ -65,7 +67,8 @@ class RenderEffect extends Effect { try { return value = super.run(deriveValue) } finally { - logging?.[dispose]() + transition[dispose]() + logging[dispose]() } } } diff --git a/packages/event-reduce/src/signals/atom.ts b/packages/event-reduce/src/signals/atom.ts index 04a2b95..693543b 100644 --- a/packages/event-reduce/src/signals/atom.ts +++ b/packages/event-reduce/src/signals/atom.ts @@ -1,6 +1,8 @@ +import { dispose } from "../utils"; import { derive, IDerivation } from "./derivation"; import { SourceBase } from "./signals"; import { getState, State } from "./state"; +import { beginTransition } from "./tracking"; export class Atom extends SourceBase { private _json: IDerivation> @@ -17,7 +19,14 @@ export class Atom extends SourceBase { toJSON() { return this._json.value; } - override setValue(value: T) { super.setValue(value, true); } + override setValue(value: T) { + let transition = beginTransition(this, true) + try { + super.setValue(value); + } finally { + transition[dispose]() + } + } override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue); } } diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 90d35c5..a83dbb5 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -3,7 +3,7 @@ import { Effect } from "./effect" import { startLogging } from "./logging" import { IObserver, ISource, SourceBase } from "./signals" import { getState, State } from "./state" -import { sourceWasAccessed, trackManagedSourceCreation } from "./tracking" +import { beginTransition, iterationSource, trackManagedSourceCreation, trackSourceAccessed } from "./tracking" export interface IDerivation extends ISource, IObserver { readonly value: T @@ -25,6 +25,11 @@ export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, return new Derivation(displayName ?? nameOfFunction(derive), derive, skipUpdate) } +// Must be set here to avoid circular dependency +SourceBase.bind = function bind(source, transform, skipUpdate, displayName) { + return new Derivation(displayName, () => transform(source.value), skipUpdate) +} + export class Derivation extends SourceBase implements IDerivation { private _derive: Effect private _json?: IDerivation> @@ -66,21 +71,26 @@ export class Derivation extends SourceBase implements IDerivation { override get value() { // Notifying tracker before checking for updates so circular reference is detected early - sourceWasAccessed(this) + let access = trackSourceAccessed(this) - if (this._derive.isStale) - this.update(undefined, undefined, true) + try { + if (this._derive.isStale) + this.update() + } finally { + access[dispose]() + } return this._value // Not using super.value to avoid duplicate tracker notification } protected override skipUpdate(nextValue: T) { return this._skipUpdate(this._value, nextValue) } - update(deriveValue?: () => T, reason?: string, isTriggerEvent = false) { + update(deriveValue?: () => T, reason?: string) { let previousValue = this._value let nextValue!: T let trigger = this._derive.invalidatingSource ? new WeakRef(this._derive.invalidatingSource) : undefined + let transition = beginTransition(this, !iterationSource) // Derivations are always safe to access, and only initiate a transition if necessary let sourceCreation = trackManagedSourceCreation() let logging = startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ Previous: previousValue, @@ -92,11 +102,12 @@ export class Derivation extends SourceBase implements IDerivation { : reason })) try { - this.setValue(nextValue = this._derive.run(deriveValue), isTriggerEvent) + this.setValue(nextValue = this._derive.run(deriveValue)) sourceCreation.verifySourceManagement() } finally { - logging?.[dispose]() + logging[dispose]() sourceCreation[dispose]() + transition[dispose]() } } diff --git a/packages/event-reduce/src/signals/effect.ts b/packages/event-reduce/src/signals/effect.ts index b071797..da0f57c 100644 --- a/packages/event-reduce/src/signals/effect.ts +++ b/packages/event-reduce/src/signals/effect.ts @@ -1,6 +1,6 @@ import { dispose } from "../utils" import type { IObserver, ISource } from "./signals" -import { currentIteration, trackAccessedSources } from "./tracking" +import { currentIteration, collectAccessedSources } from "./tracking" export class Effect implements IObserver { private _attached = false @@ -46,7 +46,7 @@ export class Effect implements IObserver { delete this.invalidatingSource let result: T - let tracker = trackAccessedSources(this) + let tracker = collectAccessedSources(this) try { result = (effect ?? this._effect)() diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index 35c766b..9bdd1d6 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -1,10 +1,18 @@ -import { dispose } from "../utils" +import { dispose, nameOfCallback } from "../utils" import { startLogging } from "./logging" import { applyScope, DeepPartial, matchesScope, Scoped } from "./scoping" import { type ISource, SourceBase } from "./signals" +import { beginTransition } from "./tracking" -export interface IEvent extends ISource { +export interface IEventBase extends ISource { (...args: Args): Args[0] + map(transform: (value: T) => Out, displayName?: string): ISource + filter(predicate: (value: T) => value is Out, displayName?: string): ISource + filterMap(transform: (value: T) => Out | undefined, displayName?: string): ISource +} + +export interface IEvent extends IEventBase { + scope>(scope: Scope): IEvent]> } export function event(name: string = "(anonymous event)"): IEvent { @@ -18,17 +26,18 @@ export function event(name: string = "(anonymous event)"): IEvent { return event }, e => ({ scope>(scope: Scope) { - return bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, + return Event.bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, (value: Scoped) => e(applyScope(value, scope)), - value => matchesScope(value, scope)) + value => matchesScope(value, scope)) as any as IEvent]> } })) } -export interface IAsyncEvent extends IEvent, [PromiseLike, Context]> { +export interface IAsyncEvent extends IEventBase, [promise: PromiseLike, context: Context]> { started: ISource> resolved: ISource> rejected: ISource> + scope>(scope: Scope): IAsyncEvent> } export type AsyncValue = @@ -86,9 +95,9 @@ export function asyncEvent(name: string = "(anony resolved: e.filter(v => 'result' in v, `${e.displayName}.resolved`), rejected: e.filter(v => 'error' in v, `${e.displayName}.rejected`), scope>(scope: Scope) { - return bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, + return Event.bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, (promise: PromiseLike, context: Scoped) => e(promise, applyScope(context, scope)), - value => matchesScope(value.context, scope)) + value => matchesScope(value.context, scope)) as IAsyncEvent> } })) } @@ -97,10 +106,10 @@ export function asyncEvent(name: string = "(anony export function makeEventFunction( name: string, fire: (emit: (event: T) => void, ...args: Args) => Return, - getMembers?: (event: IEvent) => Members -): IEvent & Members { + getMembers?: (event: IEventBase) => Members +): IEventBase & Members { let eventBase = new Event(name) - let eventFn = event as Event & IEvent + let eventFn = event as Event & IEventBase Object.setPrototypeOf(eventFn, eventBase); eventFn.apply = Function.prototype.apply; eventFn.call = Function.prototype.call; @@ -111,34 +120,45 @@ export function makeEventFunction extends SourceBase { emit(value: T) { - this.setValue(value, true) - this._value = undefined! // Events shouldn't hold on to their values + let transition = beginTransition(this, true) + try { + this.setValue(value) + } finally { + this._value = undefined! // Events shouldn't hold on to their values + transition[dispose]() + } } + override isValidState = false override skipUpdate(nextValue: T) { return false } protected override updateChildren(previousValue: T, nextValue: T) { } // Value won't be held onto + + override map(transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`): ISource { + // Mapped events should always trigger, even if the value is the same + return SourceBase.bind(this, v => transform(v), () => false, `${displayName}.map(${nameOfCallback(transform)})`) + } + + /** + * Transforms an event by wrapping the function, and filtering the emitted values. + * Useful for scope functions. + */ + static bindEvent, ScopedArgs extends any[]>( + baseEvent: E, + displayName: string, + scopedFn: (...args: ScopedArgs) => ReturnType, + eventPredicate: (value: EventValue) => boolean + ): IEventBase, ScopedArgs> { + let scopedEvent = scopedFn as IEventBase, ScopedArgs> + Object.setPrototypeOf(scopedEvent, Object.create(baseEvent, { + skipUpdate: { value: (next: EventValue) => (baseEvent as any).skipUpdate(next) || !eventPredicate(next) } + })); + scopedEvent.displayName = displayName + return scopedEvent + } } -export function isEvent(value: any): value is IEvent { +export function isEvent(value: any): value is IEventBase { return typeof value == 'function' && value instanceof Event } -/** - * Transforms an event by wrapping the function, and filtering the emitted values. - * Useful for scope functions. -*/ -export function bindEvent, ScopedArgs extends any[]>( - baseEvent: E, - displayName: string, - scopedFn: (...args: ScopedArgs) => ReturnType, - eventPredicate: (value: EventValue) => boolean -): IEvent, ScopedArgs> { - let scopedEvent = scopedFn as IEvent, ScopedArgs> - Object.setPrototypeOf(scopedEvent, Object.create(baseEvent, { - skipUpdate: { value: (next: EventValue) => (baseEvent as any).skipUpdate(next) || !eventPredicate(next) } - })); - scopedEvent.displayName = displayName - return scopedEvent -} - -type EventValue> = Event extends IEvent ? T : never \ No newline at end of file +type EventValue> = Event extends IEventBase ? T : never \ No newline at end of file diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts index c6e9d36..ee0a4e9 100644 --- a/packages/event-reduce/src/signals/index.ts +++ b/packages/event-reduce/src/signals/index.ts @@ -1,8 +1,8 @@ export { Atom } from "./atom" -export { derive } from "./derivation" +export { derive, type IDerivation } from "./derivation" export { Effect } from "./effect" export { asyncEvent, event } from "./events" export { log, startLogging } from "./logging" export { derived, reduced, state } from "./models" -export { type IReduction, reduce, type Reducer } from "./reduction" -export { type ISource } from "./signals" +export { reduce, type IReduction, type Reducer } from "./reduction" +export { type IObserver, type ISource } from "./signals" diff --git a/packages/event-reduce/src/signals/logging.ts b/packages/event-reduce/src/signals/logging.ts index 7153b09..872c6bc 100644 --- a/packages/event-reduce/src/signals/logging.ts +++ b/packages/event-reduce/src/signals/logging.ts @@ -1,4 +1,4 @@ -import { dispose } from "../utils"; +import { dispose, nullDisposable } from "../utils"; let loggingEnabled = false; @@ -23,10 +23,12 @@ export function enableLogging(enable = true) { loggingEnabled = enable; } -export function startLogging(type: string, displayName: string, args: readonly any[], getInfo?: () => Info) { +export function startLogging(type: string, displayName: string, args: readonly any[], getInfo?: () => Info): Disposable { if (process.env.NODE_ENV !== 'production' && loggingEnabled) { logStack.push([]); return { [dispose]() { logMessage(type, displayName, args, getInfo, logStack.pop()!); } } + } else { + return nullDisposable } } diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index 2517323..7e0aadd 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -5,7 +5,7 @@ import { eventsBrand, isEventsClass, Model, modelProperties, ModelProperty, Stat import { Reduction } from "./reduction" import { ISource, isSource } from "./signals" import { getState } from "./state" -import { getUnderlyingSource, trackAccessedSources, trackManagedSourceCreation, updateChildren } from "./tracking" +import { getUnderlyingSource, collectAccessedSources, trackManagedSourceCreation, updateChildren } from "./tracking" type UninitializedModel = { [modelPropertyFactories]?: Record ModelProperty> } type ModelPrototype = { [prototypePropertyFactories]?: Record ModelProperty> } @@ -26,7 +26,7 @@ export function model any>(target: Class): [modelProperties] = {} as Record constructor(...args: any[]) { let sourceCreation = trackManagedSourceCreation() - let accesses = trackAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this + let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this try { super(...args) diff --git a/packages/event-reduce/src/signals/nodes.ts b/packages/event-reduce/src/signals/nodes.ts index 91a6760..9899442 100644 --- a/packages/event-reduce/src/signals/nodes.ts +++ b/packages/event-reduce/src/signals/nodes.ts @@ -1,6 +1,6 @@ import { dispose, isDisposable, isObject } from "../utils" import { ISource, isSource } from "./signals" -import { getChildNodes, trackAccessedSources, trackManagedSourceCreation, updateChildren } from "./tracking" +import { getChildNodes, collectAccessedSources, trackManagedSourceCreation, updateChildren } from "./tracking" export const modelProperties = Symbol('model properties') export const eventsBrand = Symbol('events class') @@ -44,7 +44,7 @@ export class StateProperty implements Disposable { export function createModel(create: () => T): T & Model { let sourceCreation = trackManagedSourceCreation() - let accesses = trackAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this + let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this try { let instance = create() if (isModel(instance)) diff --git a/packages/event-reduce/src/signals/reaction.ts b/packages/event-reduce/src/signals/reaction.ts index e1218fc..69f5646 100644 --- a/packages/event-reduce/src/signals/reaction.ts +++ b/packages/event-reduce/src/signals/reaction.ts @@ -11,13 +11,13 @@ export class Reaction extends Effect { ) { super(displayName, reaction, () => { if (this._schedule) { - let reactionSource = iterationSource + let reactionSource = iterationSource! this._schedule(() => { - let transition = reactionSource ? beginTransition(reactionSource) : null + let transition = beginTransition(reactionSource, true) try { this.run() } finally { - transition?.[dispose]() + transition[dispose]() } }) } else { diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index fdb22cf..79b6279 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -3,7 +3,7 @@ import { derive, IDerivation } from "./derivation" import { startLogging } from "./logging" import { IObserver, ISource, SourceBase } from "./signals" import { getState, State } from "./state" -import { currentIteration, managedSourceCreated, sourceWasAccessed, trackAccessedSources, updateChildren } from "./tracking" +import { currentIteration, managedSourceCreated, trackSourceAccessed, collectAccessedSources, updateChildren } from "./tracking" export interface IReduction extends ISource { readonly value: T @@ -64,7 +64,7 @@ export class Reduction extends SourceBase implements IObser if (source.lastUpdated > this.lastUpdated) { let reduce = this._reducers.get(source) if (reduce) { - let tracker = trackAccessedSources(this) // Required for detecting circular references in reducers + let tracker = collectAccessedSources(this) // Required for detecting circular references in reducers let previousValue = this._value let nextValue!: T let logging = startLogging('🧪 (reduction)', this.displayName, emptyArray, () => ({ @@ -85,11 +85,15 @@ export class Reduction extends SourceBase implements IObser } override get value() { - sourceWasAccessed(this) // Notifying tracker before checking for updates so circular reference is detected early - - if (this._sourcesLastChecked < currentIteration) - for (let source of this.sources) // At most one source should be newer - this.sourceUpdated(source, (source as SourceBase).value) + let access = trackSourceAccessed(this) // Notifying tracker before checking for updates so circular reference is detected early + + try { + if (this._sourcesLastChecked < currentIteration) + for (let source of this.sources) // At most one source should be newer + this.sourceUpdated(source, (source as SourceBase).value) + } finally { + access[dispose]() + } return this._value // Not using super.value to avoid duplicate tracker notification } diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index a773b77..8086daf 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,6 +1,7 @@ -import { dispose, nameOfCallback } from "../utils" -import { derive } from "./derivation" -import { beginTransition, currentIteration, getPath, sourceWasAccessed, updateChildren } from "./tracking" +import { dispose, nameOfCallback, nameOfFunction } from "../utils" +import type { IEventBase } from "./events" +import { isEventsClass } from "./nodes" +import { beginTransition, currentIteration, getPath, iterationSource, trackSourceAccessed, updateChildren } from "./tracking" /** * NOCOMMIT @@ -40,23 +41,26 @@ export abstract class SourceBase implements ISource { get path(): string { return getPath(this) } get value() { - sourceWasAccessed(this) + trackSourceAccessed(this)[dispose]() return this._value } - protected setValue(newValue: T, isTriggerEvent = false) { + protected setValue(newValue: T) { + if (process.env.NODE_ENV !== 'production') { + if (isEventsClass(newValue) || newValue instanceof SourceBase && !newValue.isValidState) + throw new InvalidStateError(this, newValue) + + if (!iterationSource) + throw new StateChangeOutsideTransitionError(this) + } + if (!this.skipUpdate(newValue)) { - let transition = isTriggerEvent ? beginTransition(this) : undefined - try { - this.lastUpdated = currentIteration - this.updateChildren(this._value, newValue) - this._value = newValue - - for (let observer of this._observers) - observer.sourceUpdated(this, this._value) - } finally { - transition?.[dispose]() - } + this.lastUpdated = currentIteration + this.updateChildren(this._value, newValue) + this._value = newValue + + for (let observer of this._observers) + observer.sourceUpdated(this, this._value) } } @@ -64,20 +68,23 @@ export abstract class SourceBase implements ISource { protected updateChildren(previousValue: T, nextValue: T) { updateChildren(this, previousValue, nextValue) } + protected isValidState = true // NOCOMMIT these shouldn't return IDerivation types for events map(transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`) { - return derive(() => transform(this.value), `${displayName}.map(${nameOfCallback(transform)})`) + return SourceBase.bind(this, v => transform(v), Object.is, displayName) } filter(predicate: (value: T) => value is Out, displayName = `${this.displayName}.filter(${nameOfCallback(predicate)})`) { - return derive(() => this.value as Out, (_, next) => !predicate(next), `${displayName}.filter(${nameOfCallback(predicate)})`) + return SourceBase.bind(this, v => v as Out, (_, next) => !predicate(next), displayName) } filterMap(transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`) { - return derive(() => transform(this.value) as Out, (_, next) => next == undefined, `${displayName}.filterMap(${nameOfCallback(transform)})`) + return SourceBase.bind(this, v => transform(v)!, (_, next) => next == undefined, displayName) } + static bind: (source: SourceBase, transform: (value: In) => Out, skipUpdate: (previous: Out, next: Out) => boolean, displayName: string) => ISource + subscribe(observer: IObserver) { this._observers.add(observer) } unsubscribe(observer: IObserver) { this._observers.delete(observer) } @@ -94,4 +101,20 @@ export class NotASourceValueError extends Error { ) { super("Couldn't detect source for value. Make sure you pass in an observable value directly."); } +} + +export class InvalidStateError extends Error { + constructor( + public source: ISource, + public value: IEventBase | object + ) { super(`Tried to store ${isSource(value) ? value.displayName : value} in ${source.displayName}. ${nameOfFunction(value.constructor)}s cannot be state.`); } +} + +export class StateChangeOutsideTransitionError extends Error { + constructor( + public source: ISource + ) { + super(`Tried to change state of ${source.displayName} outside of a transition. ' + + 'This usually means a bug in event-reduce, or in a custom source implementation.`); + } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index c5ba219..415f953 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -1,11 +1,12 @@ -import { dispose, emptyMap, isDisposable, isObject, nameOfFunction } from "../utils" +import { dispose, emptyMap, isDisposable, isObject, nameOfFunction, nullDisposable } from "../utils" +import { Effect } from "./effect" import { type EventsClass, isEventsClass, isModel, type Model, StateProperty } from "./nodes" import { type IObserver, type ISource, isSource, SourceBase } from "./signals" type GraphNode = ISource | Model | EventsClass export let currentIteration = 0 -export let iterationSource: ISource | null = null +export let iterationSource: ISource | Effect | null = null let currentAccessTracker: AccessTracker | null = null let lastSourceAccessed: ISource | null = null @@ -18,28 +19,32 @@ let garbage = new Set() let currentCreationScope: Set> | null = null -export function beginTransition(source: ISource) { - if (iterationSource) - throw new SideEffectDuringReactionError(iterationSource, source) +export function beginTransition(source: ISource | Effect, isTriggerEvent: boolean): Disposable { + if (isTriggerEvent) { + if (iterationSource) + throw new SideEffectDuringReactionError(iterationSource, source) - currentIteration++ - iterationSource = source + currentIteration++ + iterationSource = source - return { - [dispose]() { - iterationSource = null - disposeGarbage() + return { + [dispose]() { + iterationSource = null + disposeGarbage() + } } + } else { + return nullDisposable } } -export function trackAccessedSources(observer: IObserver | null) { +export function collectAccessedSources(observer: IObserver | null) { return new AccessTracker(observer) } -export function sourceWasAccessed(source: ISource) { +export function trackSourceAccessed(source: ISource): Disposable { currentAccessTracker?.addAccessed(source) - lastSourceAccessed = source + return { [dispose]() { lastSourceAccessed = source } } } export function getUnderlyingSource(value: T): ISource | null { @@ -198,14 +203,14 @@ export function getPath(node: GraphNode): string { } export class CircularDependencyError extends Error { - constructor(path: ISource[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}`) } + constructor(path: ISource[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}.`) } } export class SideEffectDuringReactionError extends Error { constructor( - public reactingTo: ISource, - public causing: ISource - ) { super(`Side effect during reaction to ${reactingTo.displayName} in ${causing.displayName}`) } + public reactingTo: ISource | Effect, + public sideEffect: ISource | Effect + ) { super(`${sideEffect.displayName} side effect during reaction to ${reactingTo.displayName}.`) } } export class UntrackedSourceError extends Error { diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index f4b0a7b..3ba61de 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -123,6 +123,8 @@ export function isDisposable(value: any): value is Disposable { export const dispose: typeof Symbol.dispose = Symbol.dispose ?? Symbol('Symbol.dispose'); +export const nullDisposable: Disposable = Object.freeze({ [dispose]() { } }) + /** Shim for `using` declaration until it's properly supported. */ export function using(disposable: Resource, action: (resource: Resource) => void) { try { diff --git a/tests/Derivation.test.ts b/tests/Derivation.test.ts index ea4dc22..e95b466 100644 --- a/tests/Derivation.test.ts +++ b/tests/Derivation.test.ts @@ -1,17 +1,19 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { derive, event, events } from "event-reduce"; -import { DerivedEventsError, SideEffectInDerivationError } from "event-reduce/lib/derivation"; -import { consumeLastAccessed, ObservableValue } from "event-reduce/lib/observableValue"; +import { getUnderlyingSource, SideEffectDuringReactionError } from "event-reduce/src/signals/tracking"; +import { Atom, derive, event, IDerivation } from "event-reduce/src/signals"; +import { MockObserver } from "./MockObserver"; +import { InvalidStateError } from "event-reduce/src/signals/signals"; +import { events } from "event-reduce/src/signals/models"; describe("derive", () => { - let sourceA: ObservableValue; - let sourceB: ObservableValue; + let sourceA: Atom; + let sourceB: Atom; let calculation: Mock<() => string>; - let sut: ReturnType>; + let sut: IDerivation; beforeEach(() => { - sourceA = new ObservableValue(() => 'a', 'a'); - sourceB = new ObservableValue(() => 'b', 'b'); + sourceA = new Atom('a', 'a'); + sourceB = new Atom('b', 'b'); calculation = mock(() => sourceA.value + sourceB.value); sut = derive(calculation, 'sut'); }); @@ -27,7 +29,7 @@ describe("derive", () => { test("returns result of calculation", () => expect(result).toBe('ab')); - test("derivation is last accessed value", () => expect(consumeLastAccessed()!).toBe(sut)); + test("derivation is last accessed value", () => expect(getUnderlyingSource(result)!).toBe(sut)); test("when accessed again, returns same value and doesn't re-compute the value", () => { let result2 = sut.value; @@ -56,19 +58,19 @@ describe("derive", () => { }); describe("when subscribed to", () => { - let observe: Mock<(value: string) => void>; + let observer: MockObserver; beforeEach(() => { - observe = mock(); - sut.subscribe(observe); + observer = new MockObserver(); + sut.subscribe(observer); }); - test("doesn't notify immediately", () => expect(observe).not.toHaveBeenCalled()); + test("doesn't notify immediately", () => expect(observer.sourceUpdated).not.toHaveBeenCalled()); test("when a source value changed, notifies observer", () => { sourceA.setValue('A'); - expect(observe).toHaveBeenCalled(); + expect(observer.sourceUpdated).toHaveBeenCalled(); }); }); }); @@ -81,23 +83,23 @@ describe("derive", () => { derivation.value; expect.unreachable("Should have thrown"); } catch (e) { - expect(e).toBeInstanceOf(SideEffectInDerivationError); - expect((e as SideEffectInDerivationError).derivation).toBe(derivation); - expect((e as SideEffectInDerivationError).sideEffect).toBe('some event'); + expect(e).toBeInstanceOf(SideEffectDuringReactionError); + expect((e as SideEffectDuringReactionError).reactingTo).toBe(derivation); + expect((e as SideEffectDuringReactionError).sideEffect).toBe(sideEffect); } }); test("when derivation returns an event, accessing value throws", () => { - let eventValue = event('derived event'); + let eventValue = event('derived event') let derivation = derive(() => sourceA.value && eventValue); try { derivation.value; expect.unreachable("Should have thrown"); } catch (e) { - expect(e).toBeInstanceOf(DerivedEventsError); - expect((e as DerivedEventsError).derivation).toBe(derivation); - expect((e as DerivedEventsError).value).toBe(eventValue); + expect(e).toBeInstanceOf(InvalidStateError); + expect((e as InvalidStateError).source).toBe(derivation); + expect((e as InvalidStateError).value).toBe(eventValue); } }); @@ -111,20 +113,20 @@ describe("derive", () => { derivation.value; expect.unreachable("Should have thrown"); } catch (e) { - expect(e).toBeInstanceOf(DerivedEventsError); - expect((e as DerivedEventsError).derivation).toBe(derivation); - expect((e as DerivedEventsError).value).toBe(eventsValue); + expect(e).toBeInstanceOf(InvalidStateError); + expect((e as InvalidStateError).source).toBe(derivation); + expect((e as InvalidStateError).value).toBe(eventsValue); } }); }); test("derivation that depends on other derivations only updates once", () => { - let source = new ObservableValue(() => "source", "a"); + let source = new Atom("source", "a"); let inner1 = derive(() => source.value + "_inner1"); let inner2 = derive(() => source.value + "_inner2"); let outerCalc = mock(() => inner1.value + inner2.value); let outer = derive(outerCalc); - outer.subscribe(() => { }); // Outer must be observed for sources to trigger an update + outer.subscribe(new MockObserver()); // Outer must be observed for sources to trigger an update // Initial access to set up dependencies outer.value; diff --git a/tests/MockObserver.ts b/tests/MockObserver.ts new file mode 100644 index 0000000..df168f2 --- /dev/null +++ b/tests/MockObserver.ts @@ -0,0 +1,8 @@ +import { mock } from "bun:test"; +import { IObserver, ISource } from "event-reduce/src/signals"; + +export class MockObserver implements IObserver { + constructor(public displayName: string = 'mock observer') { } + sources = new Set>() + sourceUpdated = mock<(source: ISource, value: T) => void>() +} \ No newline at end of file From 17f02f76c9538b5727ce40a8c22029f1d820595a Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Thu, 12 Mar 2026 21:05:00 +1000 Subject: [PATCH 22/83] Better map/filter/filterMap typing --- packages/event-reduce/src/signals/atom.ts | 6 ++-- .../event-reduce/src/signals/derivation.ts | 6 ++-- packages/event-reduce/src/signals/events.ts | 2 +- .../event-reduce/src/signals/reduction.ts | 7 ++--- packages/event-reduce/src/signals/signals.ts | 28 +++++++++++++------ 5 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/event-reduce/src/signals/atom.ts b/packages/event-reduce/src/signals/atom.ts index 693543b..4a59cf6 100644 --- a/packages/event-reduce/src/signals/atom.ts +++ b/packages/event-reduce/src/signals/atom.ts @@ -1,10 +1,10 @@ import { dispose } from "../utils"; import { derive, IDerivation } from "./derivation"; -import { SourceBase } from "./signals"; +import { IValueSource, SourceBase } from "./signals"; import { getState, State } from "./state"; import { beginTransition } from "./tracking"; -export class Atom extends SourceBase { +export class Atom extends SourceBase implements IValueSource { private _json: IDerivation> constructor( @@ -29,4 +29,4 @@ export class Atom extends SourceBase { } override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue); } -} +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index a83dbb5..2645d33 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -1,13 +1,11 @@ import { dispose, emptyArray, nameOfFunction } from "../utils" import { Effect } from "./effect" import { startLogging } from "./logging" -import { IObserver, ISource, SourceBase } from "./signals" +import { IObserver, ISource, IValueSource, SourceBase } from "./signals" import { getState, State } from "./state" import { beginTransition, iterationSource, trackManagedSourceCreation, trackSourceAccessed } from "./tracking" -export interface IDerivation extends ISource, IObserver { - readonly value: T - +export interface IDerivation extends IValueSource, IObserver { /** * Forces the value to be re-calculated. * @param deriveValue Optional function to use to derive the value. If not provided, the original derivation function will be used. diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index 9bdd1d6..499974e 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -132,7 +132,7 @@ class Event extends SourceBase { override skipUpdate(nextValue: T) { return false } protected override updateChildren(previousValue: T, nextValue: T) { } // Value won't be held onto - override map(transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`): ISource { + override map, Out>(this: Source, transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`) { // Mapped events should always trigger, even if the value is the same return SourceBase.bind(this, v => transform(v), () => false, `${displayName}.map(${nameOfCallback(transform)})`) } diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index 79b6279..e8839c3 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,12 +1,11 @@ import { dispose, emptyArray } from "../utils" import { derive, IDerivation } from "./derivation" import { startLogging } from "./logging" -import { IObserver, ISource, SourceBase } from "./signals" +import { IObserver, ISource, IValueSource, SourceBase } from "./signals" import { getState, State } from "./state" import { currentIteration, managedSourceCreated, trackSourceAccessed, collectAccessedSources, updateChildren } from "./tracking" -export interface IReduction extends ISource { - readonly value: T +export interface IReduction extends IValueSource { on(source: ISource, reduce: Reducer): this on(getSource: (events: Events) => ISource, reduce: Reducer): this } @@ -23,7 +22,7 @@ export function reduce(initialValue: T, eventsOrDisplayName?: Events export type Reducer = (current: Result, next: Source) => Result -export class Reduction extends SourceBase implements IObserver { +export class Reduction extends SourceBase implements IReduction, IObserver { sources = new Set>() private _reducers = new Map, (current: T, next: any) => T>() private _sourcesLastChecked = -1 diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 8086daf..5ad65a9 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,7 +1,7 @@ import { dispose, nameOfCallback, nameOfFunction } from "../utils" import type { IEventBase } from "./events" import { isEventsClass } from "./nodes" -import { beginTransition, currentIteration, getPath, iterationSource, trackSourceAccessed, updateChildren } from "./tracking" +import { currentIteration, getPath, iterationSource, trackSourceAccessed, updateChildren } from "./tracking" /** * NOCOMMIT @@ -9,22 +9,27 @@ import { beginTransition, currentIteration, getPath, iterationSource, trackSourc * - Some way to create top-level reductions - maybe a case for a "createModel" that marks a plain object as a model and adds a dispose */ - export function isSource(value: any): value is ISource { return value instanceof SourceBase } +export type SourceValue> = Source extends ISource ? T : never + export interface ISource extends Disposable { displayName: string readonly path: string readonly lastUpdated: number - map(transform: (value: T) => Out, displayName?: string): ISource - filter(predicate: (value: T) => value is Out, displayName?: string): ISource - filterMap(transform: (value: T) => Out | undefined, displayName?: string): ISource subscribe(observer: IObserver): void unsubscribe(observer: IObserver): void } +export interface IValueSource extends ISource { + readonly value: T + map(transform: (value: T) => Out, displayName?: string): IValueSource + filter(predicate: (value: T) => value is Out, displayName?: string): IValueSource + filterMap(transform: (value: T) => Out | undefined, displayName?: string): IValueSource +} + export interface IObserver { displayName: string readonly sources: ReadonlySet> @@ -71,19 +76,24 @@ export abstract class SourceBase implements ISource { protected isValidState = true // NOCOMMIT these shouldn't return IDerivation types for events - map(transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`) { + map, Out>(this: Source, transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`) { return SourceBase.bind(this, v => transform(v), Object.is, displayName) } - filter(predicate: (value: T) => value is Out, displayName = `${this.displayName}.filter(${nameOfCallback(predicate)})`) { + filter, Out extends T>(this: Source, predicate: (value: T) => value is Out, displayName = `${this.displayName}.filter(${nameOfCallback(predicate)})`) { return SourceBase.bind(this, v => v as Out, (_, next) => !predicate(next), displayName) } - filterMap(transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`) { + filterMap, Out>(this: Source, transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`) { return SourceBase.bind(this, v => transform(v)!, (_, next) => next == undefined, displayName) } - static bind: (source: SourceBase, transform: (value: In) => Out, skipUpdate: (previous: Out, next: Out) => boolean, displayName: string) => ISource + static bind: , Out>( + source: Source, + transform: (value: SourceValue) => Out, + skipUpdate: (previous: Out, next: Out) => boolean, + displayName: string + ) => Source extends IValueSource ? IValueSource : ISource subscribe(observer: IObserver) { this._observers.add(observer) } unsubscribe(observer: IObserver) { this._observers.delete(observer) } From 6b4400dfcc150b0f1893ddaa24c724759ab0ce18 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Fri, 13 Mar 2026 20:44:34 +1000 Subject: [PATCH 23/83] Getting event tests working Reworked event scoping Using new BoundSource instead of Derivation for map/filter/filterMap --- .../event-reduce/src/signals/derivation.ts | 5 - packages/event-reduce/src/signals/events.ts | 134 +++++++++--------- packages/event-reduce/src/signals/index.ts | 2 +- packages/event-reduce/src/signals/scoping.ts | 2 +- packages/event-reduce/src/signals/signals.ts | 76 +++++++--- tests/Event.test.ts | 61 ++++---- tests/MockObserver.ts | 4 +- 7 files changed, 160 insertions(+), 124 deletions(-) diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 2645d33..3554172 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -23,11 +23,6 @@ export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, return new Derivation(displayName ?? nameOfFunction(derive), derive, skipUpdate) } -// Must be set here to avoid circular dependency -SourceBase.bind = function bind(source, transform, skipUpdate, displayName) { - return new Derivation(displayName, () => transform(source.value), skipUpdate) -} - export class Derivation extends SourceBase implements IDerivation { private _derive: Effect private _json?: IDerivation> diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index 499974e..21a6330 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -1,39 +1,43 @@ -import { dispose, nameOfCallback } from "../utils" +import { dispose } from "../utils" import { startLogging } from "./logging" import { applyScope, DeepPartial, matchesScope, Scoped } from "./scoping" -import { type ISource, SourceBase } from "./signals" +import { type ISource, SourceBase, SourceValue } from "./signals" import { beginTransition } from "./tracking" -export interface IEventBase extends ISource { - (...args: Args): Args[0] - map(transform: (value: T) => Out, displayName?: string): ISource - filter(predicate: (value: T) => value is Out, displayName?: string): ISource - filterMap(transform: (value: T) => Out | undefined, displayName?: string): ISource +export interface IEventFunction extends ISource { + (...args: Args): Return } -export interface IEvent extends IEventBase { +export interface IEvent extends IEventFunction { scope>(scope: Scope): IEvent]> } +export type AdditionalMembersFactory, Members extends object> = + (source: Source & IEventFunction, any>) => Members + export function event(name: string = "(anonymous event)"): IEvent { - return makeEventFunction(name, (emit: (value: T) => void, event: T) => { - let logging = startLogging('⚡ (event)', name, [event]) // NOCOMMIT Container + return makeEventFunction(name, (emit: (value: T) => void, value: T) => { + let logging = startLogging('⚡ (event)', name, [value]) // NOCOMMIT Container try { - emit(event) + emit(value) } finally { logging?.[dispose]() } - return event - }, e => ({ - scope>(scope: Scope) { - return Event.bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, - (value: Scoped) => e(applyScope(value, scope)), - value => matchesScope(value, scope)) as any as IEvent]> + return value + }, function eventMembers(e: IEventFunction) { + return { + scope>(scope: Scope) { + return bindEvent(e, + `${e.displayName}.scoped(${JSON.stringify(scope)})`, + (value: Scoped) => e(applyScope(value, scope)), + v => matchesScope(v, scope), + eventMembers) + } } - })) + }) as IEvent } -export interface IAsyncEvent extends IEventBase, [promise: PromiseLike, context: Context]> { +export interface IAsyncEvent extends IEventFunction, [promise: PromiseLike, context: Context]> { started: ISource> resolved: ISource> rejected: ISource> @@ -90,32 +94,57 @@ export function asyncEvent(name: string = "(anony }) return promise - }, e => ({ - started: e.filter(v => 'promise' in v, `${e.displayName}.started`), - resolved: e.filter(v => 'result' in v, `${e.displayName}.resolved`), - rejected: e.filter(v => 'error' in v, `${e.displayName}.rejected`), - scope>(scope: Scope) { - return Event.bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, - (promise: PromiseLike, context: Scoped) => e(promise, applyScope(context, scope)), - value => matchesScope(value.context, scope)) as IAsyncEvent> + }, function asyncEventMembers(e: IEventFunction, [promise: PromiseLike, context: Context]>) { + return { + started: e.filter(v => 'promise' in v, `${e.displayName}.started`), + resolved: e.filter(v => 'result' in v, `${e.displayName}.resolved`), + rejected: e.filter(v => 'error' in v, `${e.displayName}.rejected`), + scope>(scope: Scope) { + return bindEvent(e, + `${e.displayName}.scoped(${JSON.stringify(scope)})`, + (promise: PromiseLike, context: Scoped) => e(promise, applyScope(context, scope)), + v => matchesScope(v.context, scope), + asyncEventMembers) + } } - })) + }) as IAsyncEvent } -/** Converts an event class into a callable function */ +/** Creates a callable event object. */ export function makeEventFunction( name: string, - fire: (emit: (event: T) => void, ...args: Args) => Return, - getMembers?: (event: IEventBase) => Members -): IEventBase & Members { - let eventBase = new Event(name) - let eventFn = event as Event & IEventBase - Object.setPrototypeOf(eventFn, eventBase); + fire: (emit: (value: T) => void, ...args: Args) => Return, + getMembers?: AdditionalMembersFactory, Members> +): IEventFunction & Members { + let eventSource = new Event(name) as Event & IEventFunction + return makeSourceFunction(eventSource, event, getMembers) + + function event(...args: Args) { return fire(eventSource.emit.bind(event), ...args) } +} + +/** Wrap an event with input and output filter. Useful for scoping. */ +export function bindEvent, Args extends any[], Return, Members extends object>( + event: E, + displayName: string, + fire: (...args: Args) => Return, + predicate: (value: SourceValue) => boolean, + getMembers?: AdditionalMembersFactory, Members> +) { + return makeSourceFunction(event.filter(predicate, displayName), fire, getMembers) +} + +function makeSourceFunction, Args extends any[], Return, Members extends object>( + innerEvent: Source, + fire: (...args: Args) => Return, + getMembers?: AdditionalMembersFactory +): IEventFunction, Args, Return> & Members { + let eventFn = fire as Source & IEventFunction, Args, Return> + Object.setPrototypeOf(eventFn, innerEvent); eventFn.apply = Function.prototype.apply; eventFn.call = Function.prototype.call; - return Object.assign(eventFn, getMembers?.(eventFn) ?? {} as Members) - - function event(...args: Args) { return fire(eventFn.emit.bind(eventFn), ...args) } + return getMembers + ? Object.assign(eventFn, getMembers(eventFn)) + : eventFn as Source & IEventFunction, Args, Return> & Members } class Event extends SourceBase { @@ -131,34 +160,9 @@ class Event extends SourceBase { override isValidState = false override skipUpdate(nextValue: T) { return false } protected override updateChildren(previousValue: T, nextValue: T) { } // Value won't be held onto - - override map, Out>(this: Source, transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`) { - // Mapped events should always trigger, even if the value is the same - return SourceBase.bind(this, v => transform(v), () => false, `${displayName}.map(${nameOfCallback(transform)})`) - } - - /** - * Transforms an event by wrapping the function, and filtering the emitted values. - * Useful for scope functions. - */ - static bindEvent, ScopedArgs extends any[]>( - baseEvent: E, - displayName: string, - scopedFn: (...args: ScopedArgs) => ReturnType, - eventPredicate: (value: EventValue) => boolean - ): IEventBase, ScopedArgs> { - let scopedEvent = scopedFn as IEventBase, ScopedArgs> - Object.setPrototypeOf(scopedEvent, Object.create(baseEvent, { - skipUpdate: { value: (next: EventValue) => (baseEvent as any).skipUpdate(next) || !eventPredicate(next) } - })); - scopedEvent.displayName = displayName - return scopedEvent - } } -export function isEvent(value: any): value is IEventBase { +export function isEvent(value: any): value is IEventFunction { return typeof value == 'function' && value instanceof Event -} - -type EventValue> = Event extends IEventBase ? T : never \ No newline at end of file +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts index ee0a4e9..346ffd8 100644 --- a/packages/event-reduce/src/signals/index.ts +++ b/packages/event-reduce/src/signals/index.ts @@ -1,7 +1,7 @@ export { Atom } from "./atom" export { derive, type IDerivation } from "./derivation" export { Effect } from "./effect" -export { asyncEvent, event } from "./events" +export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue } from "./events" export { log, startLogging } from "./logging" export { derived, reduced, state } from "./models" export { reduce, type IReduction, type Reducer } from "./reduction" diff --git a/packages/event-reduce/src/signals/scoping.ts b/packages/event-reduce/src/signals/scoping.ts index 213882b..736233f 100644 --- a/packages/event-reduce/src/signals/scoping.ts +++ b/packages/event-reduce/src/signals/scoping.ts @@ -17,7 +17,7 @@ export function applyScope>(target: Scoped, sc } } -export function matchesScope(value: unknown, scope: unknown): boolean { +export function matchesScope(value: unknown, scope: Scope): value is Scope { if (isObject(scope)) { if (Array.isArray(scope)) return Array.isArray(value) diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 5ad65a9..7fe3867 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,5 +1,5 @@ -import { dispose, nameOfCallback, nameOfFunction } from "../utils" -import type { IEventBase } from "./events" +import { dispose, identity, nameOfCallback, nameOfFunction } from "../utils" +import type { IEventFunction } from "./events" import { isEventsClass } from "./nodes" import { currentIteration, getPath, iterationSource, trackSourceAccessed, updateChildren } from "./tracking" @@ -21,18 +21,20 @@ export interface ISource extends Disposable { readonly lastUpdated: number subscribe(observer: IObserver): void unsubscribe(observer: IObserver): void + + map(transform: (value: T) => Out, displayName?: string): ISource + filter(predicate: (value: T) => boolean, displayName?: string): ISource + filter(predicate: (value: T) => value is Out, displayName?: string): ISource + filterMap(transform: (value: T) => Out | undefined, displayName?: string): ISource } export interface IValueSource extends ISource { readonly value: T - map(transform: (value: T) => Out, displayName?: string): IValueSource - filter(predicate: (value: T) => value is Out, displayName?: string): IValueSource - filterMap(transform: (value: T) => Out | undefined, displayName?: string): IValueSource } export interface IObserver { displayName: string - readonly sources: ReadonlySet> + // readonly sources: ReadonlySet> // NOCOMMIT remove? sourceUpdated(source: ISource, value: S): void } @@ -75,26 +77,18 @@ export abstract class SourceBase implements ISource { protected isValidState = true - // NOCOMMIT these shouldn't return IDerivation types for events - map, Out>(this: Source, transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`) { - return SourceBase.bind(this, v => transform(v), Object.is, displayName) + map, Out>(this: Source, transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`): ISource { + return bind(displayName, this, transform) } - filter, Out extends T>(this: Source, predicate: (value: T) => value is Out, displayName = `${this.displayName}.filter(${nameOfCallback(predicate)})`) { - return SourceBase.bind(this, v => v as Out, (_, next) => !predicate(next), displayName) + filter>(this: Source, predicate: (value: T) => boolean, displayName = `${this.displayName}.filter(${nameOfCallback(predicate)})`): ISource { + return bind(displayName, this, identity, predicate) } - filterMap, Out>(this: Source, transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`) { - return SourceBase.bind(this, v => transform(v)!, (_, next) => next == undefined, displayName) + filterMap, Out>(this: Source, transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`): ISource { + return bind(displayName, this, v => transform(v)!, value => value == undefined) } - static bind: , Out>( - source: Source, - transform: (value: SourceValue) => Out, - skipUpdate: (previous: Out, next: Out) => boolean, - displayName: string - ) => Source extends IValueSource ? IValueSource : ISource - subscribe(observer: IObserver) { this._observers.add(observer) } unsubscribe(observer: IObserver) { this._observers.delete(observer) } @@ -105,6 +99,46 @@ export abstract class SourceBase implements ISource { } } +export function bind(displayName: string, source: ISource, transform: (value: In) => Out, predicate?: (value: Out) => boolean) { + return new BoundSource(displayName, source, transform, predicate) +} + +class BoundSource extends SourceBase { + constructor( + displayName: string, + private _source: ISource, + private _transform: (value: In) => Out, + private _predicate?: (value: Out) => boolean + ) { + super(displayName) + _source.subscribe(this) + } + + override subscribe(observer: IObserver): void { + if (!this._observers.size) + this._source.subscribe(this) + + super.subscribe(observer) + } + + override unsubscribe(observer: IObserver): void { + super.unsubscribe(observer) + + if (!this._observers.size) + this._source.unsubscribe(this) + } + + sourceUpdated(source: ISource, value: In): void { + try { + this.setValue(this._transform(value)) + } finally { + this._value = undefined! // Bound sources shouldn't hold on to their values + } + } + + override skipUpdate(value: Out) { return this._predicate?.(value) == false } +} + export class NotASourceValueError extends Error { constructor( public value: unknown @@ -116,7 +150,7 @@ export class NotASourceValueError extends Error { export class InvalidStateError extends Error { constructor( public source: ISource, - public value: IEventBase | object + public value: IEventFunction | object ) { super(`Tried to store ${isSource(value) ? value.displayName : value} in ${source.displayName}. ${nameOfFunction(value.constructor)}s cannot be state.`); } } diff --git a/tests/Event.test.ts b/tests/Event.test.ts index 2518df1..a35d11f 100644 --- a/tests/Event.test.ts +++ b/tests/Event.test.ts @@ -1,6 +1,7 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart } from 'event-reduce'; -import { ChainedEventsError } from "event-reduce/lib/events"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart } from 'event-reduce/src/signals'; +import { SideEffectDuringReactionError } from "event-reduce/src/signals/tracking"; +import { MockObserver } from "./MockObserver"; describe("event", () => { type TestType = { foo: string, bar: number }; @@ -11,10 +12,10 @@ describe("event", () => { }); describe("when subscribed to", () => { - let subscriber: Mock<(value: TestType) => void>; + let subscriber: MockObserver; beforeEach(() => { - subscriber = mock(); + subscriber = new MockObserver() sut.subscribe(subscriber); }); @@ -23,41 +24,43 @@ describe("event", () => { sut(value); - expect(subscriber).toHaveBeenCalledWith(value); + expect(subscriber.sourceUpdated).toHaveBeenCalledWith(sut, value); }); }); test("when handler calls another event, throws", () => { let otherEvent = event('otherEvent'); - sut.subscribe(() => otherEvent(undefined)); + let observer = new MockObserver() + observer.sourceUpdated = () => otherEvent(undefined) + sut.subscribe(observer); try { sut({ foo: 'foo', bar: 1 }); expect.unreachable("Should have thrown"); } catch (e) { - expect(e).toBeInstanceOf(ChainedEventsError); - expect((e as ChainedEventsError).currentEvent).toBe(sut.displayName); - expect((e as ChainedEventsError).newEvent).toBe(otherEvent.displayName); + expect(e).toBeInstanceOf(SideEffectDuringReactionError); + expect((e as SideEffectDuringReactionError).reactingTo).toBe(sut); + expect((e as SideEffectDuringReactionError).sideEffect).toBe(otherEvent); } }); describe("scope", () => { let scoped: any; - let rootSubscriber: Mock<(value: TestType) => void>; - let scopedSubscriber: Mock<(value: TestType) => void>; + let rootObserver: MockObserver; + let scopedObserver: MockObserver; beforeEach(() => { scoped = sut.scope({ foo: 'foo' }); - rootSubscriber = mock(); - scopedSubscriber = mock(); - sut.subscribe(rootSubscriber); - scoped.subscribe(scopedSubscriber); + rootObserver = new MockObserver() + scopedObserver = new MockObserver() + sut.subscribe(rootObserver); + scoped.subscribe(scopedObserver); }); test("when non-matching value passed to parent event, scoped event does not pass on value", () => { sut({ foo: 'bar', bar: 1 }); - expect(scopedSubscriber).not.toHaveBeenCalled(); + expect(scopedObserver.sourceUpdated).not.toHaveBeenCalled(); }); test("when matching value passed to parent event, scoped event passed on value", () => { @@ -65,14 +68,14 @@ describe("event", () => { sut(value); - expect(scopedSubscriber).toHaveBeenCalledWith(value); + expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, value); }); test("when called with partial value, scoped event fills in scope values", () => { scoped({ bar: 2 }); - expect(rootSubscriber).toHaveBeenCalledWith(expect.objectContaining({ foo: 'foo', bar: 2 })); - expect(scopedSubscriber).toHaveBeenCalledWith(expect.objectContaining({ foo: 'foo', bar: 2 })); + expect(rootObserver.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ foo: 'foo', bar: 2 })); + expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, expect.objectContaining({ foo: 'foo', bar: 2 })); }); }); }); @@ -82,15 +85,15 @@ describe("asyncEvent", () => { type Context = { bar: string; }; let sut: ReturnType>; - let started: Mock<(value: AsyncStart) => void>; - let resolved: Mock<(value: AsyncResult) => void>; - let rejected: Mock<(value: AsyncError) => void>; + let started: MockObserver>; + let resolved: MockObserver>; + let rejected: MockObserver>; beforeEach(() => { sut = asyncEvent(); - started = mock(); - resolved = mock(); - rejected = mock(); + started = new MockObserver() + resolved = new MockObserver() + rejected = new MockObserver() sut.started.subscribe(started); sut.resolved.subscribe(resolved); sut.rejected.subscribe(rejected); @@ -108,14 +111,14 @@ describe("asyncEvent", () => { sut(promise, context); }); - test("started event fired", () => expect(started).toHaveBeenCalledWith(expect.objectContaining({ promise, context }))); + test("started event fired", () => expect(started.sourceUpdated).toHaveBeenCalledWith(sut.started, expect.objectContaining({ promise, context }))); test("when promise resolved, resolved event fired", async () => { let result = { foo: 'result' }; resolve(result); await promise; - expect(resolved).toHaveBeenCalledWith(expect.objectContaining({ result, context })); + expect(resolved.sourceUpdated).toHaveBeenCalledWith(sut.resolved, expect.objectContaining({ result, context })); }); test("when promise rejected, rejected event fired", async () => { @@ -123,7 +126,7 @@ describe("asyncEvent", () => { reject(error); await promise.catch(() => { }); // Catch to prevent unhandled rejection - expect(rejected).toHaveBeenCalledWith(expect.objectContaining({ error, context })); + expect(rejected.sourceUpdated).toHaveBeenCalledWith(sut.rejected, expect.objectContaining({ error, context })); }); }); }); \ No newline at end of file diff --git a/tests/MockObserver.ts b/tests/MockObserver.ts index df168f2..b426f8d 100644 --- a/tests/MockObserver.ts +++ b/tests/MockObserver.ts @@ -2,7 +2,7 @@ import { mock } from "bun:test"; import { IObserver, ISource } from "event-reduce/src/signals"; export class MockObserver implements IObserver { - constructor(public displayName: string = 'mock observer') { } + displayName = 'mock observer' sources = new Set>() - sourceUpdated = mock<(source: ISource, value: T) => void>() + sourceUpdated: (source: ISource, value: T) => void = mock<(source: ISource, value: T) => void>() } \ No newline at end of file From ac976c25ef9a0accf8de015a2736de9081cc374c Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Fri, 13 Mar 2026 20:59:15 +1000 Subject: [PATCH 24/83] Back to shallow scoping for simpler typing --- packages/event-reduce/src/signals/events.ts | 24 +++++----- packages/event-reduce/src/signals/scoping.ts | 49 -------------------- packages/event-reduce/src/types.ts | 4 +- 3 files changed, 14 insertions(+), 63 deletions(-) delete mode 100644 packages/event-reduce/src/signals/scoping.ts diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index 21a6330..2b8f8c3 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -1,6 +1,6 @@ -import { dispose } from "../utils" +import { Scoped } from "../types" +import { dispose, matchesScope } from "../utils" import { startLogging } from "./logging" -import { applyScope, DeepPartial, matchesScope, Scoped } from "./scoping" import { type ISource, SourceBase, SourceValue } from "./signals" import { beginTransition } from "./tracking" @@ -9,7 +9,7 @@ export interface IEventFunction extends } export interface IEvent extends IEventFunction { - scope>(scope: Scope): IEvent]> + scope>(scope: Scope): IEvent, Out> } export type AdditionalMembersFactory, Members extends object> = @@ -24,14 +24,14 @@ export function event(name: string = "(anonymous event)"): IEvent { logging?.[dispose]() } return value - }, function eventMembers(e: IEventFunction) { + }, function eventMembers(e: IEventFunction) { return { - scope>(scope: Scope) { + scope>(scope: Scope): IEvent, T> { return bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, - (value: Scoped) => e(applyScope(value, scope)), - v => matchesScope(v, scope), - eventMembers) + (value: Scoped) => e({ ...value, ...scope } as In), + matchesScope(scope), + eventMembers) as IEvent, T> } } }) as IEvent @@ -41,7 +41,7 @@ export interface IAsyncEvent extends IEventFunction> resolved: ISource> rejected: ISource> - scope>(scope: Scope): IAsyncEvent> + scope>(scope: Scope): IAsyncEvent> } export type AsyncValue = @@ -99,11 +99,11 @@ export function asyncEvent(name: string = "(anony started: e.filter(v => 'promise' in v, `${e.displayName}.started`), resolved: e.filter(v => 'result' in v, `${e.displayName}.resolved`), rejected: e.filter(v => 'error' in v, `${e.displayName}.rejected`), - scope>(scope: Scope) { + scope>(scope: Scope) { return bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, - (promise: PromiseLike, context: Scoped) => e(promise, applyScope(context, scope)), - v => matchesScope(v.context, scope), + (promise: PromiseLike, context: Scoped) => e(promise, { ...context, ...scope } as Context), + v => matchesScope(scope, v.context), asyncEventMembers) } } diff --git a/packages/event-reduce/src/signals/scoping.ts b/packages/event-reduce/src/signals/scoping.ts deleted file mode 100644 index 736233f..0000000 --- a/packages/event-reduce/src/signals/scoping.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { isObject } from "../utils" - -export function applyScope>(target: Scoped, scope: S): T { - if (isObject(target) && isObject(scope)) { - if (Array.isArray(target) && Array.isArray(scope)) { - let result = target.slice() as any - scope.forEach((s, i) => result[i] = applyScope(result[i], s)) - return result - } else { - let result = Object.create(target) as any - Object.entries(scope) - .forEach(([key, scopeValue]) => result[key] = applyScope((target as any)[key], scopeValue as any)) - return result - } - } else { - return target ?? scope as any - } -} - -export function matchesScope(value: unknown, scope: Scope): value is Scope { - if (isObject(scope)) { - if (Array.isArray(scope)) - return Array.isArray(value) - && scope.every((s, i) => matchesScope((value as any)[i], s)) - else - return isObject(value) - && Object.entries(scope).every(([key, scopeValue]) => matchesScope((value as any)[key], scopeValue)) - } - - return Object.is(scope, value) -} - -export type DeepPartial = { [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] } - -export type Scoped> = - T extends object - ? ( - // Properties not in scope - & { [P in keyof T as P extends keyof Scope ? never : P]: T[P] } - // Non-objects in scope - & { [P in keyof T as P extends keyof Scope ? T[P] extends object ? never : P : never]: T[P] } - // Objects partially specified in scope - & { [P in keyof T as P extends keyof Scope ? T[P] extends object ? Scope[P] extends T[P] ? never : P : never : never]: T[P] extends object ? Scope[P] extends object ? Prettify> : never : never } - // Objects fully specified in scope - & { [P in keyof T as P extends keyof Scope ? T[P] extends object ? Scope[P] extends T[P] ? P : never : never : never]?: T[P] extends object ? Scope[P] extends object ? Prettify> : never : never } - ) - : T - -type Prettify = { [K in keyof T]: T[K]; } & {} \ No newline at end of file diff --git a/packages/event-reduce/src/types.ts b/packages/event-reduce/src/types.ts index 989e95e..734cdd2 100644 --- a/packages/event-reduce/src/types.ts +++ b/packages/event-reduce/src/types.ts @@ -1,6 +1,6 @@ -export type Scoped = +export type Scoped> = & Omit - & { [K in keyof Scope]?: T[K]; }; + & { [K in keyof Scope]?: K extends keyof T ? T[K] : never; }; export type StringKey = Extract; export type OmitValues = FromEntries, [any, Omitted]>>; From efad5c25bbb225021bc08362183fcdb4bd9ba20d Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 15 Mar 2026 10:46:40 +1000 Subject: [PATCH 25/83] Getting Model tests working with signals implementation Setting displayName for model properties Added extendReduce for extending super class reductions Derivations run once when subscribed to collect sources Added createModel helper for creating loose model objects Caching getter sources during construction to avoid creating multiple sources Properly cleaning up sources overridden in sub models Moved Reduction's setValue out of its source tracking scope so child nodes can access it without throwing --- .../event-reduce/src/signals/derivation.ts | 6 +- packages/event-reduce/src/signals/events.ts | 2 +- packages/event-reduce/src/signals/index.ts | 6 +- packages/event-reduce/src/signals/models.ts | 260 ++++++++++++------ packages/event-reduce/src/signals/nodes.ts | 38 +-- .../event-reduce/src/signals/reduction.ts | 38 ++- packages/event-reduce/src/signals/signals.ts | 8 +- packages/event-reduce/src/signals/tracking.ts | 22 +- packages/event-reduce/src/utils.ts | 14 + tests/Cleanup.test.ts | 7 +- tests/Model.test.ts | 79 +----- tests/SignalsOwnership.test.ts | 109 -------- tests/classes/ExtendedModel.ts | 16 ++ tests/classes/TestModel.ts | 37 +++ 14 files changed, 304 insertions(+), 338 deletions(-) delete mode 100644 tests/SignalsOwnership.test.ts create mode 100644 tests/classes/ExtendedModel.ts create mode 100644 tests/classes/TestModel.ts diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 3554172..7f56788 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -20,12 +20,13 @@ export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, let [skipUpdate, displayName] = typeof displayNameOrSkip == 'function' ? [displayNameOrSkip, maybeDisplayName] : [Object.is, displayNameOrSkip] - return new Derivation(displayName ?? nameOfFunction(derive), derive, skipUpdate) + return new Derivation((displayName ?? nameOfFunction(derive)) || Derivation.defaultName, derive, skipUpdate) } export class Derivation extends SourceBase implements IDerivation { private _derive: Effect private _json?: IDerivation> + static defaultName = "(anonymous derivation)" constructor( displayName: string, @@ -50,6 +51,9 @@ export class Derivation extends SourceBase implements IDerivation { if (!this._observers.size) this._derive.attach() + if (this.lastUpdated < 0) + this.update() // Subscriber may never be notified if we don't collect any sources + super.subscribe(observer) } diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index 2b8f8c3..b379aa8 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -157,7 +157,7 @@ class Event extends SourceBase { transition[dispose]() } } - override isValidState = false + override isValidAsState = false override skipUpdate(nextValue: T) { return false } protected override updateChildren(previousValue: T, nextValue: T) { } // Value won't be held onto } diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts index 346ffd8..983221d 100644 --- a/packages/event-reduce/src/signals/index.ts +++ b/packages/event-reduce/src/signals/index.ts @@ -1,8 +1,8 @@ export { Atom } from "./atom" export { derive, type IDerivation } from "./derivation" export { Effect } from "./effect" -export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue } from "./events" +export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IEvent } from "./events" export { log, startLogging } from "./logging" -export { derived, reduced, state } from "./models" -export { reduce, type IReduction, type Reducer } from "./reduction" +export { createModel, derived, events, model, reduced, state } from "./models" +export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" export { type IObserver, type ISource } from "./signals" diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index 7e0aadd..86b2abf 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -1,17 +1,57 @@ -import { argsAre, constant, dispose, getAllPropertyDescriptors, isFunction, isObject, isString, isUndefined, nameOfFunction } from "../utils" -import { Derivation, derive } from "./derivation" +import { argsAre, constant, dispose, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "../utils" +import { Derivation, derive, IDerivation } from "./derivation" import { isEvent } from "./events" -import { eventsBrand, isEventsClass, Model, modelProperties, ModelProperty, StateProperty } from "./nodes" -import { Reduction } from "./reduction" -import { ISource, isSource } from "./signals" +import { eventsBrand, isEventsClass, isModel, Model, modelProperties, ModelProperty, StateProperty } from "./nodes" +import { IReduction, Reduction } from "./reduction" +import { ISource, isSource, SourceValue } from "./signals" import { getState } from "./state" -import { getUnderlyingSource, collectAccessedSources, trackManagedSourceCreation, updateChildren } from "./tracking" +import { collectAccessedSources, getChildNodes, getUnderlyingSource, trackManagedSourceCreation, updateChildren } from "./tracking" -type UninitializedModel = { [modelPropertyFactories]?: Record ModelProperty> } -type ModelPrototype = { [prototypePropertyFactories]?: Record ModelProperty> } - -const modelPropertyFactories = Symbol('model property factories') -const prototypePropertyFactories = Symbol('prototype property factories') +/** instance -> key -> factory */ +let instancePropertyFactories = new WeakMap ModelProperty>>() +/** prototype -> key -> factory */ +let prototypePropertyFactories = new WeakMap ModelProperty>>() +/** + * instance -> getter -> property source + * Required for getters to cache their source without being overridden in sub-classes +*/ +let getterCache = new WeakMap>>() + +const modelBrand = Symbol('model class') + +/** Creates a loose model object. Not required if your model is already decorated with \@{@link model}. */ +export function createModel(create: () => T): T & Model { + let sourceCreation = trackManagedSourceCreation() + let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this + try { + let instance = create() + if (isModel(instance)) + return instance + let model = Object.create(instance, { + [modelProperties]: { + value: Object.fromEntries( + getChildNodes(instance) + .entries() + .flatMap(([node, path]) => isSource(node) ? [[path, node]] : [])) + }, + [dispose]: { + value() { + Object.values(this[modelProperties]) + .filter(isDisposable) + .forEach(p => p[dispose]()) + if (isDisposable(instance)) + instance[dispose]() + } + } + }) + updateChildren(model, null, instance) + sourceCreation.verifySourceManagement() + return model + } finally { + accesses[dispose]() + sourceCreation[dispose]() + } +} /** * Marks a class as a model. @@ -21,65 +61,83 @@ const prototypePropertyFactories = Symbol('prototype property factories') **/ export function model any>(target: Class): Class { const className = nameOfFunction(target) - return { + let thisCtor: new (...args: any[]) => any + return thisCtor = { [className]: class extends target { - [modelProperties] = {} as Record + static [modelBrand] = true constructor(...args: any[]) { let sourceCreation = trackManagedSourceCreation() let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this try { super(...args) - let properties = getModelProperties(this) + let model = this as any as Model - let prototypeFactories = (target.prototype as ModelPrototype)?.[prototypePropertyFactories] - if (prototypeFactories) - for (let key of Object.keys(prototypeFactories)) - properties[key] = prototypeFactories[key](this) + let properties = {} as Record - let instanceFactories = (this as UninitializedModel)[modelPropertyFactories] - if (instanceFactories) - for (let key of Object.keys(instanceFactories)) - properties[key] = instanceFactories[key]() + let prototypeFactories = getPrototypePropertyFactories(target.prototype) + for (let key of Object.keys(prototypeFactories)) + properties[key] = initializeModelProperty(model, key, prototypeFactories[key]) - for (let property of Object.values(properties)) - updateChildren(this, null, property) + let instanceFactories = getInstancePropertyFactories(model) + for (let [key, factory] of Object.entries(instanceFactories)) + properties[key] = initializeModelProperty(model, key, factory) + updateChildren(model, null, properties) // Sets up children with correct paths sourceCreation.verifySourceManagement() - for (let [key, property] of Object.entries(properties)) { - if (isSource(property)) { // No need to do this for state properties - Object.defineProperty(this, key, { - // Bind directly to the property for cleaner call stack (NOCOMMIT check this works) - get: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(property), 'value')!.get!.bind(property), - enumerable: true, - configurable: true - }) - } else if (process.env.NODE_ENV !== 'production' - && (isEvent(property.value) || isEventsClass(property.value)) - ) { - throw new EventsMarkedAsStateError(this, key) - } + // Layer this class' properties onto the super class' properties + model[modelProperties] = Object.assign( + Object.create(model[modelProperties] ?? {}), + properties) + + instancePropertyFactories.delete(model) // No longer needed, and avoids sub-models re-initializing properties + + // Outer-most constructor setup + let outerMostModelCtor = getPrototypeChain(new.target).find(ctor => modelBrand in ctor) + if (outerMostModelCtor == thisCtor) { + getterCache.delete(model) // Only needed during construction + + let json = derive(() => getState(model[modelProperties]), `toJSON(${className})`) + + Object.assign(this, { + toJSON() { return super.toJSON?.() ?? json.value }, + [dispose]() { + // Make sure to cover all the properties in the prototype chain + for (let layer of getPrototypeChain(model[modelProperties])) + for (let property of Object.values(layer)) + updateChildren(model, property, null) + json[dispose]() + super[dispose]?.() + } + }) } } finally { accesses[dispose]() sourceCreation[dispose]() } } - - #json = derive(() => getState(this[modelProperties]), `toJSON(${className})`) - - toJSON() { return super.toJSON?.() ?? this.#json.value } - - [dispose]() { - for (let property of Object.values(this[modelProperties])) - updateChildren(this, property, null) - super[dispose]?.() - } } }[className] } +function initializeModelProperty

(instance: Model, key: PropertyKey, factory: (instance: any) => P) { + let property = factory(instance) + if (isSource(property)) { // No need to do this for state properties + Object.defineProperty(instance, key, { + // Bind directly to the property for cleaner call stack + get: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(property), 'value')!.get!.bind(property), + enumerable: true, + configurable: true + }) + } else if (process.env.NODE_ENV !== 'production' + && (isEvent(property.value) || isEventsClass(property.value)) + ) { + throw new EventsMarkedAsStateError(instance, key) + } + return property +} + export function reduced(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): ClassAccessorDecoratorResult export function reduced(getter: () => Value, context: ClassGetterDecoratorContext): void export function reduced(prototype: object, key: string | symbol): void @@ -93,9 +151,9 @@ export function reduced(...args: let [_, context] = args return { - init(this: UninitializedModel, initial: Value) { + init(this: Target, initial: Value) { let reduction = getUnderlyingReduction(this, context.name, () => initial) - getModelPropertyFactories(this)[context.name] = constant(reduction) + getInstancePropertyFactories(this)[context.name] = constant(reduction) return initial }, get(this: Target) { throw new MissingModelDecoratorError(model, context.name) } @@ -103,10 +161,7 @@ export function reduced(...args: } else if (argsAre(args, isFunction, isDecoratorContext)) { // Getter property decorator let [getter, context] = args - - context.addInitializer(function (this: Target) { - getModelPropertyFactories(this)[context.name] = getUnderlyingReduction.bind(null, this, context.name, getter) - }) + return cachedGetter(getter, context, instance => getUnderlyingReduction(instance, context.name, getter)) } else { // Legacy decorator @@ -130,8 +185,13 @@ export function reduced(...args: function getUnderlyingReduction(instance: any, key: PropertyKey, getter: Function) { let reduction = getUnderlyingSource(getter.call(instance)) + if (!(reduction instanceof Reduction)) throw new InvalidReducedPropertyError(key) + + if (reduction.displayName == Reduction.defaultName) + reduction.displayName = String(key) + return reduction } } @@ -164,10 +224,7 @@ export function derived(...args: if (argsAre(args, isFunction, isDecoratorContext)) { // Getter property decorator let [getter, context] = args - context.addInitializer(function (this: Target) { - let derived = derive(getter.bind(this), valuesEqual ?? Object.is, String(context.name)) - getModelPropertyFactories(this)[context.name] = constant(derived) - }) + return cachedGetter(getter, context, instance => derive(getter.bind(instance), valuesEqual ?? Object.is, String(context.name))) } else { // Legacy decorator let [prototype, key] = args @@ -189,6 +246,51 @@ export function derived(...args: } } +function cachedGetter | IReduction>( + getter: () => SourceValue, + context: ClassGetterDecoratorContext>, + createSource: (instance: Model) => Source +) { + context.addInitializer(function (this: Model) { + getInstancePropertyFactories(this)[context.name] = cachedSource.bind(this) + }) + + return cachedGetter + + /** Ensures source is only created once during model construction. */ + function cachedGetter(this: Model) { + return cachedSource.call(this).value + } + + function cachedSource(this: Model) { + let cache = getModelGetterCache(this) + let source = cache.get(getter) as Source + if (!source) + cache.set(getter, source = createSource(this)) + return source + } +} + +function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISource; defaultName: string; }, errorFactory: () => Error): PropertyDescriptor { + return { set } + + function set(this: object, value: unknown) { + let source = getUnderlyingSource(value) + if (!(source instanceof sourceType)) + throw errorFactory() + + if (source.displayName == sourceType.defaultName) + source.displayName = String(key) + + Object.defineProperty(this, key, { + get: () => { throw new MissingModelDecoratorError(model, key) }, + set, + enumerable: true, + configurable: true + }) + } +} + /** Mark a property as part of the model's state. */ export function state(value: undefined, context: ClassFieldDecoratorContext): void /** Mark a property as part of the model's state. */ @@ -212,13 +314,13 @@ export function state(...args: // Field decorator let [_, context] = args context.addInitializer(function (this: Model) { - getModelPropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) + getInstancePropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) }) } else if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { // Auto-accessor property decorator let [_, context] = args context.addInitializer(function (this: Model) { - getModelPropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) + getInstancePropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) }) } else { // Class decorator, or legacy constructor parameter decorator @@ -276,42 +378,18 @@ function isDecoratorContext(context: ClassMemberDecoratorContext | unknown): con } /** For new decorators which have access to the instance. */ -function getModelPropertyFactories(instance: object) { - return (instance as UninitializedModel)[modelPropertyFactories] ??= {} +function getInstancePropertyFactories(instance: object): Record ModelProperty> { + return getOrAddWeakEntry(instancePropertyFactories, instance, () => ({})) } /** For class decorators and legacy decorators that don't have access to the instance. */ function getPrototypePropertyFactories(prototype: object): Record ModelProperty> { - let modelPrototype = prototype as ModelPrototype - return Object.hasOwn(prototype, prototypePropertyFactories) - ? modelPrototype[prototypePropertyFactories]! - : (modelPrototype[prototypePropertyFactories] = Object.create(modelPrototype[prototypePropertyFactories] ?? {})) -} - -function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISource }, errorFactory: () => Error): PropertyDescriptor { - return { set } - - function set(this: object, value: unknown) { - let source = getUnderlyingSource(value) - if (!(source instanceof sourceType)) - throw errorFactory() - - Object.defineProperty(this, key, { - get: () => { throw new MissingModelDecoratorError(model, key) }, - set, - enumerable: true, - configurable: true - }) - } + return getOrAddWeakEntry(prototypePropertyFactories, prototype, () => ({})) } -export function getModelProperties(model: Model): Record { - return model[modelProperties] -} - -// NOCOMMIT remove - just for compiling temp tests -export function asModel(value: T): T & Model { - return Object.assign(value, { [modelProperties]: {} }) as T & Model +/** For caching getters during model construction, so accessing them doesn't create extra sources. */ +function getModelGetterCache(instance: object): Map { + return getOrAddWeakEntry(getterCache, instance, () => new Map()) } export class MissingModelDecoratorError extends Error { @@ -333,7 +411,7 @@ export class InvalidReducedPropertyError extends Error { export class InvalidDerivedPropertyError extends Error { constructor( - public property: string | symbol + public property: PropertyKey ) { super(`@derived property ${String(property)} must have a getter or be set to the value of a derivation.`) } @@ -342,7 +420,7 @@ export class InvalidDerivedPropertyError extends Error { export class EventsMarkedAsStateError extends Error { constructor( public model: object, - public property: string | symbol + public property: PropertyKey ) { super(`Model property ${String(property)} returns an event or events class, and cannot be marked as @state.`) } diff --git a/packages/event-reduce/src/signals/nodes.ts b/packages/event-reduce/src/signals/nodes.ts index 9899442..bae2ec7 100644 --- a/packages/event-reduce/src/signals/nodes.ts +++ b/packages/event-reduce/src/signals/nodes.ts @@ -1,6 +1,6 @@ -import { dispose, isDisposable, isObject } from "../utils" -import { ISource, isSource } from "./signals" -import { getChildNodes, collectAccessedSources, trackManagedSourceCreation, updateChildren } from "./tracking" +import { dispose, isObject } from "../utils" +import { ISource } from "./signals" +import { updateChildren } from "./tracking" export const modelProperties = Symbol('model properties') export const eventsBrand = Symbol('events class') @@ -40,36 +40,4 @@ export class StateProperty implements Disposable { [dispose]() { updateChildren(this._instance, this.value, null) } -} - -export function createModel(create: () => T): T & Model { - let sourceCreation = trackManagedSourceCreation() - let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this - try { - let instance = create() - if (isModel(instance)) - return instance - let model = Object.create(instance, { - [modelProperties]: { - value: Object.fromEntries( - getChildNodes(instance) - .entries() - .flatMap(([node, path]) => isSource(node) ? [[path, node]] : [])) - }, - [dispose]: { - value() { - Object.values(this[modelProperties]) - .forEach(p => (p as any as Disposable)[dispose]?.()) - if (isDisposable(instance)) - instance[dispose]() - } - } - }) - updateChildren(model, null, instance) - sourceCreation.verifySourceManagement() - return model - } finally { - accesses[dispose]() - sourceCreation[dispose]() - } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index e8839c3..b3c0f92 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,9 +1,9 @@ import { dispose, emptyArray } from "../utils" import { derive, IDerivation } from "./derivation" import { startLogging } from "./logging" -import { IObserver, ISource, IValueSource, SourceBase } from "./signals" +import { IObserver, ISource, isSource, IValueSource, NotASourceValueError, SourceBase } from "./signals" import { getState, State } from "./state" -import { currentIteration, managedSourceCreated, trackSourceAccessed, collectAccessedSources, updateChildren } from "./tracking" +import { currentIteration, managedSourceCreated, trackSourceAccessed, collectAccessedSources, updateChildren, getUnderlyingSource } from "./tracking" export interface IReduction extends IValueSource { on(source: ISource, reduce: Reducer): this @@ -17,7 +17,14 @@ export function reduce(initialValue: T, eventsOrDisplayName?: Events ? [undefined as Events, eventsOrDisplayName] : [eventsOrDisplayName, maybeDisplayName] - return new Reduction(displayName ?? "(anonymous reduction)", initialValue, Object.is, events) + return new Reduction(displayName ?? Reduction.defaultName, initialValue, Object.is, events) +} + +export function extendReduce(observableValue: T, displayName?: string): IReduction { + let source = getUnderlyingSource(observableValue) + if (source) + return reduce(observableValue, displayName ?? source.displayName).on(source, (_, val) => val) + throw new NotASourceValueError(observableValue) } export type Reducer = (current: Result, next: Source) => Result @@ -27,6 +34,7 @@ export class Reduction extends SourceBase implements IReduc private _reducers = new Map, (current: T, next: any) => T>() private _sourcesLastChecked = -1 private _json: IDerivation> + static defaultName = "(anonymous reduction)" constructor( displayName: string, @@ -49,12 +57,13 @@ export class Reduction extends SourceBase implements IReduc on(source: ISource, reduce: Reducer): this on(getSource: (events: Events) => ISource, reduce: Reducer): this on(source: ISource | ((events: Events) => ISource), reduce: Reducer) { - if (!('lastUpdated' in source)) + if (!isSource(source)) source = source(this._events!) source.subscribe(this) this.sources.add(source) this._reducers.set(source, reduce) + this._sourcesLastChecked = Math.max(this._sourcesLastChecked, source.lastUpdated) // Avoid firing reduction because of new source return this } @@ -63,7 +72,6 @@ export class Reduction extends SourceBase implements IReduc if (source.lastUpdated > this.lastUpdated) { let reduce = this._reducers.get(source) if (reduce) { - let tracker = collectAccessedSources(this) // Required for detecting circular references in reducers let previousValue = this._value let nextValue!: T let logging = startLogging('🧪 (reduction)', this.displayName, emptyArray, () => ({ @@ -74,10 +82,16 @@ export class Reduction extends SourceBase implements IReduc })) try { - this.setValue(nextValue = reduce(this._value, value)) + let tracker = collectAccessedSources(this) // Required for detecting circular references in reducers + try { + nextValue = reduce(this._value, value) + } finally { + tracker[dispose]() + } + + this.setValue(nextValue) } finally { - logging?.[dispose]() - tracker[dispose]() + logging[dispose]() } } } @@ -87,9 +101,11 @@ export class Reduction extends SourceBase implements IReduc let access = trackSourceAccessed(this) // Notifying tracker before checking for updates so circular reference is detected early try { - if (this._sourcesLastChecked < currentIteration) - for (let source of this.sources) // At most one source should be newer - this.sourceUpdated(source, (source as SourceBase).value) + if (this._sourcesLastChecked < currentIteration) { + let newerSource = Array.from(this.sources).find(s => s.lastUpdated > this._sourcesLastChecked) + if (newerSource) + this.sourceUpdated(newerSource, (newerSource as SourceBase).value) + } } finally { access[dispose]() } diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 7fe3867..ded7dbd 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -54,7 +54,7 @@ export abstract class SourceBase implements ISource { protected setValue(newValue: T) { if (process.env.NODE_ENV !== 'production') { - if (isEventsClass(newValue) || newValue instanceof SourceBase && !newValue.isValidState) + if (isEventsClass(newValue) || newValue instanceof SourceBase && !newValue.isValidAsState) throw new InvalidStateError(this, newValue) if (!iterationSource) @@ -75,7 +75,7 @@ export abstract class SourceBase implements ISource { protected updateChildren(previousValue: T, nextValue: T) { updateChildren(this, previousValue, nextValue) } - protected isValidState = true + protected isValidAsState = true map, Out>(this: Source, transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`): ISource { return bind(displayName, this, transform) @@ -158,7 +158,7 @@ export class StateChangeOutsideTransitionError extends Error { constructor( public source: ISource ) { - super(`Tried to change state of ${source.displayName} outside of a transition. ' - + 'This usually means a bug in event-reduce, or in a custom source implementation.`); + super(`Tried to change state of ${source.displayName} outside of a transition. ` + + 'This usually means a bug in event-reduce, or in a custom source implementation.'); } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index 415f953..9e9834d 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -11,9 +11,9 @@ export let iterationSource: ISource | Effect | null = null let currentAccessTracker: AccessTracker | null = null let lastSourceAccessed: ISource | null = null -/** Child -> parent, path. */ +/** Child -> parent -> path. */ let parentRegistry = new WeakMap>() -/** Value -> node, path. */ +/** Value -> node -> path. */ let childSnapshots = new WeakMap>() let garbage = new Set() @@ -106,20 +106,20 @@ function isGraphNode(value: unknown): value is GraphNode { } function addParent(object: GraphNode, owner: GraphNode, path: string) { - let owners = parentRegistry.get(object) + let parents = parentRegistry.get(object) - if (!owners) - parentRegistry.set(object, owners = new Map()) + if (!parents) + parentRegistry.set(object, parents = new Map()) - owners.set(owner, path) + parents.set(owner, path) } function removeParent(node: GraphNode, parent: GraphNode) { - let owners = parentRegistry.get(node) - if (owners) { - owners.delete(parent) + let parents = parentRegistry.get(node) + if (parents) { + parents.delete(parent) - if (!owners.size && isDisposable(node)) { + if (!parents.size && isDisposable(node)) { parentRegistry.delete(node) garbage.add(node) } @@ -128,7 +128,7 @@ function removeParent(node: GraphNode, parent: GraphNode) { function disposeGarbage() { for (let node of garbage) - if (!parentRegistry.has(node)) // In case a new owner was added + if (!parentRegistry.has(node)) // In case a new parent was added node[dispose]() garbage.clear() } diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index 3ba61de..5bf31ec 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -94,6 +94,13 @@ export function getOrAdd(target: any, key: string | symbol, create: (base: T : (target[key] = create(target[key])); } +export function getOrAddWeakEntry(cache: WeakMap, key: K, create: (key: K) => V): V { + let cached = cache.get(key); + if (!cached) + cache.set(key, cached = create(key)); + return cached; +} + export function firstIntersection(a: Set, b: Set) { for (let item of a) if (b.has(item)) @@ -111,6 +118,13 @@ export function getAllPropertyDescriptors(obj: unknown) { return props; } +export function* getPrototypeChain(obj: object) { + while (obj != null) { + yield obj + obj = Object.getPrototypeOf(obj) + } +} + export function unsubscribeAll(unsubscribes: Unsubscribe[]) { for (let unsubscribe of unsubscribes) unsubscribe(); diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index af669a0..9dbc77d 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test" -import { Atom, derive, derived, event, reduce, reduced, state } from "event-reduce/src/signals" -import { getModelProperties, model } from "event-reduce/src/signals/models" -import { createModel, isModel } from "event-reduce/src/signals/nodes" +import { Atom, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce/src/signals" +import { isModel, modelProperties } from "event-reduce/src/signals/nodes" import { StringKey } from "event-reduce/src/types" import { dispose } from "event-reduce/src/utils" @@ -319,7 +318,7 @@ describe("subscription cleanup", () => { function spyOnDispose(model: T, property: StringKey) { if (isModel(model)) { - let observableValue = getModelProperties(model)[property]! + let observableValue = model[modelProperties][property]! if (dispose in observableValue) return spyOn(observableValue, dispose) } diff --git a/tests/Model.test.ts b/tests/Model.test.ts index 5f94301..62b40a6 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { asyncEvent, derive, derived, event, events, extend, model, reduce, reduced, state } from "event-reduce"; -import { EventsMarkedAsStateError } from "event-reduce/lib/models"; -import { AccessedValueWithCommonSourceError, valueChanged } from "event-reduce/lib/observableValue"; +import { asyncEvent, derive, event, events, model, reduce, reduced, state } from "event-reduce/src/signals"; +import { Derivation } from "event-reduce/src/signals/derivation"; +import { EventsMarkedAsStateError } from "event-reduce/src/signals/models"; +import { ExtendedModel } from "./classes/ExtendedModel"; +import { TestModel } from "./classes/TestModel"; describe("models", () => { let increment: ReturnType>; @@ -13,54 +15,12 @@ describe("models", () => { }); describe("basic model tests", () => { - @model - class TestModel { - @reduced - get property() { - return reduce(1) - .on(increment, c => c + 1) - .value; - } - - @reduced - get dependentProperty() { - return reduce(1) - .on(valueChanged(this.property), (_, p) => p) - .value; - } - - @derived - get derivedProperty() { - return this.property * 2; - } - - @derived - get bool() { return true; } - - @reduced - get basedOnDerivedProperty() { - return reduce(0) - .on(valueChanged(this.derivedProperty), (_, d) => d) - .value; - } - } - - @model - class ExtendedModel extends TestModel { - @reduced - override get property() { - return extend(super.property) - .on(decrement, c => c - 1) - .value; - } - } - let testModel: TestModel; let extendedModel: ExtendedModel; beforeEach(() => { - testModel = new TestModel(); - extendedModel = new ExtendedModel(); + testModel = new TestModel(increment); + extendedModel = new ExtendedModel(increment, decrement); }); test("property has initial value", () => expect(testModel.property).toBe(1)); @@ -69,7 +29,7 @@ describe("models", () => { describe("when reduction updated", () => { beforeEach(() => { - increment(undefined); + increment(); }); test("property value updated", () => expect(testModel.property).toBe(2)); @@ -85,7 +45,7 @@ describe("models", () => { describe("when extended reduction updated", () => { beforeEach(() => { - decrement(undefined); + decrement(); }); test("property value unaffected", () => expect(testModel.property).toBe(1)); @@ -103,7 +63,7 @@ describe("models", () => { let derivation = derive(() => new DerivedModel()); derivation.update(); - expect(derivation.sources.length).toBe(0); + expect((derivation as Derivation).sources.size).toBe(0); }); test("when reducer creates a new model that observes the same event that created it, doesn't throw", () => { @@ -112,7 +72,7 @@ describe("models", () => { @reduced get child() { return reduce(null as TestModel | null) - .on(increment, () => new TestModel()) + .on(increment, () => new TestModel(increment)) .value; } } @@ -123,23 +83,6 @@ describe("models", () => { expect(parentModel.child).toBeInstanceOf(TestModel); }); - test("when initial value of a model's reduced property is derived from the same event that's creating the model, throws", () => { - reduce(null as ChildModel | null) - .on(increment, () => new ChildModel()); - - @model - class ChildModel { - @reduced - get property() { - return reduce(testModel.property) - .on(decrement, c => c - 1) - .value; - } - } - - expect(() => increment(undefined)).toThrow(AccessedValueWithCommonSourceError); - }); - test("when event is marked as state, throws when constructed", () => { @model class BadModel { diff --git a/tests/SignalsOwnership.test.ts b/tests/SignalsOwnership.test.ts deleted file mode 100644 index 86ff9f4..0000000 --- a/tests/SignalsOwnership.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { asModel } from "event-reduce/lib/signals/models" -import { Reduction } from "event-reduce/lib/signals/reduction" -import { SourceBase } from "event-reduce/lib/signals/signals" -import { trackManagedSourceCreation, UntrackedSourceError, updateChildren } from "event-reduce/lib/signals/tracking" - -class TestSource extends SourceBase { - constructor(name: string) { super(name) } - emit(value: T) { this.setValue(value, true) } -} - -describe("signal reduction ownership", () => { - test("ownerless reduction throws", () => { - expect(() => new Reduction("r", 0)).toThrow(UntrackedSourceError) - }) - - test("disposes after last owner removed", () => { - let owner = new TestSource("owner") - let updates = new TestSource("updates") - - let reduction: Reduction - { - using _ = trackManagedSourceCreation() - reduction = new Reduction("r", 0).on(updates, (current, next) => current + next) - } - - updates.emit(1) - expect(reduction.value).toBe(1) - - owner.emit(reduction) - owner.emit(undefined) - - updates.emit(1) - expect(reduction.value).toBe(1) - }) - - test("array ownership diffs keep/remove/add owners", () => { - let holder = new TestSource("holder") - let updates = new TestSource("updates") - let r1, r2: Reduction - { - using _ = trackManagedSourceCreation() - r1 = new Reduction("r1", 0).on(updates, (current, next) => current + next) - r2 = new Reduction("r2", 0).on(updates, (current, next) => current + next) - } - - holder.emit([r1, r2]) - updates.emit(1) - expect(r1.value).toBe(1) - expect(r2.value).toBe(1) - - holder.emit([r2]) - updates.emit(1) - expect(r1.value).toBe(1) - expect(r2.value).toBe(2) - - holder.emit([r2, r1]) - updates.emit(1) - expect(r1.value).toBe(1) - expect(r2.value).toBe(3) - }) - - test("replacing owned reduction with model disposes reduction", () => { - let bootstrapOwner = new TestSource("bootstrap") - let holder = new TestSource("holder") - let updates = new TestSource("updates") - - let reduction: Reduction - { - using _ = trackManagedSourceCreation() - reduction = new Reduction("r", 0).on(updates, (current, next) => current + next) - } - - holder.emit(reduction) - updates.emit(1) - expect(reduction.value).toBe(1) - - let model = asModel({ reduction }) - holder.emit(model) - - updates.emit(1) - expect(reduction.value).toBe(1) - - holder.emit(undefined) - updates.emit(1) - expect(reduction.value).toBe(1) - }) - - test("disposed reductions do not reactivate when owner is re-added", () => { - let owner = new TestSource("owner") - let updates = new TestSource("updates") - - let reduction: Reduction - { - using _ = trackManagedSourceCreation() - reduction = new Reduction("r", 0).on(updates, (current, next) => current + next) - } - - updates.emit(1) - expect(reduction.value).toBe(1) - - updateChildren(owner, undefined, reduction) - updateChildren(owner, reduction, undefined) - updateChildren(owner, undefined, reduction) - - updates.emit(1) - expect(reduction.value).toBe(1) - }) -}) diff --git a/tests/classes/ExtendedModel.ts b/tests/classes/ExtendedModel.ts new file mode 100644 index 0000000..f57009a --- /dev/null +++ b/tests/classes/ExtendedModel.ts @@ -0,0 +1,16 @@ +import { extendReduce, IEvent, model, reduced } from "event-reduce/src/signals"; +import { TestModel } from "./TestModel"; + +// TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved + +@model +export class ExtendedModel extends TestModel { + constructor(increment: IEvent, public decrement: IEvent) { super(increment); } + + @reduced + override get property() { + return extendReduce(super.property) + .on(this.decrement, c => c - 1) + .value; + } +} \ No newline at end of file diff --git a/tests/classes/TestModel.ts b/tests/classes/TestModel.ts new file mode 100644 index 0000000..ec28d54 --- /dev/null +++ b/tests/classes/TestModel.ts @@ -0,0 +1,37 @@ +import { derive, derived, IEvent, model, reduce, reduced } from "event-reduce/src/signals"; + +// TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved + +@model +export class TestModel { + constructor(public increment: IEvent) { } + + @reduced + get property() { + return reduce(1) + .on(this.increment, c => c + 1) + .value; + } + + @reduced + get dependentProperty() { + return reduce(1) + .on(derive(() => this.property), (_, p) => p) + .value; + } + + @derived + get derivedProperty() { + return this.property * 2; + } + + @derived + get bool() { return true; } + + @reduced + get basedOnDerivedProperty() { + return reduce(0) + .on(derive(() => this.derivedProperty), (_, d) => d) + .value; + } +} \ No newline at end of file From e84e49602002949aa990ac8e62da07c280f015bc Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Mon, 16 Mar 2026 17:50:25 +1000 Subject: [PATCH 26/83] Getting events decorator tests working Add paths to logs and generally cleaning them up --- .../src/signals/rendering.ts | 26 +++++++++-------- .../event-reduce/src/signals/derivation.ts | 11 +++----- packages/event-reduce/src/signals/events.ts | 28 +++++++++++++------ packages/event-reduce/src/signals/models.ts | 8 +++--- .../event-reduce/src/signals/reduction.ts | 6 ++-- packages/event-reduce/src/signals/signals.ts | 14 ++++------ packages/event-reduce/src/signals/tracking.ts | 8 +++--- packages/event-reduce/src/utils.ts | 12 ++++---- tests/Model.test.ts | 6 ++-- 9 files changed, 62 insertions(+), 57 deletions(-) diff --git a/packages/event-reduce-react/src/signals/rendering.ts b/packages/event-reduce-react/src/signals/rendering.ts index 61b052f..478f381 100644 --- a/packages/event-reduce-react/src/signals/rendering.ts +++ b/packages/event-reduce-react/src/signals/rendering.ts @@ -33,12 +33,13 @@ export function useReactive(nameOrDeriveValue: string | (() => T), maybeDeriv class RenderEffect extends Effect { private _observers = new Set<() => void>() - private _trigger: WeakRef> | null = null + private _trigger: ISource | null = null + private _previousValue?: T constructor(displayName: string) { super(displayName, constant(undefined!), (source) => { log('⚛️🚩 (render invalidated)', this.displayName, [source]) - this._trigger = new WeakRef(source) + this._trigger = source for (let subscriber of this._observers) subscriber() }) @@ -50,25 +51,26 @@ class RenderEffect extends Effect { } override run(deriveValue: () => T) { - let value!: T + let previousValue = this._previousValue + let nextValue!: T let trigger = this._trigger + this._trigger = null let transition = beginTransition(this, true) let logging = startLogging('⚛️ (render)', this.displayName, emptyArray, () => ({ - // Previous: this.loggedValue(previousValue), - Current: loggedReactValue(value), - // Container: this.container, - // Sources: sourceTree(this.sources), - TriggeredBy: trigger - ? { name: trigger.deref()?.displayName ?? "(unknown)", get observable() { return trigger.deref() ?? "No longer in memory"; } } - : 'render' + Trigger: trigger?.path ?? 'render', + Previous: loggedReactValue(previousValue), + Current: loggedReactValue(nextValue), })) try { - return value = super.run(deriveValue) + nextValue = super.run(deriveValue) + if (process.env.NODE_ENV !== 'production') + this._previousValue = nextValue + return nextValue } finally { - transition[dispose]() logging[dispose]() + transition[dispose]() } } } diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 7f56788..5f37e96 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -85,18 +85,15 @@ export class Derivation extends SourceBase implements IDerivation { update(deriveValue?: () => T, reason?: string) { let previousValue = this._value let nextValue!: T - let trigger = this._derive.invalidatingSource ? new WeakRef(this._derive.invalidatingSource) : undefined + let trigger = this._derive.invalidatingSource let transition = beginTransition(this, !iterationSource) // Derivations are always safe to access, and only initiate a transition if necessary let sourceCreation = trackManagedSourceCreation() let logging = startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ + Path: this.path, + Trigger: trigger?.path ?? '(unknown)', Previous: previousValue, - Current: nextValue, - // Container: this.container, - // Sources: sourceTree(this.sources), - TriggeredBy: trigger - ? { name: trigger.deref()?.displayName ?? "(unknown)", get observable() { return trigger.deref() ?? "No longer in memory"; } } - : reason + Current: nextValue })) try { this.setValue(nextValue = this._derive.run(deriveValue)) diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index b379aa8..60da2f9 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -16,8 +16,8 @@ export type AdditionalMembersFactory, Members extend (source: Source & IEventFunction, any>) => Members export function event(name: string = "(anonymous event)"): IEvent { - return makeEventFunction(name, (emit: (value: T) => void, value: T) => { - let logging = startLogging('⚡ (event)', name, [value]) // NOCOMMIT Container + return makeEventFunction(name, function event(emit: (value: T) => void, value: T) { + let logging = startLogging('⚡ (event)', name, [value], () => ({ Path: this.path })) try { emit(value) } finally { @@ -65,8 +65,13 @@ export interface AsyncError { } export function asyncEvent(name: string = "(anonymous async event)"): IAsyncEvent { - return makeEventFunction(name,

>(emit: (value: AsyncValue) => void, promise: P, context: Context) => { - let logging = startLogging('⚡⌚ (async event)', name, [promise, context], () => ({ Promise: promise })) // NOCOMMIT Container + return makeEventFunction(name, function asyncEvent

>( + this: IEventFunction, [promise: PromiseLike, context: Context]>, + emit: (value: AsyncValue) => void, + promise: P, + context: Context + ) { + let logging = startLogging('⚡⌚ (async event)', name, [promise, context], () => ({ Path: this.path, Promise: promise })) try { emit({ promise, context }) } finally { @@ -76,7 +81,7 @@ export function asyncEvent(name: string = "(anony promise.then( result => { let val = { result, context } - let logging = startLogging('⚡✔ (async resolved)', name, [val], () => ({ Promise: promise })) // NOCOMMIT Container + let logging = startLogging('⚡✔ (async resolved)', name, [val], () => ({ Path: this.path, Promise: promise })) try { emit(val) } finally { @@ -85,7 +90,7 @@ export function asyncEvent(name: string = "(anony }, error => { let val = { error, context } - let logging = startLogging('⚡✘ (async rejected)', name, [val], () => ({ Promise: promise })) // NOCOMMIT Container + let logging = startLogging('⚡✘ (async rejected)', name, [val], () => ({ Path: this.path, Promise: promise })) try { emit(val) } finally { @@ -95,6 +100,7 @@ export function asyncEvent(name: string = "(anony return promise }, function asyncEventMembers(e: IEventFunction, [promise: PromiseLike, context: Context]>) { + let setBaseDisplayName = e.setDisplayName return { started: e.filter(v => 'promise' in v, `${e.displayName}.started`), resolved: e.filter(v => 'result' in v, `${e.displayName}.resolved`), @@ -105,6 +111,12 @@ export function asyncEvent(name: string = "(anony (promise: PromiseLike, context: Scoped) => e(promise, { ...context, ...scope } as Context), v => matchesScope(scope, v.context), asyncEventMembers) + }, + setDisplayName(name: string) { + setBaseDisplayName.call(e, name) + this.started.setDisplayName(`${name}.started`) + this.resolved.setDisplayName(`${name}.resolved`) + this.rejected.setDisplayName(`${name}.rejected`) } } }) as IAsyncEvent @@ -113,13 +125,13 @@ export function asyncEvent(name: string = "(anony /** Creates a callable event object. */ export function makeEventFunction( name: string, - fire: (emit: (value: T) => void, ...args: Args) => Return, + fire: (this: IEventFunction, emit: (value: T) => void, ...args: Args) => Return, getMembers?: AdditionalMembersFactory, Members> ): IEventFunction & Members { let eventSource = new Event(name) as Event & IEventFunction return makeSourceFunction(eventSource, event, getMembers) - function event(...args: Args) { return fire(eventSource.emit.bind(event), ...args) } + function event(...args: Args) { return fire.call(event as IEventFunction, eventSource.emit.bind(event), ...args) } } /** Wrap an event with input and output filter. Useful for scoping. */ diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index 86b2abf..e1012c1 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -1,7 +1,7 @@ import { argsAre, constant, dispose, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "../utils" import { Derivation, derive, IDerivation } from "./derivation" import { isEvent } from "./events" -import { eventsBrand, isEventsClass, isModel, Model, modelProperties, ModelProperty, StateProperty } from "./nodes" +import { eventsBrand, EventsClass, isEventsClass, isModel, Model, modelProperties, ModelProperty, StateProperty } from "./nodes" import { IReduction, Reduction } from "./reduction" import { ISource, isSource, SourceValue } from "./signals" import { getState } from "./state" @@ -280,7 +280,7 @@ function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISour throw errorFactory() if (source.displayName == sourceType.defaultName) - source.displayName = String(key) + source.setDisplayName(String(key)) Object.defineProperty(this, key, { get: () => { throw new MissingModelDecoratorError(model, key) }, @@ -345,8 +345,8 @@ export let events = (target: T): T => { for (let [key, property] of getAllPropertyDescriptors(this)) { let value = this[key] if (isEvent(value)) { - value.displayName = key - // value.container = this // NOCOMMIT container + value.setDisplayName(key) + updateChildren(this as any as EventsClass, null, value, `.${key}`) if (property.get) Object.defineProperty(this, key, { value, diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index b3c0f92..f43b8cc 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -75,10 +75,10 @@ export class Reduction extends SourceBase implements IReduc let previousValue = this._value let nextValue!: T let logging = startLogging('🧪 (reduction)', this.displayName, emptyArray, () => ({ + Path: this.path, + Trigger: source.path, Previous: previousValue, - Current: nextValue, - // Container: this.container, - // Sources: sourceTree(this.sources) + Current: nextValue })) try { diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index ded7dbd..c70358d 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -3,12 +3,6 @@ import type { IEventFunction } from "./events" import { isEventsClass } from "./nodes" import { currentIteration, getPath, iterationSource, trackSourceAccessed, updateChildren } from "./tracking" -/** - * NOCOMMIT - * - extend - * - Some way to create top-level reductions - maybe a case for a "createModel" that marks a plain object as a model and adds a dispose - */ - export function isSource(value: any): value is ISource { return value instanceof SourceBase } @@ -16,7 +10,8 @@ export function isSource(value: any): value is ISource { export type SourceValue> = Source extends ISource ? T : never export interface ISource extends Disposable { - displayName: string + readonly displayName: string + setDisplayName(name: string): void readonly path: string readonly lastUpdated: number subscribe(observer: IObserver): void @@ -33,8 +28,7 @@ export interface IValueSource extends ISource { } export interface IObserver { - displayName: string - // readonly sources: ReadonlySet> // NOCOMMIT remove? + readonly displayName: string sourceUpdated(source: ISource, value: S): void } @@ -45,6 +39,8 @@ export abstract class SourceBase implements ISource { constructor(public displayName: string) { } + setDisplayName(name: string) { this.displayName = name } + get path(): string { return getPath(this) } get value() { diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index 9e9834d..860abf4 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -55,18 +55,18 @@ export function getUnderlyingSource(value: T): ISource | null { : null } -export function updateChildren(parent: GraphNode, previousValue: unknown, nextValue: unknown) { +export function updateChildren(parent: GraphNode, previousValue: unknown, nextValue: unknown, parentPath = '') { if (Object.is(previousValue, nextValue)) return let previousChildren: ReadonlyMap = emptyMap - if (isObject(previousValue)) { + if (isObject(previousValue) || previousValue instanceof SourceBase) { previousChildren = childSnapshots.get(previousValue) ?? getChildNodes(previousValue) childSnapshots.delete(previousValue) } let newChildren: ReadonlyMap = emptyMap - if (isObject(nextValue)) { + if (isObject(nextValue) || nextValue instanceof SourceBase) { newChildren = getChildNodes(nextValue) childSnapshots.set(nextValue, newChildren) } @@ -77,7 +77,7 @@ export function updateChildren(parent: GraphNode, previousValue: unknown, nextVa for (let [added, path] of newChildren) if (!previousChildren.has(added)) - addParent(added, parent, path) + addParent(added, parent, parentPath + path) } export function getChildNodes(value: object): ReadonlyMap { diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index 5bf31ec..f2e5788 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -107,14 +107,12 @@ export function firstIntersection(a: Set, b: Set) { return item; } -export function getAllPropertyDescriptors(obj: unknown) { +export function getAllPropertyDescriptors(obj: object) { let props = new Map(); - while (obj != Object.prototype) { - Object.entries(Object.getOwnPropertyDescriptors(obj)) - .filter(([key]) => !props.has(key)) - .forEach(([key, property]) => props.set(key, property)); - obj = Object.getPrototypeOf(obj); - } + for (let proto of getPrototypeChain(obj)) + for (let [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) + if (!props.has(key)) + props.set(key, descriptor) return props; } diff --git a/tests/Model.test.ts b/tests/Model.test.ts index 62b40a6..e1ef3fe 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -136,9 +136,9 @@ describe("events decorator", () => { expect(sut.eventGetter.rejected.displayName).toBe('eventGetter.rejected'); }); - test("sets event container", () => { - expect((sut.eventField as any).container).toBe(sut); - expect((sut.eventGetter as any).container).toBe(sut); + test("sets event parents", () => { + expect(sut.eventField.path).toBe('TestEvents.eventField'); + expect(sut.eventGetter.path).toBe('TestEvents.eventGetter'); }); test("snapshots getter event", () => { From c93f51d8d0d1f2744f84da3a386ad7b9c44999b7 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Mon, 16 Mar 2026 18:01:29 +1000 Subject: [PATCH 27/83] Merging accessor tests back into Model tests and getting them working --- packages/event-reduce/src/signals/models.ts | 5 +- tests/Model.test.ts | 30 +++++----- tests/ModelAccessor.test.ts | 63 --------------------- tests/classes/ExtendedModel.ts | 15 +++-- tests/classes/TestModel.ts | 13 +++-- 5 files changed, 39 insertions(+), 87 deletions(-) delete mode 100644 tests/ModelAccessor.test.ts diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index e1012c1..e7a4bf3 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -148,15 +148,16 @@ export function reduced(...args: ) { if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { // Auto-accessor property decorator - let [_, context] = args + let [target, context] = args return { init(this: Target, initial: Value) { let reduction = getUnderlyingReduction(this, context.name, () => initial) + getModelGetterCache(this).set(target.get, reduction) getInstancePropertyFactories(this)[context.name] = constant(reduction) return initial }, - get(this: Target) { throw new MissingModelDecoratorError(model, context.name) } + get(this: Target) { return (getModelGetterCache(this).get(target.get) as Reduction)!.value }, } } else if (argsAre(args, isFunction, isDecoratorContext)) { // Getter property decorator diff --git a/tests/Model.test.ts b/tests/Model.test.ts index e1ef3fe..3626f44 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -2,31 +2,27 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; import { asyncEvent, derive, event, events, model, reduce, reduced, state } from "event-reduce/src/signals"; import { Derivation } from "event-reduce/src/signals/derivation"; import { EventsMarkedAsStateError } from "event-reduce/src/signals/models"; -import { ExtendedModel } from "./classes/ExtendedModel"; -import { TestModel } from "./classes/TestModel"; +import { decrement, ExtendedModel } from "./classes/ExtendedModel"; +import { increment, TestModel } from "./classes/TestModel"; describe("models", () => { - let increment: ReturnType>; - let decrement: ReturnType>; - - beforeEach(() => { - increment = event('increment'); - decrement = event('decrement'); - }); - describe("basic model tests", () => { let testModel: TestModel; let extendedModel: ExtendedModel; beforeEach(() => { - testModel = new TestModel(increment); - extendedModel = new ExtendedModel(increment, decrement); + testModel = new TestModel(); + extendedModel = new ExtendedModel(); }); test("property has initial value", () => expect(testModel.property).toBe(1)); + test("accessor property has initial value", () => expect(testModel.accessorProp).toBe(1)); + test("extended property has same initial value", () => expect(extendedModel.property).toBe(1)); + test("extended accessor property has same initial value", () => expect(extendedModel.accessorProp).toBe(1)); + describe("when reduction updated", () => { beforeEach(() => { increment(); @@ -34,10 +30,14 @@ describe("models", () => { test("property value updated", () => expect(testModel.property).toBe(2)); + test("accessor property value updated", () => expect(testModel.accessorProp).toBe(2)); + test("dependent property value updated", () => expect(testModel.dependentProperty).toBe(2)); test("extended property value updated", () => expect(extendedModel.property).toBe(2)); + test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(2)); + test("derived property value updated", () => expect(testModel.derivedProperty).toBe(4)); test("property based on derived value updated", () => expect(testModel.basedOnDerivedProperty).toBe(4)); @@ -50,7 +50,11 @@ describe("models", () => { test("property value unaffected", () => expect(testModel.property).toBe(1)); + test("accessor property value unaffected", () => expect(testModel.accessorProp).toBe(1)); + test("extended property value updated", () => expect(extendedModel.property).toBe(0)); + + test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(0)); }); test("when derivation creates a new model that accesses an observable value in its constructor, accessed observable value is not counted as a source for the derivation", () => { @@ -72,7 +76,7 @@ describe("models", () => { @reduced get child() { return reduce(null as TestModel | null) - .on(increment, () => new TestModel(increment)) + .on(increment, () => new TestModel()) .value; } } diff --git a/tests/ModelAccessor.test.ts b/tests/ModelAccessor.test.ts deleted file mode 100644 index 4ed4ac6..0000000 --- a/tests/ModelAccessor.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { event, extend, model, reduce, reduced } from "event-reduce"; - -// In a separate file until bun supports the accessor syntax, -// at which point these can be merged back into Model.test.ts - -describe("model accessor props", () => { - let increment: ReturnType>; - let decrement: ReturnType>; - - beforeEach(() => { - increment = event('increment'); - decrement = event('decrement'); - }); - - @model - class TestModel { - @reduced - accessor accessorProp = reduce(1) - .on(increment, c => c + 1) - .value; - } - - @model - class ExtendedModel extends TestModel { - @reduced - override accessor accessorProp: number = extend(super.accessorProp) - .on(decrement, c => c - 1) - .value; - } - - let testModel: TestModel; - let extendedModel: ExtendedModel; - - beforeEach(() => { - testModel = new TestModel(); - extendedModel = new ExtendedModel(); - }); - - test("accessor property has initial value", () => expect(testModel.accessorProp).toBe(1)); - - test("extended accessor property has same initial value", () => expect(extendedModel.accessorProp).toBe(1)); - - describe("when reduction updated", () => { - beforeEach(() => { - increment(undefined); - }); - - test("accessor property value updated", () => expect(testModel.accessorProp).toBe(2)); - - test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(2)); - }); - - describe("when extended reduction updated", () => { - beforeEach(() => { - decrement(undefined); - }); - - test("accessor property value unaffected", () => expect(testModel.accessorProp).toBe(1)); - - test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(0)); - }); -}); \ No newline at end of file diff --git a/tests/classes/ExtendedModel.ts b/tests/classes/ExtendedModel.ts index f57009a..86629c8 100644 --- a/tests/classes/ExtendedModel.ts +++ b/tests/classes/ExtendedModel.ts @@ -1,16 +1,21 @@ -import { extendReduce, IEvent, model, reduced } from "event-reduce/src/signals"; +import { event, extendReduce, model, reduced } from "event-reduce/src/signals"; import { TestModel } from "./TestModel"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved +export let decrement = event('decrement') + @model export class ExtendedModel extends TestModel { - constructor(increment: IEvent, public decrement: IEvent) { super(increment); } - @reduced override get property() { return extendReduce(super.property) - .on(this.decrement, c => c - 1) - .value; + .on(decrement, c => c - 1) + .value } + + @reduced + override accessor accessorProp = extendReduce(super.accessorProp) + .on(decrement, c => c - 1) + .value } \ No newline at end of file diff --git a/tests/classes/TestModel.ts b/tests/classes/TestModel.ts index ec28d54..e0ee8b4 100644 --- a/tests/classes/TestModel.ts +++ b/tests/classes/TestModel.ts @@ -1,18 +1,23 @@ -import { derive, derived, IEvent, model, reduce, reduced } from "event-reduce/src/signals"; +import { derive, derived, event, model, reduce, reduced } from "event-reduce/src/signals"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved +export let increment = event('increment') + @model export class TestModel { - constructor(public increment: IEvent) { } - @reduced get property() { return reduce(1) - .on(this.increment, c => c + 1) + .on(increment, c => c + 1) .value; } + @reduced + accessor accessorProp = reduce(1) + .on(increment, c => c + 1) + .value; + @reduced get dependentProperty() { return reduce(1) From ac0881a1e074d8a415e379f20ce7ba8c28aef94e Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Mon, 16 Mar 2026 18:14:06 +1000 Subject: [PATCH 28/83] Getting Reduction tests working --- .../event-reduce/src/signals/derivation.ts | 2 + packages/event-reduce/src/signals/effect.ts | 2 + .../event-reduce/src/signals/reduction.ts | 20 ++- packages/event-reduce/src/signals/signals.ts | 13 +- tests/Reduction.test.ts | 139 +++++++++++------- 5 files changed, 116 insertions(+), 60 deletions(-) diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 5f37e96..2d9ec0d 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -64,6 +64,8 @@ export class Derivation extends SourceBase implements IDerivation { this._derive.detach() } + override getSources() { return this._derive.getSources()} + sourceUpdated(source: ISource, value: S) { this._derive.sourceUpdated(source, value) } override get value() { diff --git a/packages/event-reduce/src/signals/effect.ts b/packages/event-reduce/src/signals/effect.ts index da0f57c..e2ed472 100644 --- a/packages/event-reduce/src/signals/effect.ts +++ b/packages/event-reduce/src/signals/effect.ts @@ -14,6 +14,8 @@ export class Effect implements IObserver { private _onSourceUpdatedSinceLastRun: (source: ISource) => void ) { } + getSources() { return this.sources } + attach() { this._attached = true for (let source of this.sources) diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index f43b8cc..60cd33c 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,7 +1,7 @@ import { dispose, emptyArray } from "../utils" import { derive, IDerivation } from "./derivation" import { startLogging } from "./logging" -import { IObserver, ISource, isSource, IValueSource, NotASourceValueError, SourceBase } from "./signals" +import { allSources, IObserver, ISource, isSource, IValueSource, NotASourceValueError, SourceBase } from "./signals" import { getState, State } from "./state" import { currentIteration, managedSourceCreated, trackSourceAccessed, collectAccessedSources, updateChildren, getUnderlyingSource } from "./tracking" @@ -60,6 +60,11 @@ export class Reduction extends SourceBase implements IReduc if (!isSource(source)) source = source(this._events!) + if (process.env.NODE_ENV !== 'production') { + if (allSources(source).find(s => s == this)) + throw new CircularSubscriptionError(this, source); + } + source.subscribe(this) this.sources.add(source) this._reducers.set(source, reduce) @@ -68,6 +73,8 @@ export class Reduction extends SourceBase implements IReduc return this } + override getSources() { return this.sources } + sourceUpdated(source: ISource, value: S) { if (source.lastUpdated > this.lastUpdated) { let reduce = this._reducers.get(source) @@ -88,7 +95,7 @@ export class Reduction extends SourceBase implements IReduc } finally { tracker[dispose]() } - + this.setValue(nextValue) } finally { logging[dispose]() @@ -125,4 +132,13 @@ export class Reduction extends SourceBase implements IReduc source.unsubscribe(this) super[dispose]() } +} + +export class CircularSubscriptionError extends Error { + constructor( + public reduction: IReduction, + public source: ISource + ) { + super(`Cannot subscribe to '${source.displayName}', as it depends on this reduction, '${reduction.displayName}'.`); + } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index c70358d..24822df 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -1,4 +1,4 @@ -import { dispose, identity, nameOfCallback, nameOfFunction } from "../utils" +import { dispose, emptyArray, identity, nameOfCallback, nameOfFunction } from "../utils" import type { IEventFunction } from "./events" import { isEventsClass } from "./nodes" import { currentIteration, getPath, iterationSource, trackSourceAccessed, updateChildren } from "./tracking" @@ -13,6 +13,7 @@ export interface ISource extends Disposable { readonly displayName: string setDisplayName(name: string): void readonly path: string + getSources(): Iterable> readonly lastUpdated: number subscribe(observer: IObserver): void unsubscribe(observer: IObserver): void @@ -43,6 +44,8 @@ export abstract class SourceBase implements ISource { get path(): string { return getPath(this) } + getSources() { return emptyArray as Iterable> } + get value() { trackSourceAccessed(this)[dispose]() return this._value @@ -110,6 +113,8 @@ class BoundSource extends SourceBase { _source.subscribe(this) } + override getSources() { return [this._source] } + override subscribe(observer: IObserver): void { if (!this._observers.size) this._source.subscribe(this) @@ -135,6 +140,12 @@ class BoundSource extends SourceBase { override skipUpdate(value: Out) { return this._predicate?.(value) == false } } +export function* allSources(source: ISource): Generator> { + yield source + for (let dependency of source.getSources()) + yield* allSources(dependency) +} + export class NotASourceValueError extends Error { constructor( public value: unknown diff --git a/tests/Reduction.test.ts b/tests/Reduction.test.ts index 773e35d..1efd9a5 100644 --- a/tests/Reduction.test.ts +++ b/tests/Reduction.test.ts @@ -1,36 +1,42 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { event, events, reduce } from 'event-reduce'; -import { AccessedValueWithCommonSourceError, collectAccessedValues } from 'event-reduce/lib/observableValue'; -import { CircularSubscriptionError, IBoundReduction, IReduction, ReducedEventsError } from "event-reduce/lib/reduction"; -import { Subject } from 'event-reduce/lib/subject'; +import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; +import { Atom, createModel, event, events, IReduction, reduce } from 'event-reduce/src/signals'; +import { CircularSubscriptionError } from "event-reduce/src/signals/reduction"; +import { InvalidStateError } from "event-reduce/src/signals/signals"; +import { CircularDependencyError, collectAccessedSources } from "event-reduce/src/signals/tracking"; +import { dispose } from "event-reduce/src/utils"; +import { MockObserver } from "./MockObserver"; describe("reduce", () => { describe("when unbound", () => { - let subscriber: Mock<(value: number) => void>; + let subscriber: MockObserver; let sut: IReduction; beforeEach(() => { - subscriber = mock(); - sut = reduce(1, 'sut'); + subscriber = new MockObserver(); + sut = createModel(() => reduce(1, 'sut')); sut.subscribe(subscriber); }); + afterEach(() => { + sut[dispose](); + }); + test("starts with initial value", () => expect(sut.value).toBe(1)); describe("when subscribed to an observable", () => { - let subject: Subject; + let atom: Atom; let reducer: Mock<(previous: number, event: string) => number>; beforeEach(() => { - subject = new Subject(() => 'test'); + atom = new Atom('test', 'test'); reducer = mock(); - sut.on(subject, reducer); + sut.on(atom, reducer); }); describe("when observable emits a value", () => { beforeEach(() => { reducer.mockReturnValue(3); - subject.next('foo'); + atom.setValue('foo'); }); test("reducer called with previous value and observable value", () => { @@ -42,7 +48,7 @@ describe("reduce", () => { describe("when another value is emitted", () => { beforeEach(() => { reducer.mockReturnValue(4); - subject.next('bar'); + atom.setValue('bar'); }); test("reducer called with previous value and observable value", () => { @@ -56,41 +62,51 @@ describe("reduce", () => { describe("when a reducer accesses a reduced value", () => { let other: ReturnType>; - let subject: Subject; + let source: Atom; beforeEach(() => { - other = reduce(0); - subject = new Subject(() => 'test'); - sut.on(subject, () => other.value); + other = createModel(() => reduce(0)); + source = new Atom('test', 0); + sut.on(source, () => other.value); }); - test("when other value based on same event, throws", () => { - other.on(subject, () => 0); + afterEach(() => { + other[dispose](); + }) + + test("when other value based on same source, uses latest value", () => { + other.on(source, (_, value) => value); + + source.setValue(3); + + expect(other.value).toBe(3); + expect(sut.value).toBe(3); + }); + + test("when other value also accesses current reduction, throws", () => { + other.on(source, () => sut.value); try { - subject.next(0); + source.setValue(1); expect.unreachable("Should have thrown"); } catch (e) { - expect(e).toBeInstanceOf(AccessedValueWithCommonSourceError); - expect((e as AccessedValueWithCommonSourceError).commonSource).toBe(subject); - expect((e as AccessedValueWithCommonSourceError).triggeringObservable).toBe(subject); - expect((e as AccessedValueWithCommonSourceError).accessedObservable).toBe(other as any); + expect(e).toBeInstanceOf(CircularDependencyError); } }); test("when other value not based on same event, doesn't throw", () => { - subject.next(0); + source.setValue(0); }); }); test("when subscribing to an observable based on itself, throws", () => { - let observable = sut.filter(n => n > 3); + let source = sut.filter(n => n > 3); try { - sut.on(observable, (_, n) => n); + sut.on(source, (_, n) => n); expect.unreachable("Should have thrown"); } catch (e) { expect(e).toBeInstanceOf(CircularSubscriptionError); - expect((e as CircularSubscriptionError).observable).toBe(observable); + expect((e as CircularSubscriptionError).source).toBe(source); expect((e as CircularSubscriptionError).reduction).toBe(sut); } }); @@ -98,21 +114,25 @@ describe("reduce", () => { describe("when bound to events object", () => { let eventsObj: {}; - let sut: IBoundReduction; + let sut: IReduction; beforeEach(() => { eventsObj = {}; - sut = reduce(1, eventsObj); + sut = createModel(() => reduce(1, eventsObj)); }); + afterEach(() => { + sut[dispose](); + }) + describe("when subscribing to an observable", () => { - let subject: Subject; - let getEvent: Mock<(events: typeof eventsObj) => Subject>; + let atom: Atom; + let getEvent: Mock<(events: typeof eventsObj) => Atom>; let reducer: Mock<(previous: number, event: string) => number>; beforeEach(() => { - subject = new Subject(() => 'test'); - getEvent = mock(() => subject); + atom = new Atom('test', 'test'); + getEvent = mock(() => atom); reducer = mock(); sut.on(getEvent, reducer); }); @@ -122,38 +142,41 @@ describe("reduce", () => { }); test("reduction subscribed to result of event getter", () => { - subject.next('foo'); + atom.setValue('foo'); expect(reducer).toHaveBeenCalledWith(1, 'foo'); }); }); }); test("accessed reductions updated when value is accessed", () => { - let r1 = reduce(1); - let r2 = reduce(2); + using sut = createModel(() => ({ + r1: reduce(1, 'r1'), + r2: reduce(2, 'r2') + })) - let accessed = collectAccessedValues(() => { - r1.value; - r2.value; - }); + using tracker = collectAccessedSources(null) + sut.r1.value; + sut.r2.value; - expect(Array.from(accessed)).toContain(r1 as any); - expect(Array.from(accessed)).toContain(r2 as any); + expect(tracker.accessed).toContain(sut.r1 as any); + expect(tracker.accessed).toContain(sut.r2 as any); }); test("when reducer returns an event, throws", () => { let eventValue = event('test event'); - let subject = new Subject(() => 'test'); - let sut = reduce(null as any, 'sut') - .on(subject, () => eventValue); + let subject = new Atom('test', 'test'); + using sut = createModel(() => ({ + reduction: reduce(null as any, 'sut') + .on(subject, () => eventValue) + })) try { - subject.next('foo'); + subject.setValue('foo'); expect.unreachable("Should have thrown"); } catch (e) { - expect(e).toBeInstanceOf(ReducedEventsError); - expect((e as ReducedEventsError).reduction).toBe(sut); - expect((e as ReducedEventsError).value).toBe(eventValue); + expect(e).toBeInstanceOf(InvalidStateError); + expect((e as InvalidStateError).source).toBe(sut.reduction); + expect((e as InvalidStateError).value).toBe(eventValue); } }); @@ -161,17 +184,19 @@ describe("reduce", () => { @events class Events { } let eventsValue = new Events(); - let subject = new Subject(() => 'test'); - let sut = reduce(null as any, 'sut') - .on(subject, () => eventsValue); + let subject = new Atom('test', 'test'); + using sut = createModel(() => ({ + reduction: reduce(null as any, 'sut') + .on(subject, () => eventsValue) + })) try { - subject.next('foo'); + subject.setValue('foo'); expect.unreachable("Should have thrown"); } catch (e) { - expect(e).toBeInstanceOf(ReducedEventsError); - expect((e as ReducedEventsError).reduction).toBe(sut); - expect((e as ReducedEventsError).value).toBe(eventsValue); + expect(e).toBeInstanceOf(InvalidStateError); + expect((e as InvalidStateError).source).toBe(sut.reduction); + expect((e as InvalidStateError).value).toBe(eventsValue); } }); }); \ No newline at end of file From bdfc9a7d6d4966c16adb23868f3b5864f34d6362 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Tue, 17 Mar 2026 20:47:27 +1000 Subject: [PATCH 29/83] Getting hooks and rendering tests working Implemented testing helpers Exposed detach on Derivation Exposed setValue on SourceBase RenderEffect attaches immediately useReactive uses trigger info as external state --- .../event-reduce-react/src/signals/hooks.ts | 8 +- .../event-reduce-react/src/signals/index.ts | 2 + .../src/signals/rendering.ts | 11 +- .../event-reduce/src/signals/derivation.ts | 6 +- packages/event-reduce/src/signals/effect.ts | 16 ++- packages/event-reduce/src/signals/events.ts | 8 +- .../event-reduce/src/signals/reduction.ts | 6 +- packages/event-reduce/src/signals/signals.ts | 3 +- packages/event-reduce/src/signals/testing.ts | 115 ++++++++++++++++++ tests/Hooks.test.ts | 28 +++-- tests/Rendering.test.ts | 14 +-- 11 files changed, 176 insertions(+), 41 deletions(-) create mode 100644 packages/event-reduce-react/src/signals/index.ts create mode 100644 packages/event-reduce/src/signals/testing.ts diff --git a/packages/event-reduce-react/src/signals/hooks.ts b/packages/event-reduce-react/src/signals/hooks.ts index 70412ef..7b4eb30 100644 --- a/packages/event-reduce-react/src/signals/hooks.ts +++ b/packages/event-reduce-react/src/signals/hooks.ts @@ -1,4 +1,4 @@ -import { asyncEvent, Atom, derive, event, IReduction, ISource, reduce } from "event-reduce/lib/signals" +import { asyncEvent, Atom, derive, event, IDerivation, IReduction, ISource, reduce } from "event-reduce/lib/signals" import { isModel, Model } from "event-reduce/lib/signals/nodes" import { Reduction } from "event-reduce/lib/signals/reduction" import { dispose, emptyArray } from "event-reduce/lib/utils" @@ -28,9 +28,9 @@ export function useAsyncEvent(name?: string) { * Creates a derived value that persists across renders of the component. * @param unobservableDependencies - A list of dependencies that are not observable. The derived value will be updated when any of these change. */ -export function useDerived(getValue: () => T, name?: string): ISource -export function useDerived(getValue: () => T, unobservableDependencies?: DependencyList, name?: string): ISource -export function useDerived(getValue: () => T, nameOrUnobservableDependencies?: string | DependencyList, name?: string): ISource { +export function useDerived(getValue: () => T, name?: string): IDerivation +export function useDerived(getValue: () => T, unobservableDependencies?: DependencyList, name?: string): IDerivation +export function useDerived(getValue: () => T, nameOrUnobservableDependencies?: string | DependencyList, name?: string): IDerivation { let unobservableDependencies: DependencyList | undefined [name, unobservableDependencies] = typeof nameOrUnobservableDependencies == 'string' ? [nameOrUnobservableDependencies, undefined] diff --git a/packages/event-reduce-react/src/signals/index.ts b/packages/event-reduce-react/src/signals/index.ts new file mode 100644 index 0000000..4798efb --- /dev/null +++ b/packages/event-reduce-react/src/signals/index.ts @@ -0,0 +1,2 @@ +export * from "./hooks"; +export * from "./rendering"; \ No newline at end of file diff --git a/packages/event-reduce-react/src/signals/rendering.ts b/packages/event-reduce-react/src/signals/rendering.ts index 478f381..940ef27 100644 --- a/packages/event-reduce-react/src/signals/rendering.ts +++ b/packages/event-reduce-react/src/signals/rendering.ts @@ -27,29 +27,34 @@ export function useReactive(nameOrDeriveValue: string | (() => T), maybeDeriv : ['ReactiveValue', nameOrDeriveValue] let renderEffect = useState(() => new RenderEffect(name))[0] - useSyncExternalStore(renderEffect.subscribe.bind(renderEffect), () => renderEffect.lastRun) + useSyncExternalStore(renderEffect.subscribe, renderEffect.triggerSnapshot) return renderEffect.run(deriveValue) } class RenderEffect extends Effect { private _observers = new Set<() => void>() private _trigger: ISource | null = null - private _previousValue?: T + private _triggerState = { triggeredBy: "(nothing)" } + private _previousValue?: T constructor(displayName: string) { super(displayName, constant(undefined!), (source) => { log('⚛️🚩 (render invalidated)', this.displayName, [source]) this._trigger = source + this._triggerState = { triggeredBy: source.path } for (let subscriber of this._observers) subscriber() }) + this.attach() } - subscribe(observer: () => void) { + subscribe = (observer: () => void) => { this._observers.add(observer) return () => this._observers.delete(observer) } + triggerSnapshot = () => this._triggerState + override run(deriveValue: () => T) { let previousValue = this._previousValue let nextValue!: T diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 2d9ec0d..206a1e7 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -61,10 +61,12 @@ export class Derivation extends SourceBase implements IDerivation { super.unsubscribe(observer) if (!this._observers.size) - this._derive.detach() + this.detach() } - override getSources() { return this._derive.getSources()} + detach() { this._derive.detach() } + + override getSources() { return this._derive.getSources() } sourceUpdated(source: ISource, value: S) { this._derive.sourceUpdated(source, value) } diff --git a/packages/event-reduce/src/signals/effect.ts b/packages/event-reduce/src/signals/effect.ts index e2ed472..4d3ce40 100644 --- a/packages/event-reduce/src/signals/effect.ts +++ b/packages/event-reduce/src/signals/effect.ts @@ -17,15 +17,19 @@ export class Effect implements IObserver { getSources() { return this.sources } attach() { - this._attached = true - for (let source of this.sources) - source.subscribe(this) + if (!this._attached) { + this._attached = true + for (let source of this.sources) + source.subscribe(this) + } } detach() { - this._attached = false - for (let source of this.sources) - source.unsubscribe(this) + if (this._attached) { + this._attached = false + for (let source of this.sources) + source.unsubscribe(this) + } } sourceUpdated(source: ISource, value: S): void { diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index 60da2f9..e53a43e 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -44,22 +44,22 @@ export interface IAsyncEvent extends IEventFunction>(scope: Scope): IAsyncEvent> } -export type AsyncValue = +export type AsyncValue = | AsyncStart | AsyncResult | AsyncError -export interface AsyncStart { +export interface AsyncStart { context: Context promise: PromiseLike } -export interface AsyncResult { +export interface AsyncResult { context: Context result: Result } -export interface AsyncError { +export interface AsyncError { context: Context error: any } diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index 60cd33c..be1c182 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -127,9 +127,13 @@ export class Reduction extends SourceBase implements IReduc override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue) } - override[dispose]() { + detach() { for (let source of this.sources) source.unsubscribe(this) + } + + override[dispose]() { + this.detach() super[dispose]() } } diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/signals.ts index 24822df..4521b86 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/signals.ts @@ -24,6 +24,7 @@ export interface ISource extends Disposable { filterMap(transform: (value: T) => Out | undefined, displayName?: string): ISource } +// NOCOMMIT do we actually need this, or could value be pushed down? export interface IValueSource extends ISource { readonly value: T } @@ -51,7 +52,7 @@ export abstract class SourceBase implements ISource { return this._value } - protected setValue(newValue: T) { + setValue(newValue: T) { if (process.env.NODE_ENV !== 'production') { if (isEventsClass(newValue) || newValue instanceof SourceBase && !newValue.isValidAsState) throw new InvalidStateError(this, newValue) diff --git a/packages/event-reduce/src/signals/testing.ts b/packages/event-reduce/src/signals/testing.ts new file mode 100644 index 0000000..e336275 --- /dev/null +++ b/packages/event-reduce/src/signals/testing.ts @@ -0,0 +1,115 @@ +import { isObject } from "../utils" +import { Atom } from "./atom" +import { Derivation } from "./derivation" +import { event } from "./events" +import { isModel, modelProperties } from "./nodes" +import { Reduction } from "./reduction" +import { isSource, SourceBase } from "./signals" + +export function mutable(model: T): Mutable { + if (isModel(model)) { + let properties = model[modelProperties] + + for (let key in properties) { + let property = properties[key] + if (isSource(property)) { + Object.defineProperty(model, key, { + get() { return (property as SourceBase).value }, + set(value) { + if (property instanceof Reduction || property instanceof Derivation) + property.detach() + + if (property instanceof SourceBase) + property.setValue(value) + }, + enumerable: Object.getOwnPropertyDescriptor(model, key)?.enumerable ?? true, + configurable: true + }) + } + } + + if (!('target' in model)) + Object.defineProperty(model, 'target', { value: model, configurable: true }) + } + return model as Mutable +} + +export function modelProxy(initialState: T): Mutable +export function modelProxy(): Mutable +export function modelProxy(model: T = {} as T) { + if (!isObject(model) || Array.isArray(model)) + return model + + let properties = {} as Record> + + // Pre-populate observable values with plain value fields + Object.entries(Object.getOwnPropertyDescriptors(model)) + .forEach(([key, prop]) => { + if ('value' in prop) + properties[key] = new Atom(String(key), prop.value) + }) + + let proxy: T = new Proxy(model, { + get(target: T, key: PropertyKey, receiver: T) { + if (key == 'target' && !('target' in target)) + return proxy + + if (properties.hasOwnProperty(key)) + return properties[key as string].value + + if (key in target) + return Reflect.get(target, key, receiver) + + return getOrAddObservableValue(key).value + }, + + set(target: T, key: PropertyKey, value: any) { + if (key == 'target') + return false + + getOrAddObservableValue(key).setValue(value) + return true + }, + + ownKeys(target: T) { + return Array.from(new Set(Object.keys(target).concat(Object.keys(properties)))) + }, + + has(target: T, key: PropertyKey) { + return key in target + || key in properties + } + }) + + return proxy + + function getOrAddObservableValue(key: PropertyKey): Atom { + return properties[String(key)] ??= new Atom(String(key), model[key as keyof T]) + } +} + +export type Mutable = + & { -readonly [P in keyof T]: T[P] } + & (T extends { target: any } + ? {} + : { + /** The model proxy as the original type */ + readonly target: T + }) + +export function eventProxy(): T +export function eventProxy void>(createEvent: () => TEvent): { [key: string]: TEvent } +export function eventProxy void, T = any>(createEvent: () => TEvent): T & { [P in keyof T]: TEvent } +export function eventProxy(createEvent: () => any = event) { + let event = createEvent() + return new Proxy(event, { + get(target: any, key: PropertyKey) { + if (typeof key == 'string') + return target[key] || (target[key] = eventProxy(createEvent)) + }, + + apply(target: any, thisArg: any, argArray?: any) { + event(...argArray) + } + }) +} \ No newline at end of file diff --git a/tests/Hooks.test.ts b/tests/Hooks.test.ts index ec49518..e7fedab 100644 --- a/tests/Hooks.test.ts +++ b/tests/Hooks.test.ts @@ -1,12 +1,14 @@ -import { renderHook } from "@testing-library/react-hooks"; -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { event, model, reduce, reduced, type AsyncResult } from "event-reduce"; -import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react"; -import { mutable } from "event-reduce/lib/testing"; +import { renderHook, RenderHookResult } from "@testing-library/react-hooks"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react/src/signals"; +import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce/lib/signals"; +import { IDerivation } from "event-reduce/src/signals"; +import { mutable } from "event-reduce/src/signals/testing"; +import { MockObserver } from "./MockObserver"; describe("useEvent", () => { let sut: ReturnType>>>; - let initialResult: ReturnType>; + let initialResult: IEvent beforeEach(() => { sut = renderHook(() => useEvent()); @@ -14,12 +16,12 @@ describe("useEvent", () => { }); test("returns an event", () => { - let eventSpy: Mock<(value: number) => void> = mock(); + let eventSpy = new MockObserver() initialResult.subscribe(eventSpy); initialResult(3); - expect(eventSpy).toHaveBeenCalledWith(3); + expect(eventSpy.sourceUpdated).toHaveBeenCalledWith(initialResult, 3); }); test("when rendered again, returns the same event instance", () => { @@ -39,14 +41,14 @@ describe("useAsyncEvent", () => { }); test("returns an async event", async () => { - let eventSpy: Mock<(value: AsyncResult) => void> = mock(); + let eventSpy = new MockObserver>() initialResult.resolved.subscribe(eventSpy); let promise = Promise.resolve(3); initialResult(promise); await promise; - expect(eventSpy).toHaveBeenCalledWith(expect.objectContaining({ result: 3 })); + expect(eventSpy.sourceUpdated).toHaveBeenCalledWith(initialResult.resolved, expect.objectContaining({ result: 3 })); }); test("when rendered again, returns the same event instance", () => { @@ -59,11 +61,11 @@ describe("useAsyncEvent", () => { describe("useDerived", () => { @model class SourceModel { @reduced get value() { return reduce(1).value; } } let source: ReturnType>; - let sut: ReturnType>>>; - let initialResult: ReturnType>; + let sut: RenderHookResult> + let initialResult: IDerivation beforeEach(() => { - source = mutable(new SourceModel()); + source = mutable(new SourceModel()); // NOCOMMIT signals version of testing sut = renderHook(() => useDerived(() => source.value * 2)); initialResult = sut.result.current; }); diff --git a/tests/Rendering.test.ts b/tests/Rendering.test.ts index f615b79..7906e41 100644 --- a/tests/Rendering.test.ts +++ b/tests/Rendering.test.ts @@ -1,20 +1,20 @@ import { act, renderHook } from "@testing-library/react-hooks"; import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { useReactive } from "event-reduce-react"; -import { ObservableValue } from "event-reduce/lib/observableValue"; +import { useReactive } from "event-reduce-react/src/signals"; +import { Atom } from "event-reduce/lib/signals"; describe('useReactive', () => { - let observableValue: ObservableValue; + let source: Atom; let innerBehaviour: () => void; let render: Mock<() => string>; let sut: ReturnType>; beforeEach(() => { - observableValue = new ObservableValue(() => 'test value', 'initial value'); + source = new Atom('test value', 'initial value'); innerBehaviour = () => { }; render = mock(() => { innerBehaviour(); - return observableValue.value; + return source.value; }); sut = renderHook(() => useReactive(render)); }); @@ -31,7 +31,7 @@ describe('useReactive', () => { describe("when accessed observable value changed", () => { beforeEach(() => { - act(() => observableValue.setValue('new value')); + act(() => source.setValue('new value')); }); test("render function called again", () => expect(render).toHaveBeenCalledTimes(2)); @@ -40,7 +40,7 @@ describe('useReactive', () => { }); test("when accessed observable value is changed during render, doesn't trigger an extra render", () => { - innerBehaviour = () => observableValue.setValue('new value'); + innerBehaviour = () => source.setValue('new value'); render.mockClear(); sut.rerender(); From 18a1745a61a8052c9ca90f936e1b32cd2dd15464 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Fri, 20 Mar 2026 20:44:30 +1000 Subject: [PATCH 30/83] Getting State tests working Removed setState feature Model toJSON picks up properties from base classes StateProperty toJSON returns state of value --- packages/event-reduce/src/signals/index.ts | 1 + packages/event-reduce/src/signals/models.ts | 7 +- packages/event-reduce/src/signals/nodes.ts | 3 +- packages/event-reduce/src/signals/state.ts | 2 +- tests/Model.test.ts | 4 +- .../{classes => Model.test}/ExtendedModel.ts | 0 tests/{classes => Model.test}/TestModel.ts | 0 tests/State.test.ts | 93 ++----------------- tests/State.test/BaseModel.ts | 32 +++++++ tests/State.test/ChildModel.ts | 11 +++ tests/State.test/TestModel.ts | 10 ++ 11 files changed, 72 insertions(+), 91 deletions(-) rename tests/{classes => Model.test}/ExtendedModel.ts (100%) rename tests/{classes => Model.test}/TestModel.ts (100%) create mode 100644 tests/State.test/BaseModel.ts create mode 100644 tests/State.test/ChildModel.ts create mode 100644 tests/State.test/TestModel.ts diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts index 983221d..f37c824 100644 --- a/packages/event-reduce/src/signals/index.ts +++ b/packages/event-reduce/src/signals/index.ts @@ -6,3 +6,4 @@ export { log, startLogging } from "./logging" export { createModel, derived, events, model, reduced, state } from "./models" export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" export { type IObserver, type ISource } from "./signals" +export { getState, type State } from "./state" diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index e7a4bf3..df77407 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -98,7 +98,12 @@ export function model any>(target: Class): if (outerMostModelCtor == thisCtor) { getterCache.delete(model) // Only needed during construction - let json = derive(() => getState(model[modelProperties]), `toJSON(${className})`) + let json = derive(() => { + let obj = {} as Record + for (let key in model[modelProperties]) + obj[key] = model[modelProperties][key] + return obj + }, `toJSON(${className})`) Object.assign(this, { toJSON() { return super.toJSON?.() ?? json.value }, diff --git a/packages/event-reduce/src/signals/nodes.ts b/packages/event-reduce/src/signals/nodes.ts index bae2ec7..f9b59e1 100644 --- a/packages/event-reduce/src/signals/nodes.ts +++ b/packages/event-reduce/src/signals/nodes.ts @@ -1,5 +1,6 @@ import { dispose, isObject } from "../utils" import { ISource } from "./signals" +import { getState } from "./state" import { updateChildren } from "./tracking" export const modelProperties = Symbol('model properties') @@ -33,7 +34,7 @@ export class StateProperty implements Disposable { updateChildren(_instance, null, this.value) } - toJSON() { return this.value } + toJSON() { return getState(this.value) } get value() { return this._instance[this.key] } diff --git a/packages/event-reduce/src/signals/state.ts b/packages/event-reduce/src/signals/state.ts index ee12ae4..7f5e06c 100644 --- a/packages/event-reduce/src/signals/state.ts +++ b/packages/event-reduce/src/signals/state.ts @@ -3,7 +3,7 @@ import { isObject } from "../utils" export type State = T extends { toJSON(): infer S } ? S : T export function getState(model: T): State { - return isObject(model) && 'toJSON' in model + return isObject(model) && typeof (model as any).toJSON == 'function' ? (model as any).toJSON() : model as State } \ No newline at end of file diff --git a/tests/Model.test.ts b/tests/Model.test.ts index 3626f44..c3e49d7 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; import { asyncEvent, derive, event, events, model, reduce, reduced, state } from "event-reduce/src/signals"; import { Derivation } from "event-reduce/src/signals/derivation"; import { EventsMarkedAsStateError } from "event-reduce/src/signals/models"; -import { decrement, ExtendedModel } from "./classes/ExtendedModel"; -import { increment, TestModel } from "./classes/TestModel"; +import { decrement, ExtendedModel } from "./Model.test/ExtendedModel"; +import { increment, TestModel } from "./Model.test/TestModel"; describe("models", () => { describe("basic model tests", () => { diff --git a/tests/classes/ExtendedModel.ts b/tests/Model.test/ExtendedModel.ts similarity index 100% rename from tests/classes/ExtendedModel.ts rename to tests/Model.test/ExtendedModel.ts diff --git a/tests/classes/TestModel.ts b/tests/Model.test/TestModel.ts similarity index 100% rename from tests/classes/TestModel.ts rename to tests/Model.test/TestModel.ts diff --git a/tests/State.test.ts b/tests/State.test.ts index 0b2aac3..747bc3b 100644 --- a/tests/State.test.ts +++ b/tests/State.test.ts @@ -1,101 +1,22 @@ -import { describe, test, expect, beforeEach } from "bun:test"; -import { derived, getState, model, reduce, reduced, setState, state, State } from "event-reduce"; +import { describe, expect, test } from "bun:test"; +import { getState } from "event-reduce/src/signals"; +import { TestModel } from "./State.test/TestModel"; describe("state", () => { - @model - @state('constructorProperty') - class BaseModel { - constructor(public constructorProperty: string) { } - - @reduced - get valueProperty() { return reduce(1).value; } - - @derived - get valuePlusOne() { - return this.valueProperty + 1; - } - - func() { } - - @reduced - get reducedModel() { return reduce(new ChildModel('child')).value; } - - @reduced - get modelArray() { - return reduce([new ChildModel('one'), new ChildModel('two')]) - .onRestore((_, arr) => arr.map(c => new ChildModel(c.value))) - .value; - } - - @state - mergedModel = new ChildModel('merged'); - - ignoredValue = 'ignored'; - } - - @model - class TestModel extends BaseModel { - @state - subClassProperty = 'subClass'; - } - - @model - class ChildModel { - constructor(private _initialValue: string) { } - - @reduced - get value() { return reduce(this._initialValue).value; } - } - - let testModel: TestModel; - - beforeEach(() => { - testModel = new TestModel('ctor'); - }); - test("getState copies the correct properties", () => { + let testModel = new TestModel('ctor'); let result = getState(testModel); expect(JSON.stringify(result)).toBe(JSON.stringify({ + subClassProperty: 'subClass', + constructorProperty: 'ctor', valueProperty: 1, + valuePlusOne: 2, reducedModel: { value: 'child' }, modelArray: [ { value: 'one' }, { value: 'two' } ], - constructorProperty: 'ctor', mergedModel: { value: 'merged' }, - subClassProperty: 'subClass' })); }); - - test("setState updates properties correctly", () => { - let originalMergedModel = testModel.mergedModel; - let stateValue = { - valueProperty: 2, - reducedModel: { value: 'child*' }, - modelArray: [ - { value: 'one*' }, - { value: 'two*' }, - { value: 'three*' } - ], - mergedModel: { value: 'merged*' }, - subClassProperty: 'subClass*', - ignoredValue: 'ignored*' - } as State; - setState(testModel, stateValue); - - expect(testModel.valueProperty).toBe(2); - expect(testModel.reducedModel).toBeInstanceOf(ChildModel); - expect(testModel.reducedModel.value).toBe('child*'); - expect(testModel.modelArray[0]).toBeInstanceOf(ChildModel); - expect(testModel.modelArray[1]).toBeInstanceOf(ChildModel); - expect(testModel.modelArray[2]).toBeInstanceOf(ChildModel); - expect(testModel.modelArray[0].value).toBe('one*'); - expect(testModel.modelArray[1].value).toBe('two*'); - expect(testModel.modelArray[2].value).toBe('three*'); - expect(testModel.mergedModel).toBe(originalMergedModel); - expect(testModel.mergedModel.value).toBe('merged*'); - expect(testModel.subClassProperty).toBe('subClass*'); - expect(testModel.ignoredValue).toBe('ignored'); - }); }); \ No newline at end of file diff --git a/tests/State.test/BaseModel.ts b/tests/State.test/BaseModel.ts new file mode 100644 index 0000000..60ef60b --- /dev/null +++ b/tests/State.test/BaseModel.ts @@ -0,0 +1,32 @@ +import { derived, model, reduce, reduced, state } from "event-reduce/src/signals"; +import { ChildModel } from "./ChildModel"; + +// TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved + +@model +@state('constructorProperty') +export class BaseModel { + constructor(public constructorProperty: string) { } + + @reduced + get valueProperty() { return reduce(1).value; } + + @derived + get valuePlusOne() { return this.valueProperty + 1; } + + func() { } + + @reduced + get reducedModel() { return reduce(new ChildModel('child')).value; } + + @reduced + get modelArray() { + return reduce([new ChildModel('one'), new ChildModel('two')]) + .value; + } + + @state + mergedModel = new ChildModel('merged'); + + ignoredValue = 'ignored'; +} \ No newline at end of file diff --git a/tests/State.test/ChildModel.ts b/tests/State.test/ChildModel.ts new file mode 100644 index 0000000..d176f3d --- /dev/null +++ b/tests/State.test/ChildModel.ts @@ -0,0 +1,11 @@ +import { model, reduce, reduced } from "event-reduce/src/signals"; + +// TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved + +@model +export class ChildModel { + constructor(private _initialValue: string) { } + + @reduced + get value() { return reduce(this._initialValue).value; } +} \ No newline at end of file diff --git a/tests/State.test/TestModel.ts b/tests/State.test/TestModel.ts new file mode 100644 index 0000000..c7ad734 --- /dev/null +++ b/tests/State.test/TestModel.ts @@ -0,0 +1,10 @@ +import { model, state } from "event-reduce/src/signals"; +import { BaseModel } from "./BaseModel"; + +// TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved + +@model +export class TestModel extends BaseModel { + @state + subClassProperty = 'subClass'; +} \ No newline at end of file From eccef044b0c1cc5e91c9df67577fdb878b510b55 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 21 Mar 2026 11:07:11 +1000 Subject: [PATCH 31/83] Getting Testing tests working --- packages/event-reduce/src/signals/index.ts | 2 +- packages/event-reduce/src/signals/models.ts | 5 + packages/event-reduce/src/signals/testing.ts | 35 +++++- packages/event-reduce/src/signals/tracking.ts | 10 +- tests/Testing.test.ts | 116 +++++++++--------- 5 files changed, 102 insertions(+), 66 deletions(-) diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts index f37c824..f2c03a3 100644 --- a/packages/event-reduce/src/signals/index.ts +++ b/packages/event-reduce/src/signals/index.ts @@ -3,7 +3,7 @@ export { derive, type IDerivation } from "./derivation" export { Effect } from "./effect" export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IEvent } from "./events" export { log, startLogging } from "./logging" -export { createModel, derived, events, model, reduced, state } from "./models" +export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" export { type IObserver, type ISource } from "./signals" export { getState, type State } from "./state" diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index df77407..dcc6a5b 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -53,6 +53,11 @@ export function createModel(create: () => T): T & Model { } } +export function disposeModel(model: object) { + if (isModel(model)) + model[dispose]() +} + /** * Marks a class as a model. * Initializes \@{@link reduced} and \@{@link derived} properties. diff --git a/packages/event-reduce/src/signals/testing.ts b/packages/event-reduce/src/signals/testing.ts index e336275..40f67f2 100644 --- a/packages/event-reduce/src/signals/testing.ts +++ b/packages/event-reduce/src/signals/testing.ts @@ -1,10 +1,13 @@ -import { isObject } from "../utils" +import { dispose, isObject } from "../utils" import { Atom } from "./atom" import { Derivation } from "./derivation" import { event } from "./events" import { isModel, modelProperties } from "./nodes" import { Reduction } from "./reduction" import { isSource, SourceBase } from "./signals" +import { beginTransition } from "./tracking" + +const targetAccessor = Symbol('target') export function mutable(model: T): Mutable { if (isModel(model)) { @@ -19,8 +22,14 @@ export function mutable(model: T): Mutable { if (property instanceof Reduction || property instanceof Derivation) property.detach() - if (property instanceof SourceBase) - property.setValue(value) + if (property instanceof SourceBase) { + let transition = beginTransition(property, true) + try { + property.setValue(value) + } finally { + transition[dispose]() + } + } }, enumerable: Object.getOwnPropertyDescriptor(model, key)?.enumerable ?? true, configurable: true @@ -28,6 +37,8 @@ export function mutable(model: T): Mutable { } } + (model as any)[targetAccessor] = model + if (!('target' in model)) Object.defineProperty(model, 'target', { value: model, configurable: true }) } @@ -51,8 +62,11 @@ export function modelProxy(model: T = {} as T) { let proxy: T = new Proxy(model, { get(target: T, key: PropertyKey, receiver: T) { - if (key == 'target' && !('target' in target)) + if (key == 'target' && !('target' in target) + || key == targetAccessor + ) { return proxy + } if (properties.hasOwnProperty(key)) return properties[key as string].value @@ -106,10 +120,23 @@ export function eventProxy(createEvent: () => any = event) { get(target: any, key: PropertyKey) { if (typeof key == 'string') return target[key] || (target[key] = eventProxy(createEvent)) + else if (key == targetAccessor) + return target }, apply(target: any, thisArg: any, argArray?: any) { event(...argArray) } }) +} + +export function getTarget(proxy: T): T { + let target = (proxy as any)[targetAccessor] + if (!target) + throw new NotAProxyError() + return target +} + +export class NotAProxyError extends Error { + constructor() { super('Object is not a proxy created by event-reduce testing utilities.') } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index 860abf4..7fde91d 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -1,5 +1,6 @@ -import { dispose, emptyMap, isDisposable, isObject, nameOfFunction, nullDisposable } from "../utils" +import { dispose, emptyArray, emptyMap, isDisposable, isObject, nameOfFunction, nullDisposable } from "../utils" import { Effect } from "./effect" +import { log } from "./logging" import { type EventsClass, isEventsClass, isModel, type Model, StateProperty } from "./nodes" import { type IObserver, type ISource, isSource, SourceBase } from "./signals" @@ -127,9 +128,12 @@ function removeParent(node: GraphNode, parent: GraphNode) { } function disposeGarbage() { - for (let node of garbage) - if (!parentRegistry.has(node)) // In case a new parent was added + for (let node of garbage) { + if (!parentRegistry.has(node)) { // In case a new parent was added + log('🗑️ (dispose)', getPath(node), emptyArray, undefined) node[dispose]() + } + } garbage.clear() } diff --git a/tests/Testing.test.ts b/tests/Testing.test.ts index d5f55a3..81f4ddb 100644 --- a/tests/Testing.test.ts +++ b/tests/Testing.test.ts @@ -1,70 +1,70 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { derived, event, events, model, reduce, reduced } from "event-reduce"; -import { disposeModel } from "event-reduce/lib/cleanup"; -import { eventProxy, modelProxy, mutable } from "event-reduce/lib/testing"; +import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { derived, disposeModel, event, events, model, reduce, reduced } from "event-reduce/src/signals" +import { eventProxy, getTarget, modelProxy, Mutable, mutable } from "event-reduce/src/signals/testing" +import { MockObserver } from "./MockObserver" describe("mutable", () => { @events class Events { - nextValue = event(); + nextValue = event() } @model class Model { - private privateVal = 'private'; + private privateVal = 'private' - events = new Events(); + events = new Events() @reduced get value() { return reduce(0) .on(this.events.nextValue, (_, val) => val) - .value; + .value } @derived get valuePlusOne() { return this.value + 1; } } - let baseModel: Model; - let sut: ReturnType>; - let typedModel: Model; + let baseModel: Model + let sut: Mutable + let typedModel: Model beforeEach(() => { - baseModel = new Model(); - baseModel.valuePlusOne; - sut = mutable(baseModel); - typedModel = sut.target; - }); + baseModel = new Model() + baseModel.valuePlusOne + sut = mutable(baseModel) + typedModel = sut.target + }) test("without overrides, properties behave normally", () => { - sut.events.nextValue(3); + sut.events.nextValue(3) - expect(sut.value).toBe(3); - expect(sut.valuePlusOne).toBe(4); - expect(sut.valuePlusOne).toBe(4); - }); + expect(sut.value).toBe(3) + expect(sut.valuePlusOne).toBe(4) + expect(sut.valuePlusOne).toBe(4) + }) describe("when reduced property overridden", () => { beforeEach(() => { - sut.value = 2; - }); + sut.value = 2 + }) - test("reduced value returns override", () => expect(sut.value).toBe(2)); + test("reduced value returns override", () => expect(sut.value).toBe(2)) - test("computed value is still computed", () => expect(sut.valuePlusOne).toBe(3)); - }); + test("computed value is still computed", () => expect(sut.valuePlusOne).toBe(3)) + }) test("when computed property overridden, computed value returns override", () => { - sut.valuePlusOne = 3; + sut.valuePlusOne = 3 - expect(sut.valuePlusOne).toBe(3); - }); + expect(sut.valuePlusOne).toBe(3) + }) test("can be disposed", () => { - disposeModel(sut); - }); -}); + disposeModel(sut) + }) +}) describe("modelProxy", () => { class ModelClass { @@ -74,11 +74,11 @@ describe("modelProxy", () => { ) { } } - let sut: ModelClass; + let sut: ModelClass beforeEach(() => { - sut = modelProxy(new ModelClass('foo', { prop: 'bar' })); - }); + sut = modelProxy(new ModelClass('foo', { prop: 'bar' })) + }) test("can deeply compare proxy", () => { expect(sut).toEqual({ @@ -86,44 +86,44 @@ describe("modelProxy", () => { objectValue: { prop: 'bar' } - }); - }); + }) + }) test("can be disposed", () => { - disposeModel(sut); - }); -}); + disposeModel(sut) + }) +}) describe("eventProxy", () => { @events class Events { - nextValue = event(); + nextValue = event() } - let untyped: any; - let typed: Events; + let untyped: any + let typed: Events beforeEach(() => { - untyped = eventProxy(); - typed = eventProxy(); - }); + untyped = eventProxy() + typed = eventProxy() + }) test("arbitrary events work", () => { - let handler: Mock<(value: string) => void> = mock(); - untyped.foo.subscribe(handler); + let handler = new MockObserver() + untyped.foo.subscribe(handler) - untyped.foo('bar'); + untyped.foo('bar') - expect(handler).toHaveBeenCalledWith('bar'); - }); + expect(handler.sourceUpdated).toHaveBeenCalledWith(getTarget(untyped.foo), 'bar') + }) test("custom event creation", () => { - let custom = eventProxy(() => event<{ value: string }>().scope({ value: 'foo' })); - let handler: Mock<(value: { value: string }) => void> = mock(); - custom.foo.subscribe(handler); + let custom = eventProxy(() => event<{ value: string }>().scope({ value: 'foo' })) + let handler = new MockObserver<{ value: string }>() + custom.foo.subscribe(handler) - custom.foo({}); + custom.foo({}) - expect(handler).toHaveBeenCalledWith(expect.objectContaining({ value: 'foo' })); - }); -}); \ No newline at end of file + expect(handler.sourceUpdated).toHaveBeenCalledWith(custom.foo, expect.objectContaining({ value: 'foo' })) + }) +}) \ No newline at end of file From 1fcfda34c43375171b1299ae9a731937734260f9 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 21 Mar 2026 11:26:29 +1000 Subject: [PATCH 32/83] Getting example working --- example/Counter.tsx | 5 +- example/index.tsx | 17 +- .../src/signals/rendering.ts | 10 +- packages/event-reduce/src/devtools.ts | 2 +- packages/event-reduce/src/signals/devtools.ts | 159 ++++++++++++++++++ packages/event-reduce/src/signals/index.ts | 3 +- packages/event-reduce/src/signals/state.ts | 4 +- tests/Derivation.test.ts | 5 +- tests/Example.test.tsx | 10 +- tests/package.json | 3 +- 10 files changed, 190 insertions(+), 28 deletions(-) create mode 100644 packages/event-reduce/src/signals/devtools.ts diff --git a/example/Counter.tsx b/example/Counter.tsx index 9605a76..f861141 100644 --- a/example/Counter.tsx +++ b/example/Counter.tsx @@ -1,6 +1,5 @@ -import { asyncEvent, derived, events, model, reduce, reduced } from "event-reduce"; -import { reactive } from "event-reduce-react"; -import * as React from "react"; +import { reactive } from "event-reduce-react/lib/signals"; +import { asyncEvent, derived, events, model, reduce, reduced } from "event-reduce/lib/signals"; import { CounterListEvents } from "./CounterList"; @events diff --git a/example/index.tsx b/example/index.tsx index 601600d..24cfe14 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -1,17 +1,16 @@ -import { enableDevTools, enableLogging } from "event-reduce"; -import * as React from "react"; -import { StrictMode } from "react"; -import * as ReactDOM from "react-dom/client"; -import { CounterList, CounterListEvents, CounterListModel } from "./CounterList"; +import { enableDevTools, enableLogging } from "event-reduce/lib/signals" +import { StrictMode } from "react" +import * as ReactDOM from "react-dom/client" +import { CounterList, CounterListEvents, CounterListModel } from "./CounterList" -enableLogging(); +enableLogging() -let rootModel = new CounterListModel(new CounterListEvents()); +let rootModel = new CounterListModel(new CounterListEvents()) -enableDevTools(rootModel, 'Counter List'); +enableDevTools(rootModel, { name: 'Counter List' }) ReactDOM.createRoot(document.getElementById('root')!).render( -); \ No newline at end of file +) \ No newline at end of file diff --git a/packages/event-reduce-react/src/signals/rendering.ts b/packages/event-reduce-react/src/signals/rendering.ts index 940ef27..9cddc73 100644 --- a/packages/event-reduce-react/src/signals/rendering.ts +++ b/packages/event-reduce-react/src/signals/rendering.ts @@ -3,14 +3,14 @@ import { Effect, log, startLogging } from "event-reduce/lib/signals" import { ISource } from "event-reduce/lib/signals/signals" import { beginTransition } from "event-reduce/lib/signals/tracking" import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/lib/utils" -import { Children, ReactNode, isValidElement, useState } from "react" +import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useState } from "react" import { useSyncExternalStore } from "use-sync-external-store/shim" -export function Reactive(props: { name?: string; children: () => ReactNode; }): ReactNode { - return useReactive(props.name || 'Derived', () => props.children()) +export function Reactive(props: { name?: string; children: () => ReactNode; }): ReactElement { + return useReactive(props.name || 'Derived', () => createElement(Fragment, { children: props.children() })) } -export function reactive(component: (...args: Args) => ReactNode | null) { +export function reactive(component: (...args: Args) => ReactElement | null) { let componentName = nameOfFunction(component) || 'ReactiveComponent' const reactiveComponentName = `reactive(${componentName})` @@ -35,7 +35,7 @@ class RenderEffect extends Effect { private _observers = new Set<() => void>() private _trigger: ISource | null = null private _triggerState = { triggeredBy: "(nothing)" } - private _previousValue?: T + private _previousValue?: T constructor(displayName: string) { super(displayName, constant(undefined!), (source) => { diff --git a/packages/event-reduce/src/devtools.ts b/packages/event-reduce/src/devtools.ts index 327e0b1..432630c 100644 --- a/packages/event-reduce/src/devtools.ts +++ b/packages/event-reduce/src/devtools.ts @@ -37,7 +37,7 @@ export function sendEvent(name: string, arg: any) { } /** - * redux-devtools-extension options from https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/API/Arguments.md + * redux-devtools-extension options from https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md */ export interface IDevToolsOptions { /** diff --git a/packages/event-reduce/src/signals/devtools.ts b/packages/event-reduce/src/signals/devtools.ts new file mode 100644 index 0000000..88a465c --- /dev/null +++ b/packages/event-reduce/src/signals/devtools.ts @@ -0,0 +1,159 @@ +import { getState, State } from "./state" +import { isObject } from "../utils" + +let modelDevTools = new Map() + +export function enableDevTools(model: Model, { restoreState, ...options }: IDevToolsOptions = {}) { + if (process.env.NODE_ENV !== 'production') { + if (!modelDevTools.has(model) && (window as any).__REDUX_DEVTOOLS_EXTENSION__) { + let dev = (window as any).__REDUX_DEVTOOLS_EXTENSION__.connect(options) + modelDevTools.set(model, dev) + dev.init(getState(model)) + let unsubscribe = dev.subscribe((event: any) => { + if (event.type == 'DISPATCH') + restoreState?.(model, JSON.parse(event.state)) + }) + return () => { + modelDevTools.delete(model) + unsubscribe() + } + } + } + return () => { } +} + +export function sendEvent(name: string, arg: any) { + if (process.env.NODE_ENV !== 'production') { + if (modelDevTools.size) { + let event = isObject(arg) + ? getState(arg) + : { value: arg } + if ('type' in event) + event['.type'] = event.type + event.type = name + modelDevTools.forEach((dev, model) => dev.send(event, getState(model))) + } + } +} + +/** + * redux-devtools-extension options from https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md + */ +export interface IDevToolsOptions { + /** + * The instance name to be shown on the monitor page. Default value is document.title. + * If not specified and there's no document title, it will consist of tabId and instanceId. + */ + name?: string + + /** + * Callback to restore state, used when time traveling or importing state from a file. Takes the deserialized state as an argument. + * event-reduce specific + */ + restoreState?: (model: Model, state: State) => void + + /** + * In ms. If more than one action is dispatched in the indicated interval, all new actions will be collected and sent at once. + * It is the joint between performance and speed. When set to 0, all actions will be sent instantly. + * Set it to a higher value when experiencing perf issues (also maxAge to a lower value). Default is 500 ms. + */ + latency?: number + + /** + * Maximum allowed actions to be stored in the history tree. The oldest actions are removed once maxAge is reached. + * It's critical for performance. Default is 50. + */ + maxAge?: number + + /** + * If set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code. + * You can use a function (with action object as argument) which should return new Error().stack string, getting the stack outside of reducers. + * Default to false. + */ + trace?: boolean | ((action: unknown) => string) + + /** + * Maximum stack trace frames to be stored (in case trace option was provided as true). By default it's 10. + * Note that, because extension's calls are excluded, the resulted frames could be 1 less. + * If trace option is a function, traceLimit will have no effect, as it's supposed to be handled there. + */ + traceLimit?: number + + /** + * undefined - will use regular JSON.stringify to send data (it's the fast mode). + * false - will handle also circular references. + * true - will handle also date, regex, undefined, primitives, error objects, symbols, maps, sets and functions. + */ + serialize?: boolean | { + /** + * You can indicate if to include (by setting as true). + * For function key you can also specify a custom function which handles serialization. + */ + options: { + date?: boolean + regex?: boolean + undefined?: boolean + nan?: boolean + infinity?: boolean + error?: boolean + symbol?: boolean + map?: boolean + set?: boolean + function?: boolean | ((fn: Function) => string) + } + + /** + * JSON replacer function used for both actions and states stringify. + * You can specify a data type by adding a __serializedType__ key. So you can deserialize it back while importing or persisting data. + */ + replacer?: (key: string, value: unknown) => any + + /** + * JSON reviver function used for parsing the imported actions and states. + */ + reviver?: (key: string, value: unknown) => any + + /** + * Automatically serialize/deserialize immutablejs via remotedev-serialize. Should be set to the default export of immutablejs. + */ + immutable?: any + + /** + * ImmutableJS Record classes used to make possible restore its instances back when importing, persisting. + */ + refs?: any[] + } + + /** + * Function which takes action object and id number as arguments, and should return action object back. + */ + actionSanitizer?: (action: unknown, id: number) => any + + /** + * Function which takes state object and index as arguments, and should return state object back. + */ + stateSanitizer?: (state: unknown, index: number) => any + + /** + * String or array of strings as regex - actions types to be hidden in the monitors. + * If actionsWhitelist specified, actionsBlacklist is ignored. + */ + actionsBlacklist?: string | string[] + + /** + * String or array of strings as regex - actions types to be shown in the monitors. + * If actionsWhitelist specified, actionsBlacklist is ignored. + */ + actionsWhitelist?: string | string[] + + /** + * Called for every action before sending, takes state and action object, and returns true in case it allows sending the current data to the monitor. + * Use it as a more advanced version of actionsBlacklist/actionsWhitelist parameters. + */ + predicate?: (state: unknown, action: unknown) => boolean + + /** + * Auto pauses when the extension’s window is not opened, and so has zero impact on your app when not in use. Default is false. + */ + autoPause?: boolean +} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts index f2c03a3..67fcd7b 100644 --- a/packages/event-reduce/src/signals/index.ts +++ b/packages/event-reduce/src/signals/index.ts @@ -1,8 +1,9 @@ export { Atom } from "./atom" export { derive, type IDerivation } from "./derivation" +export { enableDevTools } from "./devtools" export { Effect } from "./effect" export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IEvent } from "./events" -export { log, startLogging } from "./logging" +export { enableLogging, log, startLogging } from "./logging" export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" export { type IObserver, type ISource } from "./signals" diff --git a/packages/event-reduce/src/signals/state.ts b/packages/event-reduce/src/signals/state.ts index 7f5e06c..5c42f41 100644 --- a/packages/event-reduce/src/signals/state.ts +++ b/packages/event-reduce/src/signals/state.ts @@ -1,6 +1,8 @@ import { isObject } from "../utils" -export type State = T extends { toJSON(): infer S } ? S : T +export type State = T extends { toJSON(): infer S } + ? State + : T export function getState(model: T): State { return isObject(model) && typeof (model as any).toJSON == 'function' diff --git a/tests/Derivation.test.ts b/tests/Derivation.test.ts index e95b466..629b017 100644 --- a/tests/Derivation.test.ts +++ b/tests/Derivation.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; +import { Atom, derive, event, events, IDerivation } from "event-reduce/src/signals"; +import { InvalidStateError } from "event-reduce/src/signals/signals"; import { getUnderlyingSource, SideEffectDuringReactionError } from "event-reduce/src/signals/tracking"; -import { Atom, derive, event, IDerivation } from "event-reduce/src/signals"; import { MockObserver } from "./MockObserver"; -import { InvalidStateError } from "event-reduce/src/signals/signals"; -import { events } from "event-reduce/src/signals/models"; describe("derive", () => { let sourceA: Atom; diff --git a/tests/Example.test.tsx b/tests/Example.test.tsx index 50fa286..161c783 100644 --- a/tests/Example.test.tsx +++ b/tests/Example.test.tsx @@ -1,8 +1,10 @@ import { fireEvent, render, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, test } from "bun:test"; -import { Counter, CounterEvents, CounterModel } from "event-reduce-example/Counter"; -import { CounterList, CounterListEvents, CounterListModel } from "event-reduce-example/CounterList"; -import { eventProxy, mutable } from "event-reduce/lib/testing"; +import { Counter } from "event-reduce-example/Counter"; +import { CounterList } from "event-reduce-example/CounterList"; +import { CounterListEvents, CounterListModel } from "event-reduce-example/CounterListModel"; +import { CounterEvents, CounterModel } from "event-reduce-example/CounterModel"; +import { eventProxy, Mutable, mutable } from "event-reduce/lib/signals/testing"; describe("CounterListModel", () => { let events: CounterListEvents; @@ -73,7 +75,7 @@ describe("CounterModel", () => { let initialCount: number; let parentEvents: any; let events: CounterEvents; - let sut: ReturnType>; + let sut: Mutable; beforeEach(() => { id = 1; diff --git a/tests/package.json b/tests/package.json index 8ddf07f..150e1d8 100644 --- a/tests/package.json +++ b/tests/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "event-reduce": "workspace:*", - "event-reduce-react": "workspace:*" + "event-reduce-react": "workspace:*", + "event-reduce-example": "workspace:*" }, "devDependencies": { "@happy-dom/global-registrator": "^20.6.2", From 5802b7da5ecf89ada03d30bf2b6c417f936edade Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 22 Mar 2026 10:23:01 +1000 Subject: [PATCH 33/83] Updating legacy tests --- tests-legacy/LegacyModel.test.ts | 208 +++++++++++++++---------------- tests-legacy/LegacyState.test.ts | 40 +----- 2 files changed, 107 insertions(+), 141 deletions(-) diff --git a/tests-legacy/LegacyModel.test.ts b/tests-legacy/LegacyModel.test.ts index 5bef2e3..3bb0616 100644 --- a/tests-legacy/LegacyModel.test.ts +++ b/tests-legacy/LegacyModel.test.ts @@ -1,16 +1,15 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { asyncEvent, derive, derived, event, events, extend, model, reduce, reduced, state } from "event-reduce"; -import { EventsMarkedAsStateError } from "event-reduce/lib/models"; -import { AccessedValueWithCommonSourceError, valueChanged } from "event-reduce/lib/observableValue"; +import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { asyncEvent, derive, derived, event, events, extendReduce, model, reduce, reduced, state } from "event-reduce/src/signals" +import { EventsMarkedAsStateError } from "event-reduce/src/signals/models" describe("legacy models", () => { - let increment: ReturnType>; - let decrement: ReturnType>; + let increment: ReturnType> + let decrement: ReturnType> beforeEach(() => { - increment = event('increment'); - decrement = event('decrement'); - }); + increment = event('increment') + decrement = event('decrement') + }) describe("basic legacy model tests", () => { @model @@ -18,221 +17,222 @@ describe("legacy models", () => { @reduced legacyField = reduce(1) .on(increment, c => c + 1) - .value; + .value @reduced get property() { return reduce(1) .on(increment, c => c + 1) - .value; + .value } @reduced get dependentProperty() { return reduce(1) - .on(valueChanged(this.property), (_, p) => p) - .value; + .on(derive(() => this.property), (_, p) => p) + .value } @derived get derivedProperty() { - return this.property * 2; + return this.property * 2 } @derived - derivedField = derive(() => this.legacyField * 2).value; + derivedField = derive(() => this.legacyField * 2).value @reduced get basedOnDerivedProperty() { return reduce(0) - .on(valueChanged(this.derivedProperty), (_, d) => d) - .value; + .on(derive(() => this.derivedProperty), (_, d) => d) + .value } } @model class ExtendedModel extends TestModel { - override legacyField: number = extend(this.property) + override legacyField: number = extendReduce(this.property) .on(decrement, c => c - 1) - .value; + .value @reduced override get property() { - return extend(super.property) + return extendReduce(super.property) .on(decrement, c => c - 1) - .value; + .value } } - let testModel: TestModel; - let extendedModel: ExtendedModel; + let testModel: TestModel + let extendedModel: ExtendedModel beforeEach(() => { - testModel = new TestModel(); - extendedModel = new ExtendedModel(); - }); + testModel = new TestModel() + extendedModel = new ExtendedModel() + }) - test("property has initial value", () => expect(testModel.property).toBe(1)); + test("property has initial value", () => expect(testModel.property).toBe(1)) - test("extended property has same initial value", () => expect(extendedModel.property).toBe(1)); + test("extended property has same initial value", () => expect(extendedModel.property).toBe(1)) - test("legacy field has initial value", () => expect(testModel.legacyField).toBe(1)); + test("legacy field has initial value", () => expect(testModel.legacyField).toBe(1)) - test("extended legacy field has same initial value", () => expect(extendedModel.legacyField).toBe(1)); + test("extended legacy field has same initial value", () => expect(extendedModel.legacyField).toBe(1)) describe("when reduction updated", () => { beforeEach(() => { - increment(undefined); - }); + increment(undefined) + }) - test("property value updated", () => expect(testModel.property).toBe(2)); + test("property value updated", () => expect(testModel.property).toBe(2)) - test("dependent property value updated", () => expect(testModel.dependentProperty).toBe(2)); + test("dependent property value updated", () => expect(testModel.dependentProperty).toBe(2)) - test("extended property value updated", () => expect(extendedModel.property).toBe(2)); + test("extended property value updated", () => expect(extendedModel.property).toBe(2)) - test("derived property value updated", () => expect(testModel.derivedProperty).toBe(4)); + test("derived property value updated", () => expect(testModel.derivedProperty).toBe(4)) - test("derived field value updated", () => expect(testModel.derivedField).toBe(4)); + test("derived field value updated", () => expect(testModel.derivedField).toBe(4)) - test("property based on derived value updated", () => expect(testModel.basedOnDerivedProperty).toBe(4)); + test("property based on derived value updated", () => expect(testModel.basedOnDerivedProperty).toBe(4)) - test("legacy field value updated", () => expect(testModel.legacyField).toBe(2)); + test("legacy field value updated", () => expect(testModel.legacyField).toBe(2)) - test("extended legacy field value updated", () => expect(extendedModel.legacyField).toBe(2)); - }); + test("extended legacy field value updated", () => expect(extendedModel.legacyField).toBe(2)) + }) describe("when extended reduction updated", () => { beforeEach(() => { - decrement(undefined); - }); + decrement(undefined) + }) - test("property value unaffected", () => expect(testModel.property).toBe(1)); + test("property value unaffected", () => expect(testModel.property).toBe(1)) - test("extended property value updated", () => expect(extendedModel.property).toBe(0)); + test("extended property value updated", () => expect(extendedModel.property).toBe(0)) - test("legacy field value unaffected", () => expect(testModel.legacyField).toBe(1)); + test("legacy field value unaffected", () => expect(testModel.legacyField).toBe(1)) - test("extended legacy field value updated", () => expect(extendedModel.legacyField).toBe(0)); - }); + test("extended legacy field value updated", () => expect(extendedModel.legacyField).toBe(0)) + }) test("when derivation creates a new model that accesses an observable value in its constructor, accessed observable value is not counted as a source for the derivation", () => { - //@model + @model class DerivedModel { - property = testModel.property; + property = testModel.property } - let derivation = derive(() => new DerivedModel()); + let derivation = derive(() => new DerivedModel()) + derivation.value - expect(derivation.sources.length).toBe(0); - }); + expect(Array.from(derivation.getSources()).length).toBe(0) + }) test("when reducer creates a new model that observes the same event that created it, doesn't throw", () => { class Parent { @reduced child = reduce(null as TestModel | null) .on(increment, () => new TestModel()) - .value; + .value } - let parentModel = new Parent(); + let parentModel = new Parent() - increment(undefined); - expect(parentModel.child).toBeInstanceOf(TestModel); - }); + increment(undefined) + expect(parentModel.child).toBeInstanceOf(TestModel) + }) test("when initial value of a model's reduced property is derived from the same event that's creating the model, throws", () => { reduce(null as ChildModel | null) - .on(increment, () => new ChildModel()); + .on(increment, () => new ChildModel()) class ChildModel { @reduced property = reduce(testModel.property) .on(decrement, c => c - 1) - .value; + .value } - expect(() => increment(undefined)).toThrow(AccessedValueWithCommonSourceError); - }); + expect(() => increment(undefined)).toThrow() // NOCOMMIT probably wrong now + }) test("when event is marked as state, throws when constructed", () => { @model class BadModel { @state - event = event('bad state'); + event = event('bad state') } try { - new BadModel(); - expect.unreachable("Should have thrown"); + new BadModel() + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(EventsMarkedAsStateError); - expect((e as EventsMarkedAsStateError).model).toBeDefined(); - expect((e as EventsMarkedAsStateError).property).toBe('event'); + expect(e).toBeInstanceOf(EventsMarkedAsStateError) + expect((e as EventsMarkedAsStateError).model).toBeDefined() + expect((e as EventsMarkedAsStateError).property).toBe('event') } - }); - }); -}); + }) + }) +}) describe("events decorator", () => { - let getterSpy: Mock<() => void>; + let getterSpy: Mock<() => void> @events class TestEvents { - eventField = asyncEvent(); + eventField = asyncEvent() get eventGetter() { - getterSpy(); - return asyncEvent(); + getterSpy() + return asyncEvent() } } - let sut: TestEvents; + let sut: TestEvents beforeEach(() => { - getterSpy = mock(); - sut = new TestEvents(); - }); + getterSpy = mock() + sut = new TestEvents() + }) - test("keeps class name", () => expect(TestEvents.name).toBe('TestEvents')); + test("keeps class name", () => expect(TestEvents.name).toBe('TestEvents')) test("sets event name", () => { - expect(sut.eventField.displayName).toBe('eventField'); - expect(sut.eventField.started.displayName).toBe('eventField.started'); - expect(sut.eventField.resolved.displayName).toBe('eventField.resolved'); - expect(sut.eventField.rejected.displayName).toBe('eventField.rejected'); + expect(sut.eventField.displayName).toBe('eventField') + expect(sut.eventField.started.displayName).toBe('eventField.started') + expect(sut.eventField.resolved.displayName).toBe('eventField.resolved') + expect(sut.eventField.rejected.displayName).toBe('eventField.rejected') - expect(sut.eventGetter.displayName).toBe('eventGetter'); - expect(sut.eventGetter.started.displayName).toBe('eventGetter.started'); - expect(sut.eventGetter.resolved.displayName).toBe('eventGetter.resolved'); - expect(sut.eventGetter.rejected.displayName).toBe('eventGetter.rejected'); - }); + expect(sut.eventGetter.displayName).toBe('eventGetter') + expect(sut.eventGetter.started.displayName).toBe('eventGetter.started') + expect(sut.eventGetter.resolved.displayName).toBe('eventGetter.resolved') + expect(sut.eventGetter.rejected.displayName).toBe('eventGetter.rejected') + }) test("sets event container", () => { - expect((sut.eventField as any).container).toBe(sut); - expect((sut.eventGetter as any).container).toBe(sut); - }); + expect((sut.eventField as any).container).toBe(sut) + expect((sut.eventGetter as any).container).toBe(sut) + }) test("snapshots getter event", () => { - sut.eventGetter; - sut.eventGetter; + sut.eventGetter + sut.eventGetter - expect(getterSpy).toHaveBeenCalledTimes(1); - }); + expect(getterSpy).toHaveBeenCalledTimes(1) + }) test("when event class is marked as state, throws when constructed", () => { @model class BadModel { @state - events = new TestEvents(); + events = new TestEvents() } try { - new BadModel(); - expect.unreachable("Should have thrown"); + new BadModel() + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(EventsMarkedAsStateError); - expect((e as EventsMarkedAsStateError).model).toBeDefined(); - expect((e as EventsMarkedAsStateError).property).toBe('events'); + expect(e).toBeInstanceOf(EventsMarkedAsStateError) + expect((e as EventsMarkedAsStateError).model).toBeDefined() + expect((e as EventsMarkedAsStateError).property).toBe('events') } - }); -}); \ No newline at end of file + }) +}) \ No newline at end of file diff --git a/tests-legacy/LegacyState.test.ts b/tests-legacy/LegacyState.test.ts index ea5685d..21b7d9b 100644 --- a/tests-legacy/LegacyState.test.ts +++ b/tests-legacy/LegacyState.test.ts @@ -1,8 +1,7 @@ -import { describe, test, expect, beforeEach } from "bun:test"; -import { derived, getState, reduce, reduced, setState, state, State } from "event-reduce"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { derived, getState, reduce, reduced, state } from "event-reduce/lib/signals"; describe("legacy state", () => { - class BaseModel { constructor(@state('constructorProperty') public constructorProperty: string) { } @@ -20,9 +19,7 @@ describe("legacy state", () => { reducedModel = reduce(new ChildModel('child')).value; @reduced - modelArray = reduce([new ChildModel('one'), new ChildModel('two')]) - .onRestore((_, arr) => arr.map(c => new ChildModel(c.value))) - .value; + modelArray = reduce([new ChildModel('one'), new ChildModel('two')]).value; @state mergedModel = new ChildModel('merged'); @@ -62,35 +59,4 @@ describe("legacy state", () => { subClassProperty: 'subClass' })); }); - - test("setState updates properties correctly", () => { - let originalMergedModel = model.mergedModel; - let stateValue = { - valueProperty: 2, - reducedModel: { value: 'child*' }, - modelArray: [ - { value: 'one*' }, - { value: 'two*' }, - { value: 'three*' } - ], - mergedModel: { value: 'merged*' }, - subClassProperty: 'subClass*', - ignoredValue: 'ignored*' - } as State; - setState(model, stateValue); - - expect(model.valueProperty).toBe(2); - expect(model.reducedModel).toBeInstanceOf(ChildModel); - expect(model.reducedModel.value).toBe('child*'); - expect(model.modelArray[0]).toBeInstanceOf(ChildModel); - expect(model.modelArray[1]).toBeInstanceOf(ChildModel); - expect(model.modelArray[2]).toBeInstanceOf(ChildModel); - expect(model.modelArray[0].value).toBe('one*'); - expect(model.modelArray[1].value).toBe('two*'); - expect(model.modelArray[2].value).toBe('three*'); - expect(model.mergedModel).toBe(originalMergedModel); - expect(model.mergedModel.value).toBe('merged*'); - expect(model.subClassProperty).toBe('subClass*'); - expect(model.ignoredValue).toBe('ignored'); - }); }); \ No newline at end of file From dd21cdce22fd25df2d1919fc4bddadc3a9b311b3 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Mon, 23 Mar 2026 07:49:16 +1000 Subject: [PATCH 34/83] Cleanup --- example/CounterList.tsx | 6 +-- .../event-reduce-react/src/signals/hooks.ts | 7 ++- .../src/signals/rendering.ts | 2 +- packages/event-reduce/src/signals/atom.ts | 6 ++- .../event-reduce/src/signals/derivation.ts | 8 ++-- packages/event-reduce/src/signals/effect.ts | 2 +- packages/event-reduce/src/signals/events.ts | 16 +++---- packages/event-reduce/src/signals/index.ts | 2 +- packages/event-reduce/src/signals/models.ts | 48 +++++++++++++++++-- packages/event-reduce/src/signals/nodes.ts | 44 ----------------- packages/event-reduce/src/signals/reaction.ts | 28 ----------- .../event-reduce/src/signals/reduction.ts | 14 +++--- .../src/signals/{signals.ts => sources.ts} | 32 ++++++------- packages/event-reduce/src/signals/testing.ts | 10 ++-- packages/event-reduce/src/signals/tracking.ts | 10 ++-- readme.md | 1 - tests/Cleanup.test.ts | 4 +- tests/Derivation.test.ts | 2 +- tests/Example.test.tsx | 6 +-- tests/Hooks.test.ts | 4 +- tests/Reduction.test.ts | 2 +- 21 files changed, 112 insertions(+), 142 deletions(-) delete mode 100644 packages/event-reduce/src/signals/nodes.ts delete mode 100644 packages/event-reduce/src/signals/reaction.ts rename packages/event-reduce/src/signals/{signals.ts => sources.ts} (82%) diff --git a/example/CounterList.tsx b/example/CounterList.tsx index 33097d1..968d87e 100644 --- a/example/CounterList.tsx +++ b/example/CounterList.tsx @@ -1,6 +1,5 @@ -import { event, events, model, reduce, reduced } from "event-reduce"; -import { reactive } from "event-reduce-react"; -import * as React from "react"; +import { reactive } from "event-reduce-react/lib/signals"; +import { event, events, model, reduce, reduced } from "event-reduce/lib/signals"; import { Counter, CounterEvents, CounterModel } from "./Counter"; @events @@ -23,7 +22,6 @@ export class CounterListModel { return reduce([] as CounterModel[], this.events) .on(e => e.counterAdded, (cs) => cs.concat(this.createCounter({ id: uid++ }))) .on(e => e.counterRemoved, (cs, { id }) => cs.filter(c => c.id != id)) - .onRestore((_, counterStates) => counterStates.map(c => this.createCounter(c))) .value; } diff --git a/packages/event-reduce-react/src/signals/hooks.ts b/packages/event-reduce-react/src/signals/hooks.ts index 7b4eb30..5abab1c 100644 --- a/packages/event-reduce-react/src/signals/hooks.ts +++ b/packages/event-reduce-react/src/signals/hooks.ts @@ -1,6 +1,7 @@ -import { asyncEvent, Atom, derive, event, IDerivation, IReduction, ISource, reduce } from "event-reduce/lib/signals" -import { isModel, Model } from "event-reduce/lib/signals/nodes" +import { asyncEvent, Atom, derive, event, IDerivation, IReduction, reduce } from "event-reduce/lib/signals" +import { isModel, Model } from "event-reduce/lib/signals/models" import { Reduction } from "event-reduce/lib/signals/reduction" +import { trackManagedSourceCreation, updateChildren } from "event-reduce/lib/signals/tracking" import { dispose, emptyArray } from "event-reduce/lib/utils" import { DependencyList, useEffect, useMemo, useRef, useState } from "react" @@ -44,7 +45,9 @@ export function useDerived(getValue: () => T, nameOrUnobservableDependencies? } export function useReduced(initial: T, name?: string): IReduction { + let sourceCreation = trackManagedSourceCreation() // It's fine for this reduction to be loose, because it will be disposed on unmount let reduction = useState(() => reduce(initial, name))[0] + sourceCreation[dispose]() useEffect(() => () => (reduction as Reduction)[dispose]?.(), []) return reduction } diff --git a/packages/event-reduce-react/src/signals/rendering.ts b/packages/event-reduce-react/src/signals/rendering.ts index 9cddc73..968f4f9 100644 --- a/packages/event-reduce-react/src/signals/rendering.ts +++ b/packages/event-reduce-react/src/signals/rendering.ts @@ -1,6 +1,6 @@ import { LogValue } from "event-reduce/lib/logging" import { Effect, log, startLogging } from "event-reduce/lib/signals" -import { ISource } from "event-reduce/lib/signals/signals" +import { ISource } from "event-reduce/lib/signals/sources" import { beginTransition } from "event-reduce/lib/signals/tracking" import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/lib/utils" import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useState } from "react" diff --git a/packages/event-reduce/src/signals/atom.ts b/packages/event-reduce/src/signals/atom.ts index 4a59cf6..763a5a8 100644 --- a/packages/event-reduce/src/signals/atom.ts +++ b/packages/event-reduce/src/signals/atom.ts @@ -1,10 +1,10 @@ import { dispose } from "../utils"; import { derive, IDerivation } from "./derivation"; -import { IValueSource, SourceBase } from "./signals"; +import { ISource, Source } from "./sources"; import { getState, State } from "./state"; import { beginTransition } from "./tracking"; -export class Atom extends SourceBase implements IValueSource { +export class Atom extends Source implements ISource { private _json: IDerivation> constructor( @@ -19,6 +19,8 @@ export class Atom extends SourceBase implements IValueSource { toJSON() { return this._json.value; } + override get value() { return super.value } + override setValue(value: T) { let transition = beginTransition(this, true) try { diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts index 206a1e7..49088f3 100644 --- a/packages/event-reduce/src/signals/derivation.ts +++ b/packages/event-reduce/src/signals/derivation.ts @@ -1,11 +1,13 @@ import { dispose, emptyArray, nameOfFunction } from "../utils" import { Effect } from "./effect" import { startLogging } from "./logging" -import { IObserver, ISource, IValueSource, SourceBase } from "./signals" +import { IObserver, ISource, Source } from "./sources" import { getState, State } from "./state" import { beginTransition, iterationSource, trackManagedSourceCreation, trackSourceAccessed } from "./tracking" -export interface IDerivation extends IValueSource, IObserver { +export interface IDerivation extends ISource, IObserver { + readonly value: T + /** * Forces the value to be re-calculated. * @param deriveValue Optional function to use to derive the value. If not provided, the original derivation function will be used. @@ -23,7 +25,7 @@ export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, return new Derivation((displayName ?? nameOfFunction(derive)) || Derivation.defaultName, derive, skipUpdate) } -export class Derivation extends SourceBase implements IDerivation { +export class Derivation extends Source implements IDerivation { private _derive: Effect private _json?: IDerivation> static defaultName = "(anonymous derivation)" diff --git a/packages/event-reduce/src/signals/effect.ts b/packages/event-reduce/src/signals/effect.ts index 4d3ce40..d973644 100644 --- a/packages/event-reduce/src/signals/effect.ts +++ b/packages/event-reduce/src/signals/effect.ts @@ -1,5 +1,5 @@ import { dispose } from "../utils" -import type { IObserver, ISource } from "./signals" +import type { IObserver, ISource } from "./sources" import { currentIteration, collectAccessedSources } from "./tracking" export class Effect implements IObserver { diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index e53a43e..5f845a2 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -1,19 +1,16 @@ import { Scoped } from "../types" import { dispose, matchesScope } from "../utils" import { startLogging } from "./logging" -import { type ISource, SourceBase, SourceValue } from "./signals" +import { type ISource, Source, SourceValue } from "./sources" import { beginTransition } from "./tracking" -export interface IEventFunction extends ISource { - (...args: Args): Return -} - export interface IEvent extends IEventFunction { scope>(scope: Scope): IEvent, Out> } -export type AdditionalMembersFactory, Members extends object> = - (source: Source & IEventFunction, any>) => Members +export interface IEventFunction extends ISource { + (...args: Args): Return +} export function event(name: string = "(anonymous event)"): IEvent { return makeEventFunction(name, function event(emit: (value: T) => void, value: T) { @@ -159,7 +156,10 @@ function makeSourceFunction, Args extends any[], Ret : eventFn as Source & IEventFunction, Args, Return> & Members } -class Event extends SourceBase { +export type AdditionalMembersFactory, Members extends object> = + (source: Source & IEventFunction, any>) => Members + +class Event extends Source { emit(value: T) { let transition = beginTransition(this, true) try { diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts index 67fcd7b..76a8016 100644 --- a/packages/event-reduce/src/signals/index.ts +++ b/packages/event-reduce/src/signals/index.ts @@ -6,5 +6,5 @@ export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, export { enableLogging, log, startLogging } from "./logging" export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" -export { type IObserver, type ISource } from "./signals" +export { type IObserver, type ISource } from "./sources" export { getState, type State } from "./state" diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts index dcc6a5b..77b11ac 100644 --- a/packages/event-reduce/src/signals/models.ts +++ b/packages/event-reduce/src/signals/models.ts @@ -1,12 +1,25 @@ import { argsAre, constant, dispose, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "../utils" import { Derivation, derive, IDerivation } from "./derivation" import { isEvent } from "./events" -import { eventsBrand, EventsClass, isEventsClass, isModel, Model, modelProperties, ModelProperty, StateProperty } from "./nodes" import { IReduction, Reduction } from "./reduction" -import { ISource, isSource, SourceValue } from "./signals" +import { ISource, isSource, SourceValue } from "./sources" import { getState } from "./state" import { collectAccessedSources, getChildNodes, getUnderlyingSource, trackManagedSourceCreation, updateChildren } from "./tracking" +const modelBrand = Symbol('model class') +const modelProperties = Symbol('model properties') + +export interface Model extends Disposable { + [modelProperties]: Record +} + +export type ModelProperty = ISource | StateProperty + +export function isModel(value: unknown): value is Model { + return isObject(value) + && modelProperties in value +} + /** instance -> key -> factory */ let instancePropertyFactories = new WeakMap ModelProperty>>() /** prototype -> key -> factory */ @@ -17,7 +30,6 @@ let prototypePropertyFactories = new WeakMap>>() -const modelBrand = Symbol('model class') /** Creates a loose model object. Not required if your model is already decorated with \@{@link model}. */ export function createModel(create: () => T): T & Model { @@ -342,6 +354,21 @@ export function state(...args: } } +export class StateProperty implements Disposable { + constructor( + private _instance: any, + public readonly key: PropertyKey + ) { + updateChildren(_instance, null, this.value) + } + + toJSON() { return getState(this.value) } + + get value() { return this._instance[this.key] } + + [dispose]() { updateChildren(this._instance, this.value, null) } +} + /** * Marks a class as an events class. * Automatically populates the names of event properties. @@ -372,6 +399,21 @@ export let events = (target: T): T => { }[className] } +const eventsBrand = Symbol('events class') + +export interface EventsClass { + [eventsBrand]: true +} + +export function isEventsClass(value: unknown): value is EventsClass { + return value instanceof Object + && eventsBrand in value +} + +export function getModelProperties(model: Model): Record { + return model[modelProperties] +} + function isClassFieldDecoratorContext(context: ClassFieldDecoratorContext | unknown): context is ClassFieldDecoratorContext { return isDecoratorContext(context) && context.kind == 'field' } diff --git a/packages/event-reduce/src/signals/nodes.ts b/packages/event-reduce/src/signals/nodes.ts deleted file mode 100644 index f9b59e1..0000000 --- a/packages/event-reduce/src/signals/nodes.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { dispose, isObject } from "../utils" -import { ISource } from "./signals" -import { getState } from "./state" -import { updateChildren } from "./tracking" - -export const modelProperties = Symbol('model properties') -export const eventsBrand = Symbol('events class') - -export interface Model extends Disposable { - [modelProperties]: Record -} - -export type ModelProperty = ISource | StateProperty - -export interface EventsClass { - [eventsBrand]: true -} - -export function isModel(value: unknown): value is Model { - return isObject(value) - && modelProperties in value -} - -export function isEventsClass(value: unknown): value is EventsClass { - return value instanceof Object - && eventsBrand in value -} - -export class StateProperty implements Disposable { - constructor( - private _instance: any, - public readonly key: PropertyKey - ) { - updateChildren(_instance, null, this.value) - } - - toJSON() { return getState(this.value) } - - get value() { return this._instance[this.key] } - - [dispose]() { - updateChildren(this._instance, this.value, null) - } -} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/reaction.ts b/packages/event-reduce/src/signals/reaction.ts deleted file mode 100644 index 69f5646..0000000 --- a/packages/event-reduce/src/signals/reaction.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { dispose } from "../utils" -import { Effect } from "./effect" -import { beginTransition, iterationSource } from "./tracking" - -// NOCOMMIT not sure I have a use for this -export class Reaction extends Effect { - constructor( - displayName: string, - reaction: () => void, - private _schedule?: (run: () => void) => void - ) { - super(displayName, reaction, () => { - if (this._schedule) { - let reactionSource = iterationSource! - this._schedule(() => { - let transition = beginTransition(reactionSource, true) - try { - this.run() - } finally { - transition[dispose]() - } - }) - } else { - this.run() - } - }) - } -} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts index be1c182..c8e185a 100644 --- a/packages/event-reduce/src/signals/reduction.ts +++ b/packages/event-reduce/src/signals/reduction.ts @@ -1,11 +1,12 @@ import { dispose, emptyArray } from "../utils" import { derive, IDerivation } from "./derivation" import { startLogging } from "./logging" -import { allSources, IObserver, ISource, isSource, IValueSource, NotASourceValueError, SourceBase } from "./signals" +import { allSources, IObserver, ISource, isSource, NotASourceValueError, Source } from "./sources" import { getState, State } from "./state" -import { currentIteration, managedSourceCreated, trackSourceAccessed, collectAccessedSources, updateChildren, getUnderlyingSource } from "./tracking" +import { collectAccessedSources, currentIteration, getUnderlyingSource, managedSourceCreated, trackSourceAccessed, updateChildren } from "./tracking" -export interface IReduction extends IValueSource { +export interface IReduction extends ISource { + readonly value: T on(source: ISource, reduce: Reducer): this on(getSource: (events: Events) => ISource, reduce: Reducer): this } @@ -20,6 +21,7 @@ export function reduce(initialValue: T, eventsOrDisplayName?: Events return new Reduction(displayName ?? Reduction.defaultName, initialValue, Object.is, events) } +/** Extends an existing source with reduction capabilities. */ export function extendReduce(observableValue: T, displayName?: string): IReduction { let source = getUnderlyingSource(observableValue) if (source) @@ -29,7 +31,7 @@ export function extendReduce(observableValue: T, displayName?: string): IRedu export type Reducer = (current: Result, next: Source) => Result -export class Reduction extends SourceBase implements IReduction, IObserver { +export class Reduction extends Source implements IReduction, IObserver { sources = new Set>() private _reducers = new Map, (current: T, next: any) => T>() private _sourcesLastChecked = -1 @@ -61,7 +63,7 @@ export class Reduction extends SourceBase implements IReduc source = source(this._events!) if (process.env.NODE_ENV !== 'production') { - if (allSources(source).find(s => s == this)) + if (allSources(source).find(s => s == this)) throw new CircularSubscriptionError(this, source); } @@ -111,7 +113,7 @@ export class Reduction extends SourceBase implements IReduc if (this._sourcesLastChecked < currentIteration) { let newerSource = Array.from(this.sources).find(s => s.lastUpdated > this._sourcesLastChecked) if (newerSource) - this.sourceUpdated(newerSource, (newerSource as SourceBase).value) + this.sourceUpdated(newerSource, (newerSource as Source).value) } } finally { access[dispose]() diff --git a/packages/event-reduce/src/signals/signals.ts b/packages/event-reduce/src/signals/sources.ts similarity index 82% rename from packages/event-reduce/src/signals/signals.ts rename to packages/event-reduce/src/signals/sources.ts index 4521b86..68d2639 100644 --- a/packages/event-reduce/src/signals/signals.ts +++ b/packages/event-reduce/src/signals/sources.ts @@ -1,14 +1,8 @@ import { dispose, emptyArray, identity, nameOfCallback, nameOfFunction } from "../utils" import type { IEventFunction } from "./events" -import { isEventsClass } from "./nodes" +import { isEventsClass } from "./models" import { currentIteration, getPath, iterationSource, trackSourceAccessed, updateChildren } from "./tracking" -export function isSource(value: any): value is ISource { - return value instanceof SourceBase -} - -export type SourceValue> = Source extends ISource ? T : never - export interface ISource extends Disposable { readonly displayName: string setDisplayName(name: string): void @@ -24,17 +18,19 @@ export interface ISource extends Disposable { filterMap(transform: (value: T) => Out | undefined, displayName?: string): ISource } -// NOCOMMIT do we actually need this, or could value be pushed down? -export interface IValueSource extends ISource { - readonly value: T -} - +// NOCOMMIT move elsewhere? export interface IObserver { readonly displayName: string sourceUpdated(source: ISource, value: S): void } -export abstract class SourceBase implements ISource { +export function isSource(value: any): value is ISource { + return value instanceof Source +} + +export type SourceValue> = Source extends ISource ? T : never + +export abstract class Source implements ISource { protected readonly _observers = new Set>() protected _value!: T lastUpdated = -1 @@ -54,7 +50,7 @@ export abstract class SourceBase implements ISource { setValue(newValue: T) { if (process.env.NODE_ENV !== 'production') { - if (isEventsClass(newValue) || newValue instanceof SourceBase && !newValue.isValidAsState) + if (isEventsClass(newValue) || newValue instanceof Source && !newValue.isValidAsState) throw new InvalidStateError(this, newValue) if (!iterationSource) @@ -77,15 +73,15 @@ export abstract class SourceBase implements ISource { protected isValidAsState = true - map, Out>(this: Source, transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`): ISource { + map, Out>(this: S, transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`): ISource { return bind(displayName, this, transform) } - filter>(this: Source, predicate: (value: T) => boolean, displayName = `${this.displayName}.filter(${nameOfCallback(predicate)})`): ISource { + filter>(this: S, predicate: (value: T) => boolean, displayName = `${this.displayName}.filter(${nameOfCallback(predicate)})`): ISource { return bind(displayName, this, identity, predicate) } - filterMap, Out>(this: Source, transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`): ISource { + filterMap, Out>(this: S, transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`): ISource { return bind(displayName, this, v => transform(v)!, value => value == undefined) } @@ -103,7 +99,7 @@ export function bind(displayName: string, source: ISource, transfor return new BoundSource(displayName, source, transform, predicate) } -class BoundSource extends SourceBase { +class BoundSource extends Source { constructor( displayName: string, private _source: ISource, diff --git a/packages/event-reduce/src/signals/testing.ts b/packages/event-reduce/src/signals/testing.ts index 40f67f2..0cf45b1 100644 --- a/packages/event-reduce/src/signals/testing.ts +++ b/packages/event-reduce/src/signals/testing.ts @@ -2,27 +2,27 @@ import { dispose, isObject } from "../utils" import { Atom } from "./atom" import { Derivation } from "./derivation" import { event } from "./events" -import { isModel, modelProperties } from "./nodes" +import { getModelProperties, isModel } from "./models" import { Reduction } from "./reduction" -import { isSource, SourceBase } from "./signals" +import { isSource, Source } from "./sources" import { beginTransition } from "./tracking" const targetAccessor = Symbol('target') export function mutable(model: T): Mutable { if (isModel(model)) { - let properties = model[modelProperties] + let properties = getModelProperties(model) for (let key in properties) { let property = properties[key] if (isSource(property)) { Object.defineProperty(model, key, { - get() { return (property as SourceBase).value }, + get() { return (property as Source).value }, set(value) { if (property instanceof Reduction || property instanceof Derivation) property.detach() - if (property instanceof SourceBase) { + if (property instanceof Source) { let transition = beginTransition(property, true) try { property.setValue(value) diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/signals/tracking.ts index 7fde91d..c997a81 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/signals/tracking.ts @@ -1,8 +1,8 @@ import { dispose, emptyArray, emptyMap, isDisposable, isObject, nameOfFunction, nullDisposable } from "../utils" import { Effect } from "./effect" import { log } from "./logging" -import { type EventsClass, isEventsClass, isModel, type Model, StateProperty } from "./nodes" -import { type IObserver, type ISource, isSource, SourceBase } from "./signals" +import { type EventsClass, isEventsClass, isModel, type Model, StateProperty } from "./models" +import { type IObserver, type ISource, isSource, Source } from "./sources" type GraphNode = ISource | Model | EventsClass @@ -50,7 +50,7 @@ export function trackSourceAccessed(source: ISource): Disposable { export function getUnderlyingSource(value: T): ISource | null { return lastSourceAccessed - && lastSourceAccessed instanceof SourceBase + && lastSourceAccessed instanceof Source && Object.is(lastSourceAccessed.value, value) ? lastSourceAccessed : null @@ -61,13 +61,13 @@ export function updateChildren(parent: GraphNode, previousValue: unknown, nextVa return let previousChildren: ReadonlyMap = emptyMap - if (isObject(previousValue) || previousValue instanceof SourceBase) { + if (isObject(previousValue) || previousValue instanceof Source) { previousChildren = childSnapshots.get(previousValue) ?? getChildNodes(previousValue) childSnapshots.delete(previousValue) } let newChildren: ReadonlyMap = emptyMap - if (isObject(nextValue) || nextValue instanceof SourceBase) { + if (isObject(nextValue) || nextValue instanceof Source) { newChildren = getChildNodes(nextValue) childSnapshots.set(nextValue, newChildren) } diff --git a/readme.md b/readme.md index dadd726..6bdb9ff 100644 --- a/readme.md +++ b/readme.md @@ -71,7 +71,6 @@ class CounterModel { } } ``` -> **Note:** event-reduce currently relies on `"experimentalDecorators": true` and `"useDefineForClassFields": false` being set in your `tsconfig.json` file. Standard ECMAScript field behaviour and decorator support is coming soon. Then the model can be used like so: ```ts diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index 9dbc77d..3a042ee 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test" import { Atom, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce/src/signals" -import { isModel, modelProperties } from "event-reduce/src/signals/nodes" +import { getModelProperties, isModel } from "event-reduce/src/signals/models" import { StringKey } from "event-reduce/src/types" import { dispose } from "event-reduce/src/utils" @@ -318,7 +318,7 @@ describe("subscription cleanup", () => { function spyOnDispose(model: T, property: StringKey) { if (isModel(model)) { - let observableValue = model[modelProperties][property]! + let observableValue = getModelProperties(model)[property]! if (dispose in observableValue) return spyOn(observableValue, dispose) } diff --git a/tests/Derivation.test.ts b/tests/Derivation.test.ts index 629b017..92c19e8 100644 --- a/tests/Derivation.test.ts +++ b/tests/Derivation.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; import { Atom, derive, event, events, IDerivation } from "event-reduce/src/signals"; -import { InvalidStateError } from "event-reduce/src/signals/signals"; +import { InvalidStateError } from "event-reduce/src/signals/sources"; import { getUnderlyingSource, SideEffectDuringReactionError } from "event-reduce/src/signals/tracking"; import { MockObserver } from "./MockObserver"; diff --git a/tests/Example.test.tsx b/tests/Example.test.tsx index 161c783..d4708eb 100644 --- a/tests/Example.test.tsx +++ b/tests/Example.test.tsx @@ -1,9 +1,7 @@ import { fireEvent, render, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, test } from "bun:test"; -import { Counter } from "event-reduce-example/Counter"; -import { CounterList } from "event-reduce-example/CounterList"; -import { CounterListEvents, CounterListModel } from "event-reduce-example/CounterListModel"; -import { CounterEvents, CounterModel } from "event-reduce-example/CounterModel"; +import { Counter, CounterEvents, CounterModel } from "event-reduce-example/Counter"; +import { CounterList, CounterListEvents, CounterListModel } from "event-reduce-example/CounterList"; import { eventProxy, Mutable, mutable } from "event-reduce/lib/signals/testing"; describe("CounterListModel", () => { diff --git a/tests/Hooks.test.ts b/tests/Hooks.test.ts index e7fedab..6f90ba6 100644 --- a/tests/Hooks.test.ts +++ b/tests/Hooks.test.ts @@ -2,8 +2,8 @@ import { renderHook, RenderHookResult } from "@testing-library/react-hooks"; import { beforeEach, describe, expect, test } from "bun:test"; import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react/src/signals"; import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce/lib/signals"; +import { mutable } from "event-reduce/lib/signals/testing"; import { IDerivation } from "event-reduce/src/signals"; -import { mutable } from "event-reduce/src/signals/testing"; import { MockObserver } from "./MockObserver"; describe("useEvent", () => { @@ -65,7 +65,7 @@ describe("useDerived", () => { let initialResult: IDerivation beforeEach(() => { - source = mutable(new SourceModel()); // NOCOMMIT signals version of testing + source = mutable(new SourceModel()); sut = renderHook(() => useDerived(() => source.value * 2)); initialResult = sut.result.current; }); diff --git a/tests/Reduction.test.ts b/tests/Reduction.test.ts index 1efd9a5..606085a 100644 --- a/tests/Reduction.test.ts +++ b/tests/Reduction.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; import { Atom, createModel, event, events, IReduction, reduce } from 'event-reduce/src/signals'; import { CircularSubscriptionError } from "event-reduce/src/signals/reduction"; -import { InvalidStateError } from "event-reduce/src/signals/signals"; +import { InvalidStateError } from "event-reduce/src/signals/sources"; import { CircularDependencyError, collectAccessedSources } from "event-reduce/src/signals/tracking"; import { dispose } from "event-reduce/src/utils"; import { MockObserver } from "./MockObserver"; From 9b7d639f43727f8dacfcca111889df9ab8d32c40 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 28 Mar 2026 08:51:54 +1000 Subject: [PATCH 35/83] Reworking package and module references --- .vscode/tasks.json | 16 ++++++- bunfig.legacy.toml | 2 + bunfig.toml | 6 ++- example/Counter.tsx | 6 +-- example/CounterList.tsx | 4 +- example/index.tsx | 4 +- example/tsconfig.json | 26 ++++++---- package.json | 6 ++- packages/event-reduce-react/package.json | 47 +++++++++++++++++-- packages/event-reduce-react/src/hooks.ts | 15 +++--- packages/event-reduce-react/src/index.ts | 4 +- packages/event-reduce-react/src/rendering.ts | 15 +++--- .../event-reduce-react/src/signals/hooks.ts | 12 ++--- .../src/signals/rendering.ts | 12 ++--- packages/event-reduce-react/tsconfig.json | 27 ++++++----- packages/event-reduce/package.json | 47 +++++++++++++++++-- tests-legacy/LegacyModel.test.ts | 6 +-- tests-legacy/LegacyState.test.ts | 4 +- tests-legacy/tsconfig.json | 29 +++++++----- tests/Cleanup.test.ts | 8 ++-- tests/Derivation.test.ts | 8 ++-- tests/Event.test.ts | 6 +-- tests/Example.test.tsx | 4 +- tests/Hooks.test.ts | 12 ++--- tests/MockObserver.ts | 4 +- tests/Model.test.ts | 8 ++-- tests/Model.test/ExtendedModel.ts | 4 +- tests/Model.test/TestModel.ts | 4 +- tests/Observable.test.ts | 6 ++- tests/ObservableValue.test.ts | 4 +- tests/Reduction.test.ts | 12 ++--- tests/Rendering.test.ts | 8 ++-- tests/State.test.ts | 4 +- tests/State.test/BaseModel.ts | 4 +- tests/State.test/ChildModel.ts | 4 +- tests/State.test/TestModel.ts | 4 +- tests/Subject.test.ts | 2 +- tests/Testing.test.ts | 6 +-- tests/setup.ts | 17 +------ tests/tsconfig.json | 30 +++++++----- tsconfig.base.json | 12 ++--- tsconfig.json | 12 ++--- 42 files changed, 291 insertions(+), 180 deletions(-) create mode 100644 bunfig.legacy.toml diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a3c7cb9..1906129 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -29,6 +29,20 @@ "kind": "test", "isDefault": true } + }, + { + "type": "bun", + "script": "run test:modern", + "problemMatcher": [], + "label": "Run modern tests", + "group": "test" + }, + { + "type": "shell", + "command": "bun run test:legacy", + "problemMatcher": [], + "label": "Run legacy tests", + "group": "test" } ] -} \ No newline at end of file +} diff --git a/bunfig.legacy.toml b/bunfig.legacy.toml new file mode 100644 index 0000000..8691789 --- /dev/null +++ b/bunfig.legacy.toml @@ -0,0 +1,2 @@ +[test] +root = "tests-legacy/dist" diff --git a/bunfig.toml b/bunfig.toml index b21c098..7ecd9d8 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,4 @@ -[test] -preload = ["./tests/dist/setup.js"] \ No newline at end of file +[test] +root = "tests" +pathIgnorePatterns = ["**/dist/**"] +preload = ["./tests/setup.ts"] diff --git a/example/Counter.tsx b/example/Counter.tsx index f861141..9e6031a 100644 --- a/example/Counter.tsx +++ b/example/Counter.tsx @@ -1,5 +1,5 @@ -import { reactive } from "event-reduce-react/lib/signals"; -import { asyncEvent, derived, events, model, reduce, reduced } from "event-reduce/lib/signals"; +import { reactive } from "event-reduce-react/signals"; +import { asyncEvent, derived, events, model, reduce, reduced } from "event-reduce/signals"; import { CounterListEvents } from "./CounterList"; @events @@ -66,4 +66,4 @@ export const Counter = reactive(function Counter({ model }: { model: CounterMode function onFetch() { events.valueFetched(Promise.resolve(100)); } -}); \ No newline at end of file +}); diff --git a/example/CounterList.tsx b/example/CounterList.tsx index 968d87e..6a43725 100644 --- a/example/CounterList.tsx +++ b/example/CounterList.tsx @@ -1,5 +1,5 @@ -import { reactive } from "event-reduce-react/lib/signals"; -import { event, events, model, reduce, reduced } from "event-reduce/lib/signals"; +import { reactive } from "event-reduce-react/signals"; +import { event, events, model, reduce, reduced } from "event-reduce/signals"; import { Counter, CounterEvents, CounterModel } from "./Counter"; @events diff --git a/example/index.tsx b/example/index.tsx index 24cfe14..2b60cea 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -1,4 +1,4 @@ -import { enableDevTools, enableLogging } from "event-reduce/lib/signals" +import { enableDevTools, enableLogging } from "event-reduce/signals" import { StrictMode } from "react" import * as ReactDOM from "react-dom/client" import { CounterList, CounterListEvents, CounterListModel } from "./CounterList" @@ -13,4 +13,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render( -) \ No newline at end of file +) diff --git a/example/tsconfig.json b/example/tsconfig.json index b6937dd..4d39db3 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -1,10 +1,18 @@ { - "extends": "../tsconfig.base.json", - "compilerOptions": { - "noEmit": true, - "esModuleInterop": true, - "types": [ - "bun" - ] - } -} \ No newline at end of file + "extends": "../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "esModuleInterop": true, + "types": [ + "bun" + ] + }, + "references": [ + { + "path": "../packages/event-reduce" + }, + { + "path": "../packages/event-reduce-react" + } + ] +} diff --git a/package.json b/package.json index 07755a4..eee249b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "scripts": { "prepare": "tsc -b", "watch": "tsc -b -w", - "test": "tsc -b tests tests-legacy && bun test tests/dist tests-legacy/dist", + "test:modern": "bun test", + "test:legacy": "tsc -b tests-legacy && bun test --config=bunfig.legacy.toml", + "test": "bun run test:modern && bun run test:legacy", "example": "bun example/serve.ts" }, "devDependencies": { @@ -28,4 +30,4 @@ "resolutions": { "deasync": "0.1.30" } -} \ No newline at end of file +} diff --git a/packages/event-reduce-react/package.json b/packages/event-reduce-react/package.json index 2e5e6b4..86a7f01 100644 --- a/packages/event-reduce-react/package.json +++ b/packages/event-reduce-react/package.json @@ -10,14 +10,51 @@ "directory": "packages/event-reduce-react" }, "license": "MIT", - "main": "lib/index", - "types": "lib/index", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "bun": "./src/index.ts", + "default": "./lib/index.js" + }, + "./signals": { + "types": "./lib/signals/index.d.ts", + "bun": "./src/signals/index.ts", + "default": "./lib/signals/index.js" + }, + "./signals/*": { + "types": "./lib/signals/*.d.ts", + "bun": "./src/signals/*.ts", + "default": "./lib/signals/*.js" + }, + "./*": { + "types": "./lib/*.d.ts", + "bun": "./src/*.ts", + "default": "./lib/*.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "signals": [ + "lib/signals/index.d.ts" + ], + "signals/*": [ + "lib/signals/*" + ], + "*": [ + "lib/*" + ] + } + }, "scripts": { "prepack": "cp ../../LICENSE ." }, "files": [ - "lib" - ], + "lib", + "src" + ], "dependencies": { "event-reduce": "workspace:^", "use-sync-external-store": "*" @@ -30,4 +67,4 @@ "@types/use-sync-external-store": "*", "typescript": "~5" } -} \ No newline at end of file +} diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index 8208915..46b0d4d 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -1,8 +1,11 @@ -import { IObservableValue, IReduction, asyncEvent, derive, event, reduce } from "event-reduce"; -import { changeOwnedValue } from "event-reduce/lib/cleanup"; -import { ObservableValue } from "event-reduce/lib/observableValue"; -import { ValueOf } from "event-reduce/lib/types"; -import { emptyArray } from "event-reduce/lib/utils"; +import { asyncEvent, event } from "event-reduce/events"; +import { IObservableValue } from "event-reduce/observableValue"; +import { IReduction, reduce } from "event-reduce/reduction"; +import { derive } from "event-reduce/derivation"; +import { changeOwnedValue } from "event-reduce/cleanup"; +import { ObservableValue } from "event-reduce/observableValue"; +import { ValueOf } from "event-reduce/types"; +import { emptyArray } from "event-reduce/utils"; import { DependencyList, useMemo, useRef, useState } from "react"; /** @@ -77,4 +80,4 @@ export function useObserved(value: T, name: string = '(anonymous observed val let observableValue = useModel(() => new ObservableValue(() => name, value)); observableValue.setValue(value); return observableValue; -} \ No newline at end of file +} diff --git a/packages/event-reduce-react/src/index.ts b/packages/event-reduce-react/src/index.ts index 59e8dc7..fcfae24 100644 --- a/packages/event-reduce-react/src/index.ts +++ b/packages/event-reduce-react/src/index.ts @@ -1,7 +1,7 @@ -import { cleanupOptions } from "event-reduce/lib/cleanup" +import { cleanupOptions } from "event-reduce/cleanup" export * from "./hooks"; export * from "./rendering"; let baseSkip = cleanupOptions.skipCleanup; -cleanupOptions.skipCleanup = (value: object) => baseSkip(value) || '$$typeof' in value; // Skip react elements \ No newline at end of file +cleanupOptions.skipCleanup = (value: object) => baseSkip(value) || '$$typeof' in value; // Skip react elements diff --git a/packages/event-reduce-react/src/rendering.ts b/packages/event-reduce-react/src/rendering.ts index 0e548d9..a9893c2 100644 --- a/packages/event-reduce-react/src/rendering.ts +++ b/packages/event-reduce-react/src/rendering.ts @@ -1,9 +1,10 @@ -import { IObservableValue, Observe } from "event-reduce"; -import { Derivation } from "event-reduce/lib/derivation"; -import { LogValue } from "event-reduce/lib/logging"; -import { ObservableValue } from "event-reduce/lib/observableValue"; -import { reactionQueue } from "event-reduce/lib/reactions"; -import { constant, nameOfFunction, unsubscribeAll } from "event-reduce/lib/utils"; +import { Observe } from "event-reduce/observable"; +import { IObservableValue } from "event-reduce/observableValue"; +import { Derivation } from "event-reduce/derivation"; +import { LogValue } from "event-reduce/logging"; +import { ObservableValue } from "event-reduce/observableValue"; +import { reactionQueue } from "event-reduce/reactions"; +import { constant, nameOfFunction, unsubscribeAll } from "event-reduce/utils"; import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useCallback, useEffect, useState } from "react"; import { useSyncExternalStore } from "use-sync-external-store/shim"; @@ -113,4 +114,4 @@ class RenderedValue extends Derivation { } return value; } -} \ No newline at end of file +} diff --git a/packages/event-reduce-react/src/signals/hooks.ts b/packages/event-reduce-react/src/signals/hooks.ts index 5abab1c..e0f1be0 100644 --- a/packages/event-reduce-react/src/signals/hooks.ts +++ b/packages/event-reduce-react/src/signals/hooks.ts @@ -1,8 +1,8 @@ -import { asyncEvent, Atom, derive, event, IDerivation, IReduction, reduce } from "event-reduce/lib/signals" -import { isModel, Model } from "event-reduce/lib/signals/models" -import { Reduction } from "event-reduce/lib/signals/reduction" -import { trackManagedSourceCreation, updateChildren } from "event-reduce/lib/signals/tracking" -import { dispose, emptyArray } from "event-reduce/lib/utils" +import { asyncEvent, Atom, derive, event, IDerivation, IReduction, reduce } from "event-reduce/signals" +import { isModel, Model } from "event-reduce/signals/models" +import { Reduction } from "event-reduce/signals/reduction" +import { trackManagedSourceCreation } from "event-reduce/signals/tracking" +import { dispose, emptyArray } from "event-reduce/utils" import { DependencyList, useEffect, useMemo, useRef, useState } from "react" /** @@ -82,4 +82,4 @@ export function useAtom(value: T, name: string = '(anonymous observed value)' class InvalidModelError extends Error { constructor() { super("Invalid model created in useModel. A model must be an instance of a class decorated with @model.") } -} \ No newline at end of file +} diff --git a/packages/event-reduce-react/src/signals/rendering.ts b/packages/event-reduce-react/src/signals/rendering.ts index 968f4f9..ce9093c 100644 --- a/packages/event-reduce-react/src/signals/rendering.ts +++ b/packages/event-reduce-react/src/signals/rendering.ts @@ -1,8 +1,8 @@ -import { LogValue } from "event-reduce/lib/logging" -import { Effect, log, startLogging } from "event-reduce/lib/signals" -import { ISource } from "event-reduce/lib/signals/sources" -import { beginTransition } from "event-reduce/lib/signals/tracking" -import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/lib/utils" +import { LogValue } from "event-reduce/logging" +import { Effect, log, startLogging } from "event-reduce/signals" +import { ISource } from "event-reduce/signals/sources" +import { beginTransition } from "event-reduce/signals/tracking" +import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/utils" import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useState } from "react" import { useSyncExternalStore } from "use-sync-external-store/shim" @@ -118,4 +118,4 @@ function loggedReactValue(value: unknown) { } } return value -} \ No newline at end of file +} diff --git a/packages/event-reduce-react/tsconfig.json b/packages/event-reduce-react/tsconfig.json index de39928..9cf8129 100644 --- a/packages/event-reduce-react/tsconfig.json +++ b/packages/event-reduce-react/tsconfig.json @@ -1,13 +1,18 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "outDir": "lib", - "rootDir": "src", - "declaration": true, - "declarationMap": true - }, - "include": [ - "src" - ] -} \ No newline at end of file + "compilerOptions": { + "composite": true, + "outDir": "lib", + "rootDir": "src", + "declaration": true, + "declarationMap": true + }, + "references": [ + { + "path": "../event-reduce" + } + ], + "include": [ + "src" + ] +} diff --git a/packages/event-reduce/package.json b/packages/event-reduce/package.json index 01c0966..7a16942 100644 --- a/packages/event-reduce/package.json +++ b/packages/event-reduce/package.json @@ -10,16 +10,53 @@ "directory": "packages/event-reduce" }, "license": "MIT", - "main": "lib/index", - "types": "lib/index", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "bun": "./src/index.ts", + "default": "./lib/index.js" + }, + "./signals": { + "types": "./lib/signals/index.d.ts", + "bun": "./src/signals/index.ts", + "default": "./lib/signals/index.js" + }, + "./signals/*": { + "types": "./lib/signals/*.d.ts", + "bun": "./src/signals/*.ts", + "default": "./lib/signals/*.js" + }, + "./*": { + "types": "./lib/*.d.ts", + "bun": "./src/*.ts", + "default": "./lib/*.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "signals": [ + "lib/signals/index.d.ts" + ], + "signals/*": [ + "lib/signals/*" + ], + "*": [ + "lib/*" + ] + } + }, "scripts": { "prepack": "cp ../../readme.md . && cp ../../LICENSE ." }, "files": [ - "lib" - ], + "lib", + "src" + ], "devDependencies": { "@types/node": "*", "typescript": "~5" } -} \ No newline at end of file +} diff --git a/tests-legacy/LegacyModel.test.ts b/tests-legacy/LegacyModel.test.ts index 3bb0616..0b6bbba 100644 --- a/tests-legacy/LegacyModel.test.ts +++ b/tests-legacy/LegacyModel.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" -import { asyncEvent, derive, derived, event, events, extendReduce, model, reduce, reduced, state } from "event-reduce/src/signals" -import { EventsMarkedAsStateError } from "event-reduce/src/signals/models" +import { asyncEvent, derive, derived, event, events, extendReduce, model, reduce, reduced, state } from "event-reduce/signals" +import { EventsMarkedAsStateError } from "event-reduce/signals/models" describe("legacy models", () => { let increment: ReturnType> @@ -235,4 +235,4 @@ describe("events decorator", () => { expect((e as EventsMarkedAsStateError).property).toBe('events') } }) -}) \ No newline at end of file +}) diff --git a/tests-legacy/LegacyState.test.ts b/tests-legacy/LegacyState.test.ts index 21b7d9b..5651aee 100644 --- a/tests-legacy/LegacyState.test.ts +++ b/tests-legacy/LegacyState.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { derived, getState, reduce, reduced, state } from "event-reduce/lib/signals"; +import { derived, getState, reduce, reduced, state } from "event-reduce/signals"; describe("legacy state", () => { class BaseModel { @@ -59,4 +59,4 @@ describe("legacy state", () => { subClassProperty: 'subClass' })); }); -}); \ No newline at end of file +}); diff --git a/tests-legacy/tsconfig.json b/tests-legacy/tsconfig.json index 53452bf..948e451 100644 --- a/tests-legacy/tsconfig.json +++ b/tests-legacy/tsconfig.json @@ -1,14 +1,19 @@ { "extends": "../tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "outDir": "./dist", - "rootDir": ".", - "experimentalDecorators": true, - "useDefineForClassFields": false - }, - "include": [ - "./**/*.ts", - "./**/*.tsx" - ] -} \ No newline at end of file + "compilerOptions": { + "baseUrl": ".", + "outDir": "./dist", + "rootDir": ".", + "experimentalDecorators": true, + "useDefineForClassFields": false + }, + "references": [ + { + "path": "../packages/event-reduce" + } + ], + "include": [ + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index 3a042ee..4e4cac9 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test" -import { Atom, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce/src/signals" -import { getModelProperties, isModel } from "event-reduce/src/signals/models" -import { StringKey } from "event-reduce/src/types" -import { dispose } from "event-reduce/src/utils" +import { Atom, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce/signals" +import { getModelProperties, isModel } from "event-reduce/signals/models" +import { StringKey } from "event-reduce/types" +import { dispose } from "event-reduce/utils" describe("subscription cleanup", () => { @model diff --git a/tests/Derivation.test.ts b/tests/Derivation.test.ts index 92c19e8..1765ae5 100644 --- a/tests/Derivation.test.ts +++ b/tests/Derivation.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { Atom, derive, event, events, IDerivation } from "event-reduce/src/signals"; -import { InvalidStateError } from "event-reduce/src/signals/sources"; -import { getUnderlyingSource, SideEffectDuringReactionError } from "event-reduce/src/signals/tracking"; +import { Atom, derive, event, events, IDerivation } from "event-reduce/signals"; +import { InvalidStateError } from "event-reduce/signals/sources"; +import { getUnderlyingSource, SideEffectDuringReactionError } from "event-reduce/signals/tracking"; import { MockObserver } from "./MockObserver"; describe("derive", () => { @@ -135,4 +135,4 @@ test("derivation that depends on other derivations only updates once", () => { source.setValue("b"); outer.value; expect(outerCalc).toHaveBeenCalledTimes(2); -}); \ No newline at end of file +}); diff --git a/tests/Event.test.ts b/tests/Event.test.ts index a35d11f..35fe8fb 100644 --- a/tests/Event.test.ts +++ b/tests/Event.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart } from 'event-reduce/src/signals'; -import { SideEffectDuringReactionError } from "event-reduce/src/signals/tracking"; +import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart } from 'event-reduce/signals'; +import { SideEffectDuringReactionError } from "event-reduce/signals/tracking"; import { MockObserver } from "./MockObserver"; describe("event", () => { @@ -129,4 +129,4 @@ describe("asyncEvent", () => { expect(rejected.sourceUpdated).toHaveBeenCalledWith(sut.rejected, expect.objectContaining({ error, context })); }); }); -}); \ No newline at end of file +}); diff --git a/tests/Example.test.tsx b/tests/Example.test.tsx index d4708eb..8d75c8a 100644 --- a/tests/Example.test.tsx +++ b/tests/Example.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, test } from "bun:test"; import { Counter, CounterEvents, CounterModel } from "event-reduce-example/Counter"; import { CounterList, CounterListEvents, CounterListModel } from "event-reduce-example/CounterList"; -import { eventProxy, Mutable, mutable } from "event-reduce/lib/signals/testing"; +import { eventProxy, Mutable, mutable } from "event-reduce/signals/testing"; describe("CounterListModel", () => { let events: CounterListEvents; @@ -177,4 +177,4 @@ describe("Counter", () => { expect(sut.getByTestId('count').textContent).toBe('100'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/Hooks.test.ts b/tests/Hooks.test.ts index 6f90ba6..13ca2ce 100644 --- a/tests/Hooks.test.ts +++ b/tests/Hooks.test.ts @@ -1,9 +1,9 @@ -import { renderHook, RenderHookResult } from "@testing-library/react-hooks"; +import { renderHook, RenderHookResult } from "@testing-library/react-hooks/dom"; import { beforeEach, describe, expect, test } from "bun:test"; -import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react/src/signals"; -import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce/lib/signals"; -import { mutable } from "event-reduce/lib/signals/testing"; -import { IDerivation } from "event-reduce/src/signals"; +import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react/signals"; +import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce/signals"; +import { mutable } from "event-reduce/signals/testing"; +import { IDerivation } from "event-reduce/signals"; import { MockObserver } from "./MockObserver"; describe("useEvent", () => { @@ -110,4 +110,4 @@ describe("useReduced", () => { expect(sut.result.current).toBe(initialResult); }); -}); \ No newline at end of file +}); diff --git a/tests/MockObserver.ts b/tests/MockObserver.ts index b426f8d..2ec748e 100644 --- a/tests/MockObserver.ts +++ b/tests/MockObserver.ts @@ -1,8 +1,8 @@ import { mock } from "bun:test"; -import { IObserver, ISource } from "event-reduce/src/signals"; +import { IObserver, ISource } from "event-reduce/signals"; export class MockObserver implements IObserver { displayName = 'mock observer' sources = new Set>() sourceUpdated: (source: ISource, value: T) => void = mock<(source: ISource, value: T) => void>() -} \ No newline at end of file +} diff --git a/tests/Model.test.ts b/tests/Model.test.ts index c3e49d7..4646c0d 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { asyncEvent, derive, event, events, model, reduce, reduced, state } from "event-reduce/src/signals"; -import { Derivation } from "event-reduce/src/signals/derivation"; -import { EventsMarkedAsStateError } from "event-reduce/src/signals/models"; +import { asyncEvent, derive, event, events, model, reduce, reduced, state } from "event-reduce/signals"; +import { Derivation } from "event-reduce/signals/derivation"; +import { EventsMarkedAsStateError } from "event-reduce/signals/models"; import { decrement, ExtendedModel } from "./Model.test/ExtendedModel"; import { increment, TestModel } from "./Model.test/TestModel"; @@ -168,4 +168,4 @@ describe("events decorator", () => { expect((e as EventsMarkedAsStateError).property).toBe('events'); } }); -}); \ No newline at end of file +}); diff --git a/tests/Model.test/ExtendedModel.ts b/tests/Model.test/ExtendedModel.ts index 86629c8..a9ee1ec 100644 --- a/tests/Model.test/ExtendedModel.ts +++ b/tests/Model.test/ExtendedModel.ts @@ -1,4 +1,4 @@ -import { event, extendReduce, model, reduced } from "event-reduce/src/signals"; +import { event, extendReduce, model, reduced } from "event-reduce/signals"; import { TestModel } from "./TestModel"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -18,4 +18,4 @@ export class ExtendedModel extends TestModel { override accessor accessorProp = extendReduce(super.accessorProp) .on(decrement, c => c - 1) .value -} \ No newline at end of file +} diff --git a/tests/Model.test/TestModel.ts b/tests/Model.test/TestModel.ts index e0ee8b4..c75e2e3 100644 --- a/tests/Model.test/TestModel.ts +++ b/tests/Model.test/TestModel.ts @@ -1,4 +1,4 @@ -import { derive, derived, event, model, reduce, reduced } from "event-reduce/src/signals"; +import { derive, derived, event, model, reduce, reduced } from "event-reduce/signals"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -39,4 +39,4 @@ export class TestModel { .on(derive(() => this.derivedProperty), (_, d) => d) .value; } -} \ No newline at end of file +} diff --git a/tests/Observable.test.ts b/tests/Observable.test.ts index 759c392..a65af7a 100644 --- a/tests/Observable.test.ts +++ b/tests/Observable.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { IObservable, IObserver, merge, Subject, Unsubscribe } from 'event-reduce'; -import { ObservableOperation } from 'event-reduce/lib/observable'; +import { IObservable, IObserver, merge } from 'event-reduce/observable'; +import { Subject } from 'event-reduce/subject'; +import { Unsubscribe } from 'event-reduce/types'; +import { ObservableOperation } from 'event-reduce/observable'; describe("ObservableOperation", () => { describe("when subscribing", () => { diff --git a/tests/ObservableValue.test.ts b/tests/ObservableValue.test.ts index e976257..aa05ea5 100644 --- a/tests/ObservableValue.test.ts +++ b/tests/ObservableValue.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { ObservableValue, collectAccessedValues, consumeLastAccessed } from "event-reduce/lib/observableValue"; +import { ObservableValue, collectAccessedValues, consumeLastAccessed } from "event-reduce/observableValue"; describe("ObservableValue", () => { let sut: ObservableValue; @@ -55,4 +55,4 @@ describe("collectAccessedValues", () => { expect(Array.from(result)).toContain(valueA); expect(Array.from(result)).toContain(valueB); }); -}); \ No newline at end of file +}); diff --git a/tests/Reduction.test.ts b/tests/Reduction.test.ts index 606085a..e051b3b 100644 --- a/tests/Reduction.test.ts +++ b/tests/Reduction.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { Atom, createModel, event, events, IReduction, reduce } from 'event-reduce/src/signals'; -import { CircularSubscriptionError } from "event-reduce/src/signals/reduction"; -import { InvalidStateError } from "event-reduce/src/signals/sources"; -import { CircularDependencyError, collectAccessedSources } from "event-reduce/src/signals/tracking"; -import { dispose } from "event-reduce/src/utils"; +import { Atom, createModel, event, events, IReduction, reduce } from 'event-reduce/signals'; +import { CircularSubscriptionError } from "event-reduce/signals/reduction"; +import { InvalidStateError } from "event-reduce/signals/sources"; +import { CircularDependencyError, collectAccessedSources } from "event-reduce/signals/tracking"; +import { dispose } from "event-reduce/utils"; import { MockObserver } from "./MockObserver"; describe("reduce", () => { @@ -199,4 +199,4 @@ describe("reduce", () => { expect((e as InvalidStateError).value).toBe(eventsValue); } }); -}); \ No newline at end of file +}); diff --git a/tests/Rendering.test.ts b/tests/Rendering.test.ts index 7906e41..24359ff 100644 --- a/tests/Rendering.test.ts +++ b/tests/Rendering.test.ts @@ -1,7 +1,7 @@ -import { act, renderHook } from "@testing-library/react-hooks"; +import { act, renderHook } from "@testing-library/react-hooks/dom"; import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { useReactive } from "event-reduce-react/src/signals"; -import { Atom } from "event-reduce/lib/signals"; +import { useReactive } from "event-reduce-react/signals"; +import { Atom } from "event-reduce/signals"; describe('useReactive', () => { let source: Atom; @@ -46,4 +46,4 @@ describe('useReactive', () => { expect(render).toHaveBeenCalledTimes(1); }); -}); \ No newline at end of file +}); diff --git a/tests/State.test.ts b/tests/State.test.ts index 747bc3b..44b2d9b 100644 --- a/tests/State.test.ts +++ b/tests/State.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { getState } from "event-reduce/src/signals"; +import { getState } from "event-reduce/signals"; import { TestModel } from "./State.test/TestModel"; describe("state", () => { @@ -19,4 +19,4 @@ describe("state", () => { mergedModel: { value: 'merged' }, })); }); -}); \ No newline at end of file +}); diff --git a/tests/State.test/BaseModel.ts b/tests/State.test/BaseModel.ts index 60ef60b..0b1bd82 100644 --- a/tests/State.test/BaseModel.ts +++ b/tests/State.test/BaseModel.ts @@ -1,4 +1,4 @@ -import { derived, model, reduce, reduced, state } from "event-reduce/src/signals"; +import { derived, model, reduce, reduced, state } from "event-reduce/signals"; import { ChildModel } from "./ChildModel"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -29,4 +29,4 @@ export class BaseModel { mergedModel = new ChildModel('merged'); ignoredValue = 'ignored'; -} \ No newline at end of file +} diff --git a/tests/State.test/ChildModel.ts b/tests/State.test/ChildModel.ts index d176f3d..5c544ab 100644 --- a/tests/State.test/ChildModel.ts +++ b/tests/State.test/ChildModel.ts @@ -1,4 +1,4 @@ -import { model, reduce, reduced } from "event-reduce/src/signals"; +import { model, reduce, reduced } from "event-reduce/signals"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -8,4 +8,4 @@ export class ChildModel { @reduced get value() { return reduce(this._initialValue).value; } -} \ No newline at end of file +} diff --git a/tests/State.test/TestModel.ts b/tests/State.test/TestModel.ts index c7ad734..288d8cb 100644 --- a/tests/State.test/TestModel.ts +++ b/tests/State.test/TestModel.ts @@ -1,4 +1,4 @@ -import { model, state } from "event-reduce/src/signals"; +import { model, state } from "event-reduce/signals"; import { BaseModel } from "./BaseModel"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -7,4 +7,4 @@ import { BaseModel } from "./BaseModel"; export class TestModel extends BaseModel { @state subClassProperty = 'subClass'; -} \ No newline at end of file +} diff --git a/tests/Subject.test.ts b/tests/Subject.test.ts index 43b86d7..c1c7103 100644 --- a/tests/Subject.test.ts +++ b/tests/Subject.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { Subject } from 'event-reduce'; +import { Subject } from 'event-reduce/subject'; describe("Subject", () => { let sut: Subject; diff --git a/tests/Testing.test.ts b/tests/Testing.test.ts index 81f4ddb..27da6d3 100644 --- a/tests/Testing.test.ts +++ b/tests/Testing.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" -import { derived, disposeModel, event, events, model, reduce, reduced } from "event-reduce/src/signals" -import { eventProxy, getTarget, modelProxy, Mutable, mutable } from "event-reduce/src/signals/testing" +import { derived, disposeModel, event, events, model, reduce, reduced } from "event-reduce/signals" +import { eventProxy, getTarget, modelProxy, Mutable, mutable } from "event-reduce/signals/testing" import { MockObserver } from "./MockObserver" describe("mutable", () => { @@ -126,4 +126,4 @@ describe("eventProxy", () => { expect(handler.sourceUpdated).toHaveBeenCalledWith(custom.foo, expect.objectContaining({ value: 'foo' })) }) -}) \ No newline at end of file +}) diff --git a/tests/setup.ts b/tests/setup.ts index 17bea92..23bfbd2 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,18 +1,3 @@ import { GlobalRegistrator } from "@happy-dom/global-registrator"; -import { cleanup } from "@testing-library/react"; -import { reactionQueue } from "event-reduce/lib/reactions"; -import { afterEach } from "bun:test"; -import { Action } from "event-reduce/lib/types"; -GlobalRegistrator.register(); - -reactionQueue.current = { - add(reaction: Action) { - reaction(); - return () => { } - } -} - -afterEach(() => { - cleanup(); -}); \ No newline at end of file +GlobalRegistrator.register(); \ No newline at end of file diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 8dd6696..6834659 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,12 +1,20 @@ { - "extends": "../tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "outDir": "./dist", - "rootDir": "." - }, - "include": [ - "./**/*.ts", - "./**/*.tsx" - ] -} \ No newline at end of file + "extends": "../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "./dist", + "rootDir": "." + }, + "references": [ + { + "path": "../packages/event-reduce" + }, + { + "path": "../packages/event-reduce-react" + } + ], + "include": [ + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 70da4ca..14498c5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,13 +1,13 @@ { - "compilerOptions": { - "target": "es2024", - "module": "commonjs", - "lib": ["ES2024", "ESNext.Collection", "ESNext.Iterator", "ESNext.Disposable", "dom"], + "compilerOptions": { + "target": "es2024", + "module": "commonjs", + "lib": ["ES2024", "ESNext.Collection", "ESNext.Iterator", "ESNext.Disposable", "dom"], "jsx": "react-jsx", "strict": true, "noImplicitOverride": true, - "inlineSourceMap": true, - "inlineSources": true, + "sourceMap": true, + "inlineSources": true, "skipLibCheck": true } } diff --git a/tsconfig.json b/tsconfig.json index 55375bc..4b12590 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ -{ - "extends": "./tsconfig.base.json", - "files": [], - "references": [ - { +{ + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "packages/event-reduce" }, { @@ -18,4 +18,4 @@ "path": "tests-legacy" } ] -} \ No newline at end of file +} From 70c013f21994973a213eb2d9358488ef94aebda1 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 28 Mar 2026 09:23:54 +1000 Subject: [PATCH 36/83] Removing bogus rendering test --- tests/Rendering.test.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/Rendering.test.ts b/tests/Rendering.test.ts index 24359ff..9ff0663 100644 --- a/tests/Rendering.test.ts +++ b/tests/Rendering.test.ts @@ -1,7 +1,7 @@ -import { act, renderHook } from "@testing-library/react-hooks/dom"; +import { act, renderHook } from "@testing-library/react-hooks/dom"; import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { useReactive } from "event-reduce-react/signals"; -import { Atom } from "event-reduce/signals"; +import { useReactive } from "event-reduce-react/signals"; +import { Atom } from "event-reduce/signals"; describe('useReactive', () => { let source: Atom; @@ -38,12 +38,4 @@ describe('useReactive', () => { test("returns updated result", () => expect(sut.result.current).toBe('new value')); }); - - test("when accessed observable value is changed during render, doesn't trigger an extra render", () => { - innerBehaviour = () => source.setValue('new value'); - render.mockClear(); - sut.rerender(); - - expect(render).toHaveBeenCalledTimes(1); - }); -}); +}); From 82509999b2ae6780b92dd299e7b5e7353f68723c Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 28 Mar 2026 09:24:11 +1000 Subject: [PATCH 37/83] Updating packages --- bun.lock | 60 ++++++++----------- packages/event-reduce-react/src/hooks.ts | 20 +++---- .../event-reduce-react/src/signals/hooks.ts | 12 ++-- packages/event-reduce/src/signals/events.ts | 8 +-- tests-legacy/tsconfig.json | 5 +- tests/Hooks.test.ts | 18 +++--- tests/Rendering.test.ts | 2 +- tests/package.json | 53 ++++++++-------- tests/tsconfig.json | 3 +- tsconfig.base.json | 15 ++--- 10 files changed, 91 insertions(+), 105 deletions(-) diff --git a/bun.lock b/bun.lock index 4bc0a61..bbb8cc7 100644 --- a/bun.lock +++ b/bun.lock @@ -52,19 +52,19 @@ "name": "event-reduce-tests", "version": "1.0.0", "dependencies": { - "event-reduce": "*", - "event-reduce-react": "*", + "event-reduce": "workspace:*", + "event-reduce-example": "workspace:*", + "event-reduce-react": "workspace:*", }, "devDependencies": { - "@happy-dom/global-registrator": "^20.6.2", - "@testing-library/react": "*", - "@testing-library/react-hooks": "*", - "@types/react": ">=16.8", - "@types/react-dom": ">=16.8", - "react": ">=16.8", - "react-dom": ">=16.8", - "react-test-renderer": ">=16.8", - "typescript": "~5", + "@happy-dom/global-registrator": "^20.8.9", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-test-renderer": "^19.2.4", + "typescript": "~6.0.2", }, }, "tests-legacy": { @@ -91,13 +91,11 @@ "@babel/runtime": ["@babel/runtime@7.18.3", "", { "dependencies": { "regenerator-runtime": "^0.13.4" } }, "sha512-38Y8f7YUhce/K7RMwTp7m0uCumpv9hZkitCbBClqQIow1qSbCvGkcegKOXpEWCQLfWmevgRiWokZ1GkpfhbZug=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.6.2", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.6.2" } }, "sha512-M1EnDvO29XUXwJ/H5AqulA3CEf5mDzXgvubW4U6F+5L1BOf7bdxTcxX74RlM78wVa4JriUmp8vKiG5qH4VUQ1g=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.8.9" } }, "sha512-DtZeRRHY9A/bisTJziUBBPrdnPui7+R185G/hzi6/Boymhqh7/wi53AY+IvQHS1+7OPaqfO/1XNpngNwthLz+A=="], "@testing-library/dom": ["@testing-library/dom@8.13.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^4.2.0", "aria-query": "^5.0.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.4.4", "pretty-format": "^27.0.2" } }, "sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ=="], - "@testing-library/react": ["@testing-library/react@13.3.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.5.0", "@types/react-dom": "^18.0.0" } }, "sha512-DB79aA426+deFgGSjnf5grczDPiL4taK3hFaa+M5q7q20Kcve9eQottOG5kZ74KEr55v0tU2CQormSSDK87zYQ=="], - - "@testing-library/react-hooks": ["@testing-library/react-hooks@8.0.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "react-error-boundary": "^3.1.0" } }, "sha512-uZqcgtcUUtw7Z9N32W13qQhVAD+Xki2hxbTR461MKax8T6Jr8nsUvZB+vcBTkzY2nFvsUet434CsgF0ncW2yFw=="], + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], "@types/aria-query": ["@types/aria-query@4.2.2", "", {}, "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig=="], @@ -105,13 +103,9 @@ "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], - "@types/prop-types": ["@types/prop-types@15.7.5", "", {}, "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w=="], - - "@types/react": ["@types/react@18.0.12", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-duF1OTASSBQtcigUvhuiTB1Ya3OvSy+xORCiEf20H0P0lzx+/KeVsA99U5UjLXSbyo1DRJDlLKqTeM1ngosqtg=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - "@types/react-dom": ["@types/react-dom@18.0.5", "", { "dependencies": { "@types/react": "*" } }, "sha512-OWPWTUrY/NIrjsAPkAk1wW9LZeIjSvkXRhclsFO8CZcZGCOg2G0YZy4ft+rOyYxy8B7ui5iZzi9OkDebZ7/QSA=="], - - "@types/scheduler": ["@types/scheduler@0.16.2", "", {}, "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.3", "", {}, "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA=="], @@ -133,7 +127,7 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "csstype": ["csstype@3.1.0", "", {}, "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "dom-accessibility-api": ["dom-accessibility-api@0.5.14", "", {}, "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg=="], @@ -151,35 +145,27 @@ "event-reduce-tests-legacy": ["event-reduce-tests-legacy@workspace:tests-legacy"], - "happy-dom": ["happy-dom@20.6.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Xk/Y0cuq9ngN/my8uvK4gKoyDl6sBKkIl8A/hJ0IabZVH7E5SJLHNE7uKRPVmSrQbhJaLIHTEcvTct4GgNtsRA=="], + "happy-dom": ["happy-dom@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lz-string": ["lz-string@1.4.4", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], - "react": ["react@18.1.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ=="], - - "react-dom": ["react-dom@18.1.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.22.0" } }, "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - "react-error-boundary": ["react-error-boundary@3.1.4", "", { "dependencies": { "@babel/runtime": "^7.12.5" } }, "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA=="], + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], - "react-is": ["react-is@18.1.0", "", {}, "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg=="], + "react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="], - "react-shallow-renderer": ["react-shallow-renderer@16.15.0", "", { "dependencies": { "object-assign": "^4.1.1", "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" } }, "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA=="], - - "react-test-renderer": ["react-test-renderer@18.1.0", "", { "dependencies": { "react-is": "^18.1.0", "react-shallow-renderer": "^16.15.0", "scheduler": "^0.22.0" } }, "sha512-OfuueprJFW7h69GN+kr4Ywin7stcuqaYAt1g7airM5cUgP0BoF5G5CXsPGmXeDeEkncb2fqYNECO4y18sSqphg=="], + "react-test-renderer": ["react-test-renderer@19.2.4", "", { "dependencies": { "react-is": "^19.2.4", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-Ttl5D7Rnmi6JGMUpri4UjB4BAN0FPs4yRDnu2XSsigCWOLm11o8GwRlVsh27ER+4WFqsGtrBuuv5zumUaRCmKw=="], "regenerator-runtime": ["regenerator-runtime@0.13.9", "", {}, "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="], - "scheduler": ["scheduler@0.22.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -197,6 +183,8 @@ "bun-types/@types/node": ["@types/node@17.0.40", "", {}, "sha512-UXdBxNGqTMtm7hCwh9HtncFVLrXoqA3oJW30j6XWp5BH/wu3mVeaxo7cq5benFdBw34HB3XDT2TRPI7rXZ+mDg=="], + "event-reduce-tests/typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index 46b0d4d..4d3ddc2 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -1,11 +1,11 @@ -import { asyncEvent, event } from "event-reduce/events"; -import { IObservableValue } from "event-reduce/observableValue"; -import { IReduction, reduce } from "event-reduce/reduction"; -import { derive } from "event-reduce/derivation"; -import { changeOwnedValue } from "event-reduce/cleanup"; -import { ObservableValue } from "event-reduce/observableValue"; -import { ValueOf } from "event-reduce/types"; -import { emptyArray } from "event-reduce/utils"; +import { asyncEvent, event } from "event-reduce/events"; +import { IObservableValue } from "event-reduce/observableValue"; +import { IReduction, reduce } from "event-reduce/reduction"; +import { derive } from "event-reduce/derivation"; +import { changeOwnedValue } from "event-reduce/cleanup"; +import { ObservableValue } from "event-reduce/observableValue"; +import { ValueOf } from "event-reduce/types"; +import { emptyArray } from "event-reduce/utils"; import { DependencyList, useMemo, useRef, useState } from "react"; /** @@ -44,7 +44,7 @@ export function useDerived(getValue: () => T, nameOrUnobservableDependencies? let derived = useState(() => derive(getValue, name))[0]; - useMemo(() => derived.update(getValue, 'render'), unobservableDependencies); + useMemo(() => derived.update(getValue, 'render'), unobservableDependencies!); return derived; } @@ -80,4 +80,4 @@ export function useObserved(value: T, name: string = '(anonymous observed val let observableValue = useModel(() => new ObservableValue(() => name, value)); observableValue.setValue(value); return observableValue; -} +} diff --git a/packages/event-reduce-react/src/signals/hooks.ts b/packages/event-reduce-react/src/signals/hooks.ts index e0f1be0..f71ae3b 100644 --- a/packages/event-reduce-react/src/signals/hooks.ts +++ b/packages/event-reduce-react/src/signals/hooks.ts @@ -1,8 +1,8 @@ -import { asyncEvent, Atom, derive, event, IDerivation, IReduction, reduce } from "event-reduce/signals" -import { isModel, Model } from "event-reduce/signals/models" -import { Reduction } from "event-reduce/signals/reduction" -import { trackManagedSourceCreation } from "event-reduce/signals/tracking" -import { dispose, emptyArray } from "event-reduce/utils" +import { asyncEvent, Atom, derive, event, IDerivation, IReduction, reduce } from "event-reduce/signals" +import { isModel, Model } from "event-reduce/signals/models" +import { Reduction } from "event-reduce/signals/reduction" +import { trackManagedSourceCreation } from "event-reduce/signals/tracking" +import { dispose, emptyArray } from "event-reduce/utils" import { DependencyList, useEffect, useMemo, useRef, useState } from "react" /** @@ -39,7 +39,7 @@ export function useDerived(getValue: () => T, nameOrUnobservableDependencies? let derived = useState(() => derive(getValue, name))[0] - useMemo(() => derived.update(getValue, 'render'), unobservableDependencies) + useMemo(() => derived.update(getValue, 'render'), unobservableDependencies!) return derived } diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts index 5f845a2..594e00d 100644 --- a/packages/event-reduce/src/signals/events.ts +++ b/packages/event-reduce/src/signals/events.ts @@ -96,18 +96,18 @@ export function asyncEvent(name: string = "(anony }) return promise - }, function asyncEventMembers(e: IEventFunction, [promise: PromiseLike, context: Context]>) { + }, function asyncEventMembers(e: IEventFunction, [promise: PromiseLike, context: CurrentContext]>) { let setBaseDisplayName = e.setDisplayName return { started: e.filter(v => 'promise' in v, `${e.displayName}.started`), resolved: e.filter(v => 'result' in v, `${e.displayName}.resolved`), rejected: e.filter(v => 'error' in v, `${e.displayName}.rejected`), - scope>(scope: Scope) { + scope>(scope: Scope): IAsyncEvent> { return bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, - (promise: PromiseLike, context: Scoped) => e(promise, { ...context, ...scope } as Context), + (promise: PromiseLike, context: Scoped) => e(promise, { ...context, ...scope } as CurrentContext), v => matchesScope(scope, v.context), - asyncEventMembers) + asyncEventMembers) as IAsyncEvent> }, setDisplayName(name: string) { setBaseDisplayName.call(e, name) diff --git a/tests-legacy/tsconfig.json b/tests-legacy/tsconfig.json index 948e451..f69ff6d 100644 --- a/tests-legacy/tsconfig.json +++ b/tests-legacy/tsconfig.json @@ -1,7 +1,6 @@ -{ - "extends": "../tsconfig.base.json", +{ + "extends": "../tsconfig.base.json", "compilerOptions": { - "baseUrl": ".", "outDir": "./dist", "rootDir": ".", "experimentalDecorators": true, diff --git a/tests/Hooks.test.ts b/tests/Hooks.test.ts index 13ca2ce..de36ebb 100644 --- a/tests/Hooks.test.ts +++ b/tests/Hooks.test.ts @@ -1,13 +1,13 @@ -import { renderHook, RenderHookResult } from "@testing-library/react-hooks/dom"; +import { renderHook, RenderHookResult } from "@testing-library/react"; import { beforeEach, describe, expect, test } from "bun:test"; -import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react/signals"; -import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce/signals"; -import { mutable } from "event-reduce/signals/testing"; -import { IDerivation } from "event-reduce/signals"; +import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react/signals"; +import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce/signals"; +import { mutable } from "event-reduce/signals/testing"; +import { IDerivation } from "event-reduce/signals"; import { MockObserver } from "./MockObserver"; describe("useEvent", () => { - let sut: ReturnType>>>; + let sut: RenderHookResult>, unknown>; let initialResult: IEvent beforeEach(() => { @@ -32,7 +32,7 @@ describe("useEvent", () => { }); describe("useAsyncEvent", () => { - let sut: ReturnType>>>; + let sut: RenderHookResult>, unknown>; let initialResult: ReturnType>; beforeEach(() => { @@ -61,7 +61,7 @@ describe("useAsyncEvent", () => { describe("useDerived", () => { @model class SourceModel { @reduced get value() { return reduce(1).value; } } let source: ReturnType>; - let sut: RenderHookResult> + let sut: RenderHookResult, unknown> let initialResult: IDerivation beforeEach(() => { @@ -87,7 +87,7 @@ describe("useDerived", () => { describe("useReduced", () => { let added: ReturnType>; - let sut: ReturnType>>>; + let sut: RenderHookResult>, unknown>; let initialResult: ReturnType>; beforeEach(() => { diff --git a/tests/Rendering.test.ts b/tests/Rendering.test.ts index 9ff0663..1feaafc 100644 --- a/tests/Rendering.test.ts +++ b/tests/Rendering.test.ts @@ -1,4 +1,4 @@ -import { act, renderHook } from "@testing-library/react-hooks/dom"; +import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; import { useReactive } from "event-reduce-react/signals"; import { Atom } from "event-reduce/signals"; diff --git a/tests/package.json b/tests/package.json index 150e1d8..699016b 100644 --- a/tests/package.json +++ b/tests/package.json @@ -1,28 +1,27 @@ -{ - "name": "event-reduce-tests", - "version": "1.0.0", - "license": "MIT", - "private": true, - "author": "Simon Oxtoby", - "homepage": "https://github.com/soxtoby/event-reduce", - "repository": { - "type": "git", - "url": "https://github.com/soxtoby/event-reduce.git" - }, - "dependencies": { - "event-reduce": "workspace:*", - "event-reduce-react": "workspace:*", - "event-reduce-example": "workspace:*" - }, - "devDependencies": { - "@happy-dom/global-registrator": "^20.6.2", - "@testing-library/react": "*", - "@testing-library/react-hooks": "*", - "@types/react": ">=16.8", - "@types/react-dom": ">=16.8", - "react": ">=16.8", - "react-dom": ">=16.8", - "react-test-renderer": ">=16.8", - "typescript": "~5" - } +{ + "name": "event-reduce-tests", + "version": "1.0.0", + "license": "MIT", + "private": true, + "author": "Simon Oxtoby", + "homepage": "https://github.com/soxtoby/event-reduce", + "repository": { + "type": "git", + "url": "https://github.com/soxtoby/event-reduce.git" + }, + "dependencies": { + "event-reduce": "workspace:*", + "event-reduce-react": "workspace:*", + "event-reduce-example": "workspace:*" + }, + "devDependencies": { + "@happy-dom/global-registrator": "^20.8.9", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-test-renderer": "^19.2.4", + "typescript": "~6.0.2" + } } \ No newline at end of file diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 6834659..9914ec4 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,7 +1,6 @@ -{ +{ "extends": "../tsconfig.base.json", "compilerOptions": { - "baseUrl": ".", "outDir": "./dist", "rootDir": "." }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 14498c5..ceb7807 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,13 +1,14 @@ { - "compilerOptions": { - "target": "es2024", - "module": "commonjs", - "lib": ["ES2024", "ESNext.Collection", "ESNext.Iterator", "ESNext.Disposable", "dom"], + "compilerOptions": { + "target": "es2024", + "module": "commonjs", + "lib": ["ES2024", "ESNext.Collection", "ESNext.Iterator", "ESNext.Disposable", "dom"], "jsx": "react-jsx", "strict": true, "noImplicitOverride": true, - "sourceMap": true, - "inlineSources": true, - "skipLibCheck": true + "sourceMap": true, + "inlineSources": true, + "skipLibCheck": true, + "types": ["bun"] } } From c55069dddf33ebacacbd659fae584ca60c1d8215 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 28 Mar 2026 10:03:31 +1000 Subject: [PATCH 38/83] Updating old observable tests --- packages/event-reduce/src/signals/sources.ts | 2 +- tests/Atom.test.ts | 54 ++++++++++++++ tests/Observable.test.ts | 75 -------------------- tests/ObservableValue.test.ts | 58 --------------- tests/Source.test.ts | 69 ++++++++++++++++++ 5 files changed, 124 insertions(+), 134 deletions(-) create mode 100644 tests/Atom.test.ts delete mode 100644 tests/Observable.test.ts delete mode 100644 tests/ObservableValue.test.ts create mode 100644 tests/Source.test.ts diff --git a/packages/event-reduce/src/signals/sources.ts b/packages/event-reduce/src/signals/sources.ts index 68d2639..36731d1 100644 --- a/packages/event-reduce/src/signals/sources.ts +++ b/packages/event-reduce/src/signals/sources.ts @@ -82,7 +82,7 @@ export abstract class Source implements ISource { } filterMap, Out>(this: S, transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`): ISource { - return bind(displayName, this, v => transform(v)!, value => value == undefined) + return bind(displayName, this, v => transform(v)!, value => value != undefined) } subscribe(observer: IObserver) { this._observers.add(observer) } diff --git a/tests/Atom.test.ts b/tests/Atom.test.ts new file mode 100644 index 0000000..80855b1 --- /dev/null +++ b/tests/Atom.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { Atom } from "event-reduce/signals"; +import { getUnderlyingSource } from "event-reduce/signals/tracking"; +import { MockObserver } from "./MockObserver"; + +describe("Atom", () => { + let sut: Atom; + let observer: MockObserver; + + beforeEach(() => { + sut = new Atom('sut', 'initial'); + observer = new MockObserver(); + sut.subscribe(observer); + }); + + describe("when value accessed", () => { + let result: string; + + beforeEach(() => { + result = sut.value; + }); + + test("returns provided value", () => expect(result).toBe('initial')); + + test("tracks the accessed source", () => expect(getUnderlyingSource(result)).toBe(sut)); + }); + + describe("when value changed", () => { + beforeEach(() => { + sut.setValue('different'); + }); + + test("updates the value", () => expect(sut.value).toBe('different')); + + test("notifies observers", () => expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'different')); + }); + + test("when value set to same value, observers are not notified", () => { + sut.setValue('initial'); + + expect(observer.sourceUpdated).not.toHaveBeenCalled(); + }); + + test("when custom equality treats values as equal, observers are not notified", () => { + let custom = new Atom('custom', 'Alpha', (left, right) => left?.toLowerCase() === right?.toLowerCase()); + let customObserver = new MockObserver(); + custom.subscribe(customObserver); + + custom.setValue('alpha'); + + expect(custom.value).toBe('Alpha'); + expect(customObserver.sourceUpdated).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/Observable.test.ts b/tests/Observable.test.ts deleted file mode 100644 index a65af7a..0000000 --- a/tests/Observable.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { IObservable, IObserver, merge } from 'event-reduce/observable'; -import { Subject } from 'event-reduce/subject'; -import { Unsubscribe } from 'event-reduce/types'; -import { ObservableOperation } from 'event-reduce/observable'; - -describe("ObservableOperation", () => { - describe("when subscribing", () => { - let unsubscribe: Mock; - let subscribe: Mock<(observer: IObserver) => Unsubscribe>; - let observer: Mock<(value: any) => void>; - let sut: ObservableOperation; - let result: () => void; - - beforeEach(() => { - unsubscribe = mock(); - subscribe = mock(() => unsubscribe); - observer = mock(); - sut = new ObservableOperation(() => 'test', [], subscribe); - result = sut.subscribe(observer); - }); - - test("subscribe function called with observer", () => { - expect(subscribe).toHaveBeenCalledWith(expect.objectContaining({ - getDisplayName: expect.any(Function), - next: expect.any(Function) - })); - }); - - test("when unsubscribed, unsubscribed from inner subscription", () => { - result(); - - expect(unsubscribe).toHaveBeenCalled(); - }); - }); - - test("filter", () => { - let source = observableOf(1, 2, 3, 2, 1); - let result = source.filter(v => v > 1); - expect(values(result)).toEqual([2, 3, 2]); - }); - - test("map", () => { - let source = observableOf(1, 2, 3); - let result = source.map(v => v * 2); - expect(values(result)).toEqual([2, 4, 6]); - }); - - test("merge", () => { - let source1 = new Subject(() => 'source 1'); - let source2 = new Subject(() => 'source 2'); - let result = merge([source1, source2]); - let vals = [] as number[]; - result.subscribe(v => vals.push(v)); - - source1.next(1); - source2.next(2); - source1.next(3); - - expect(vals).toEqual([1, 2, 3]); - }); - - function observableOf(...args: T[]) { - return new ObservableOperation(() => 'source', [], observer => { - args.forEach(a => observer.next(a)); - return () => { } - }); - } - - function values(observable: IObservable) { - let vals = [] as T[]; - observable.subscribe(value => vals.push(value)); - return vals; - } -}); diff --git a/tests/ObservableValue.test.ts b/tests/ObservableValue.test.ts deleted file mode 100644 index aa05ea5..0000000 --- a/tests/ObservableValue.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { ObservableValue, collectAccessedValues, consumeLastAccessed } from "event-reduce/observableValue"; - -describe("ObservableValue", () => { - let sut: ObservableValue; - let observer: Mock<(value: string) => void>; - - beforeEach(() => { - sut = new ObservableValue(() => 'sut', 'initial'); - observer = mock(); - sut.subscribe(observer); - }); - - describe("when value accessed", () => { - let result: string; - - beforeEach(() => { - result = sut.value; - }); - - test("returns provided value", () => expect(result).toBe('initial')); - - test("is last accessed value", () => expect(consumeLastAccessed()!).toBe(sut)); - }); - - describe("when value changed", () => { - beforeEach(() => { - sut.setValue('different'); - }); - - test("value updated", () => expect(sut.value).toBe('different')); - - test("observers notified", () => expect(observer).toHaveBeenCalled()); - }); - - test("when value set to same value, observers not notified", () => { - sut.setValue('initial'); - - expect(observer).not.toHaveBeenCalled(); - }); -}); - -describe("collectAccessedValues", () => { - let valueA: ObservableValue; - let valueB: ObservableValue; - - beforeEach(() => { - valueA = new ObservableValue(() => 'a', 'a'); - valueB = new ObservableValue(() => 'b', 'b'); - }); - - test("when multiple values accessed, all accessed values returned", () => { - let result = collectAccessedValues(() => valueA.value + valueB.value); - - expect(Array.from(result)).toContain(valueA); - expect(Array.from(result)).toContain(valueB); - }); -}); diff --git a/tests/Source.test.ts b/tests/Source.test.ts new file mode 100644 index 0000000..6a5562a --- /dev/null +++ b/tests/Source.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { Atom, type ISource } from "event-reduce/signals"; +import { MockObserver } from "./MockObserver"; + +describe("Source", () => { + let sut: Atom; + let observer: MockObserver; + + beforeEach(() => { + sut = new Atom('sut', 'initial'); + observer = new MockObserver(); + sut.subscribe(observer); + }); + + test("when updated, notifies subscribed observers", () => { + sut.setValue('updated'); + + expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'updated'); + }); + + test("when unsubscribed, stops notifying observer", () => { + sut.unsubscribe(observer); + + sut.setValue('updated'); + + expect(observer.sourceUpdated).not.toHaveBeenCalled(); + }); + + test("map", () => { + let mapped = sut.map(value => value.toUpperCase()); + let observed = values(mapped); + + sut.setValue('a'); + sut.setValue('b'); + + expect(observed).toEqual(['A', 'B']); + }); + + test("filter", () => { + let filtered = sut.filter(value => value.length > 1); + let observed = values(filtered); + + sut.setValue('a'); + sut.setValue('bb'); + sut.setValue('ccc'); + + expect(observed).toEqual(['bb', 'ccc']); + }); + + test("filterMap", () => { + let filtered = sut.filterMap(value => value.length > 1 ? value.length : undefined); + let observed = values(filtered); + + sut.setValue('a'); + sut.setValue('bb'); + sut.setValue('cccc'); + + expect(observed).toEqual([2, 4]); + }); + + function values(source: ISource) { + let observed: T[] = []; + source.subscribe({ + displayName: 'value collector', + sourceUpdated: (_, value) => observed.push(value) + }); + return observed; + } +}); \ No newline at end of file From f3ada14142658385036c62f1a3b8ad5d1f127089 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 28 Mar 2026 10:13:52 +1000 Subject: [PATCH 39/83] Replacing old implementation with new signals-based implementation --- bun.lock | 1 - example/Counter.tsx | 6 +- example/CounterList.tsx | 4 +- example/index.tsx | 4 +- packages/event-reduce-react/package.json | 77 +-- packages/event-reduce-react/src/hooks.ts | 86 +-- packages/event-reduce-react/src/index.ts | 7 +- packages/event-reduce-react/src/rendering.ts | 182 +++--- .../event-reduce-react/src/signals/hooks.ts | 85 --- .../event-reduce-react/src/signals/index.ts | 2 - .../src/signals/rendering.ts | 121 ---- packages/event-reduce/package.json | 60 +- .../event-reduce/src/{signals => }/atom.ts | 2 +- packages/event-reduce/src/cleanup.ts | 76 --- packages/event-reduce/src/derivation.ts | 246 +++----- packages/event-reduce/src/devtools.ts | 90 +-- .../event-reduce/src/{signals => }/effect.ts | 4 +- packages/event-reduce/src/events.ts | 350 +++++------ packages/event-reduce/src/index.ts | 22 +- packages/event-reduce/src/logging.ts | 127 ++-- packages/event-reduce/src/models.ts | 588 ++++++++++-------- packages/event-reduce/src/observable.ts | 151 ----- packages/event-reduce/src/observableValue.ts | 207 ------ packages/event-reduce/src/reactions.ts | 23 - packages/event-reduce/src/reduction.ts | 218 ++++--- .../event-reduce/src/signals/derivation.ts | 119 ---- packages/event-reduce/src/signals/devtools.ts | 159 ----- packages/event-reduce/src/signals/events.ts | 180 ------ packages/event-reduce/src/signals/index.ts | 10 - packages/event-reduce/src/signals/logging.ts | 69 -- packages/event-reduce/src/signals/models.ts | 480 -------------- .../event-reduce/src/signals/reduction.ts | 150 ----- packages/event-reduce/src/signals/state.ts | 11 - packages/event-reduce/src/signals/testing.ts | 142 ----- .../event-reduce/src/{signals => }/sources.ts | 2 +- packages/event-reduce/src/state.ts | 134 +--- packages/event-reduce/src/subject.ts | 13 - packages/event-reduce/src/testing.ts | 120 ++-- .../src/{signals => }/tracking.ts | 2 +- tests-legacy/LegacyModel.test.ts | 6 +- tests-legacy/LegacyState.test.ts | 4 +- tests/Atom.test.ts | 4 +- tests/Cleanup.test.ts | 8 +- tests/Derivation.test.ts | 8 +- tests/Event.test.ts | 6 +- tests/Example.test.tsx | 4 +- tests/Hooks.test.ts | 8 +- tests/MockObserver.ts | 4 +- tests/Model.test.ts | 8 +- tests/Model.test/ExtendedModel.ts | 4 +- tests/Model.test/TestModel.ts | 4 +- tests/Reduction.test.ts | 12 +- tests/Rendering.test.ts | 4 +- tests/Source.test.ts | 2 +- tests/State.test.ts | 4 +- tests/State.test/BaseModel.ts | 4 +- tests/State.test/ChildModel.ts | 4 +- tests/State.test/TestModel.ts | 4 +- tests/Subject.test.ts | 44 -- tests/Testing.test.ts | 6 +- tests/setup.ts | 4 +- tsconfig.base.json | 3 +- 62 files changed, 1126 insertions(+), 3363 deletions(-) delete mode 100644 packages/event-reduce-react/src/signals/hooks.ts delete mode 100644 packages/event-reduce-react/src/signals/index.ts delete mode 100644 packages/event-reduce-react/src/signals/rendering.ts rename packages/event-reduce/src/{signals => }/atom.ts (93%) delete mode 100644 packages/event-reduce/src/cleanup.ts rename packages/event-reduce/src/{signals => }/effect.ts (92%) delete mode 100644 packages/event-reduce/src/observable.ts delete mode 100644 packages/event-reduce/src/observableValue.ts delete mode 100644 packages/event-reduce/src/reactions.ts delete mode 100644 packages/event-reduce/src/signals/derivation.ts delete mode 100644 packages/event-reduce/src/signals/devtools.ts delete mode 100644 packages/event-reduce/src/signals/events.ts delete mode 100644 packages/event-reduce/src/signals/index.ts delete mode 100644 packages/event-reduce/src/signals/logging.ts delete mode 100644 packages/event-reduce/src/signals/models.ts delete mode 100644 packages/event-reduce/src/signals/reduction.ts delete mode 100644 packages/event-reduce/src/signals/state.ts delete mode 100644 packages/event-reduce/src/signals/testing.ts rename packages/event-reduce/src/{signals => }/sources.ts (96%) delete mode 100644 packages/event-reduce/src/subject.ts rename packages/event-reduce/src/{signals => }/tracking.ts (96%) delete mode 100644 tests/Subject.test.ts diff --git a/bun.lock b/bun.lock index bbb8cc7..59c0a77 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,6 @@ "name": "event-reduce", "version": "0.7.5", "devDependencies": { - "@types/node": "*", "typescript": "~5", }, }, diff --git a/example/Counter.tsx b/example/Counter.tsx index 9e6031a..92d9246 100644 --- a/example/Counter.tsx +++ b/example/Counter.tsx @@ -1,5 +1,5 @@ -import { reactive } from "event-reduce-react/signals"; -import { asyncEvent, derived, events, model, reduce, reduced } from "event-reduce/signals"; +import { reactive } from "event-reduce-react"; +import { asyncEvent, derived, events, model, reduce, reduced } from "event-reduce"; import { CounterListEvents } from "./CounterList"; @events @@ -66,4 +66,4 @@ export const Counter = reactive(function Counter({ model }: { model: CounterMode function onFetch() { events.valueFetched(Promise.resolve(100)); } -}); +}); diff --git a/example/CounterList.tsx b/example/CounterList.tsx index 6a43725..4541357 100644 --- a/example/CounterList.tsx +++ b/example/CounterList.tsx @@ -1,5 +1,5 @@ -import { reactive } from "event-reduce-react/signals"; -import { event, events, model, reduce, reduced } from "event-reduce/signals"; +import { reactive } from "event-reduce-react"; +import { event, events, model, reduce, reduced } from "event-reduce"; import { Counter, CounterEvents, CounterModel } from "./Counter"; @events diff --git a/example/index.tsx b/example/index.tsx index 2b60cea..e2cdecd 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -1,4 +1,4 @@ -import { enableDevTools, enableLogging } from "event-reduce/signals" +import { enableDevTools, enableLogging } from "event-reduce" import { StrictMode } from "react" import * as ReactDOM from "react-dom/client" import { CounterList, CounterListEvents, CounterListModel } from "./CounterList" @@ -13,4 +13,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render( -) +) diff --git a/packages/event-reduce-react/package.json b/packages/event-reduce-react/package.json index 86a7f01..102cc48 100644 --- a/packages/event-reduce-react/package.json +++ b/packages/event-reduce-react/package.json @@ -1,15 +1,15 @@ -{ - "name": "event-reduce-react", - "description": "React integration for event-reduce: state management based on reducing observable events into state", - "version": "0.7.5", - "author": "Simon Oxtoby", - "homepage": "https://github.com/soxtoby/event-reduce", - "repository": { - "type": "git", - "url": "https://github.com/soxtoby/event-reduce.git", - "directory": "packages/event-reduce-react" - }, - "license": "MIT", +{ + "name": "event-reduce-react", + "description": "React integration for event-reduce: state management based on reducing observable events into state", + "version": "0.7.5", + "author": "Simon Oxtoby", + "homepage": "https://github.com/soxtoby/event-reduce", + "repository": { + "type": "git", + "url": "https://github.com/soxtoby/event-reduce.git", + "directory": "packages/event-reduce-react" + }, + "license": "MIT", "main": "./lib/index.js", "types": "./lib/index.d.ts", "exports": { @@ -18,16 +18,6 @@ "bun": "./src/index.ts", "default": "./lib/index.js" }, - "./signals": { - "types": "./lib/signals/index.d.ts", - "bun": "./src/signals/index.ts", - "default": "./lib/signals/index.js" - }, - "./signals/*": { - "types": "./lib/signals/*.d.ts", - "bun": "./src/signals/*.ts", - "default": "./lib/signals/*.js" - }, "./*": { "types": "./lib/*.d.ts", "bun": "./src/*.ts", @@ -35,36 +25,23 @@ }, "./package.json": "./package.json" }, - "typesVersions": { - "*": { - "signals": [ - "lib/signals/index.d.ts" - ], - "signals/*": [ - "lib/signals/*" - ], - "*": [ - "lib/*" - ] - } + "scripts": { + "prepack": "cp ../../LICENSE ." }, - "scripts": { - "prepack": "cp ../../LICENSE ." - }, - "files": [ + "files": [ "lib", "src" ], - "dependencies": { - "event-reduce": "workspace:^", - "use-sync-external-store": "*" - }, - "peerDependencies": { - "react": ">=16.8" - }, - "devDependencies": { - "@types/react": ">=16.8", - "@types/use-sync-external-store": "*", - "typescript": "~5" - } + "dependencies": { + "event-reduce": "workspace:^", + "use-sync-external-store": "*" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "devDependencies": { + "@types/react": ">=16.8", + "@types/use-sync-external-store": "*", + "typescript": "~5" + } } diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index 4d3ddc2..bdce44c 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -1,83 +1,85 @@ -import { asyncEvent, event } from "event-reduce/events"; -import { IObservableValue } from "event-reduce/observableValue"; -import { IReduction, reduce } from "event-reduce/reduction"; -import { derive } from "event-reduce/derivation"; -import { changeOwnedValue } from "event-reduce/cleanup"; -import { ObservableValue } from "event-reduce/observableValue"; -import { ValueOf } from "event-reduce/types"; -import { emptyArray } from "event-reduce/utils"; -import { DependencyList, useMemo, useRef, useState } from "react"; +import { asyncEvent, Atom, derive, event, IDerivation, IReduction, reduce } from "event-reduce" +import { isModel, Model } from "event-reduce/models" +import { Reduction } from "event-reduce/reduction" +import { trackManagedSourceCreation } from "event-reduce/tracking" +import { dispose, emptyArray } from "event-reduce/utils" +import { DependencyList, useEffect, useMemo, useRef, useState } from "react" /** * Creates a model that persists across renders of the component. * @param unobservableDependencies - A list of dependencies that are not observable. The model will be re-created when any of these change. */ export function useModel(createModel: () => T, unobservableDependencies?: DependencyList) { - let modelOwner = useState(() => ({}))[0]; // Effectively makes the component the owner of the model for cleanup purposes - return useMemo(() => { - let model = createModel(); - changeOwnedValue(modelOwner, undefined, model); - return model; - }, unobservableDependencies ?? emptyArray); + let model = useMemo(createModel, unobservableDependencies ?? emptyArray) as Model + if (!isModel(model)) + throw new InvalidModelError() + useEffect(() => () => model[dispose]?.(), unobservableDependencies ?? emptyArray) + return model } export function useEvent(name?: string) { - return useState(() => event(name))[0]; + return useState(() => event(name))[0] } export function useAsyncEvent(name?: string) { - return useState(() => asyncEvent(name))[0]; + return useState(() => asyncEvent(name))[0] } /** * Creates a derived value that persists across renders of the component. * @param unobservableDependencies - A list of dependencies that are not observable. The derived value will be updated when any of these change. - * If not specified, the derived value will be updated every render, but will still only *trigger* a re-render inside a reactive component if the derived value changes. */ -export function useDerived(getValue: () => T, name?: string): IObservableValue; -export function useDerived(getValue: () => T, unobservableDependencies?: DependencyList, name?: string): IObservableValue; -export function useDerived(getValue: () => T, nameOrUnobservableDependencies?: string | DependencyList, name?: string): IObservableValue { - let unobservableDependencies: DependencyList | undefined; - [name, unobservableDependencies] = typeof nameOrUnobservableDependencies === 'string' +export function useDerived(getValue: () => T, name?: string): IDerivation +export function useDerived(getValue: () => T, unobservableDependencies?: DependencyList, name?: string): IDerivation +export function useDerived(getValue: () => T, nameOrUnobservableDependencies?: string | DependencyList, name?: string): IDerivation { + let unobservableDependencies: DependencyList | undefined + [name, unobservableDependencies] = typeof nameOrUnobservableDependencies == 'string' ? [nameOrUnobservableDependencies, undefined] - : [name, nameOrUnobservableDependencies]; + : [name, nameOrUnobservableDependencies] - let derived = useState(() => derive(getValue, name))[0]; + let derived = useState(() => derive(getValue, name))[0] - useMemo(() => derived.update(getValue, 'render'), unobservableDependencies!); + useMemo(() => derived.update(getValue, 'render'), unobservableDependencies!) - return derived; + return derived } export function useReduced(initial: T, name?: string): IReduction { - return useState(() => reduce(initial, name))[0]; + let sourceCreation = trackManagedSourceCreation() // It's fine for this reduction to be loose, because it will be disposed on unmount + let reduction = useState(() => reduce(initial, name))[0] + sourceCreation[dispose]() + useEffect(() => () => (reduction as Reduction)[dispose]?.(), []) + return reduction } export function useObservedProps(values: T, name: string = '(anonymous observed values)') { - let observableValues = useModel(() => ({} as Record>>)); - let nameBase = (name || '') + '.'; + let observableValues = useState(() => ({} as { [K in keyof T]?: Atom }))[0] + let nameBase = (name || '') + '.' // Update any values that are already being observed - for (let [key, observableValue] of Object.entries(observableValues) as [keyof T, ObservableValue>][]) - observableValue.setValue(values[key as keyof T]); + for (let [key, atom] of Object.entries(observableValues) as [keyof T, Atom][]) + atom.setValue(values[key]) - let latestValues = useRef(values); - latestValues.current = values; + let latestValues = useRef(values) + latestValues.current = values // Create observable values as properties are accessed return new Proxy({} as T, { get(_, key) { return (observableValues[key as keyof T] - ??= new ObservableValue( - () => nameBase + String(key), + ??= new Atom(nameBase + String(key), (latestValues.current)[key as keyof T])) - .value; + .value } - }); + }) } -export function useObserved(value: T, name: string = '(anonymous observed value)') { - let observableValue = useModel(() => new ObservableValue(() => name, value)); - observableValue.setValue(value); - return observableValue; +export function useAtom(value: T, name: string = '(anonymous observed value)') { + let observableValue = useState(() => new Atom(name, value))[0] + observableValue.setValue(value) + return observableValue +} + +class InvalidModelError extends Error { + constructor() { super("Invalid model created in useModel. A model must be an instance of a class decorated with @model.") } } diff --git a/packages/event-reduce-react/src/index.ts b/packages/event-reduce-react/src/index.ts index fcfae24..4798efb 100644 --- a/packages/event-reduce-react/src/index.ts +++ b/packages/event-reduce-react/src/index.ts @@ -1,7 +1,2 @@ -import { cleanupOptions } from "event-reduce/cleanup" - export * from "./hooks"; -export * from "./rendering"; - -let baseSkip = cleanupOptions.skipCleanup; -cleanupOptions.skipCleanup = (value: object) => baseSkip(value) || '$$typeof' in value; // Skip react elements +export * from "./rendering"; \ No newline at end of file diff --git a/packages/event-reduce-react/src/rendering.ts b/packages/event-reduce-react/src/rendering.ts index a9893c2..03c7166 100644 --- a/packages/event-reduce-react/src/rendering.ts +++ b/packages/event-reduce-react/src/rendering.ts @@ -1,117 +1,121 @@ -import { Observe } from "event-reduce/observable"; -import { IObservableValue } from "event-reduce/observableValue"; -import { Derivation } from "event-reduce/derivation"; -import { LogValue } from "event-reduce/logging"; -import { ObservableValue } from "event-reduce/observableValue"; -import { reactionQueue } from "event-reduce/reactions"; -import { constant, nameOfFunction, unsubscribeAll } from "event-reduce/utils"; -import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useCallback, useEffect, useState } from "react"; -import { useSyncExternalStore } from "use-sync-external-store/shim"; +import { Effect, log, startLogging } from "event-reduce" +import { LogValue } from "event-reduce/logging" +import { ISource } from "event-reduce/sources" +import { beginTransition } from "event-reduce/tracking" +import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/utils" +import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useState } from "react" +import { useSyncExternalStore } from "use-sync-external-store/shim" export function Reactive(props: { name?: string; children: () => ReactNode; }): ReactElement { - return useReactive(props.name || 'Derived', () => createElement(Fragment, { children: props.children() })); + return useReactive(props.name || 'Derived', () => createElement(Fragment, { children: props.children() })) } export function reactive(component: (...args: Args) => ReactElement | null) { - let componentName = nameOfFunction(component) || 'ReactiveComponent'; + let componentName = nameOfFunction(component) || 'ReactiveComponent' - const reactiveComponentName = `reactive(${componentName})`; + const reactiveComponentName = `reactive(${componentName})` return { [reactiveComponentName]: (...args: Args) => useReactive(componentName, () => component(...args)) - }[reactiveComponentName]; + }[reactiveComponentName] } -export function useReactive(deriveValue: () => T): T; -export function useReactive(name: string, deriveValue: () => T): T; +export function useReactive(deriveValue: () => T): T +export function useReactive(name: string, deriveValue: () => T): T export function useReactive(nameOrDeriveValue: string | (() => T), maybeDeriveValue?: () => T): T { let [name, deriveValue] = typeof nameOrDeriveValue == 'string' ? [nameOrDeriveValue, maybeDeriveValue!] - : ['ReactiveValue', nameOrDeriveValue]; + : ['ReactiveValue', nameOrDeriveValue] - let derivation = useSyncDerivation(name); - return useRenderValue(derivation, deriveValue); + let renderEffect = useState(() => new RenderEffect(name))[0] + useSyncExternalStore(renderEffect.subscribe, renderEffect.triggerSnapshot) + return renderEffect.run(deriveValue) } -function useSyncDerivation(name: string) { - // Using a bogus derive function because we'll provide a new one every render - let renderedValue = useState(() => new RenderedValue(constant(name), constant(undefined!)))[0]; - - useEffect(renderedValue.connect); - - useSyncExternalStore(renderedValue.onInvalidated, renderedValue.getInvalidatedState); - - return renderedValue; -} - -function useRenderValue(derivation: RenderedValue, deriveValue: () => T) { - useCallback(function update() { derivation.update(deriveValue, 'render'); }, [deriveValue])(); // need to use a hook to be considered a hook in devtools - return derivation.value; -} - -class RenderedValue extends Derivation { - private readonly _invalidatedState = new ObservableValue(() => `${this.displayName}.render`, { invalidatedBy: "(nothing)" }); - - onInvalidated = (observe: Observe<{ invalidatedBy: string }>) => this._invalidatedState.subscribe(observe); - - getInvalidatedState = () => this._invalidatedState.value; - - connect = () => { - let subscriptions = this.subscribeToSources(); - return unsubscribeAll.bind(null, subscriptions); +class RenderEffect extends Effect { + private _observers = new Set<() => void>() + private _trigger: ISource | null = null + private _triggerState = { triggeredBy: "(nothing)" } + private _previousValue?: T + + constructor(displayName: string) { + super(displayName, constant(undefined!), (source) => { + log('⚛️🚩 (render invalidated)', this.displayName, [source]) + this._trigger = source + this._triggerState = { triggeredBy: source.path } + for (let subscriber of this._observers) + subscriber() + }) + this.attach() } - protected override onSourcesUpdated(): void { - // Do nothing - will subscribe later + subscribe = (observer: () => void) => { + this._observers.add(observer) + return () => this._observers.delete(observer) } - protected override onSourceValueChanged(source: IObservableValue) { - if (this._state == 'indeterminate') { - this._state = 'uninitialized'; - reactionQueue.current.add(() => this._invalidatedState.setValue({ invalidatedBy: source.displayName ?? "(unknown)" })); + triggerSnapshot = () => this._triggerState + + override run(deriveValue: () => T) { + let previousValue = this._previousValue + let nextValue!: T + let trigger = this._trigger + this._trigger = null + + let transition = beginTransition(this, true) + let logging = startLogging('⚛️ (render)', this.displayName, emptyArray, () => ({ + Trigger: trigger?.path ?? 'render', + Previous: loggedReactValue(previousValue), + Current: loggedReactValue(nextValue), + })) + + try { + nextValue = super.run(deriveValue) + if (process.env.NODE_ENV !== 'production') + this._previousValue = nextValue + return nextValue + } finally { + logging[dispose]() + transition[dispose]() } } +} - protected override getInvalidatedMessage() { return '⚛️🚩 (render invalidated)'; } - - protected override getUpdateMessage() { return '⚛️ (render)'; } - - protected override loggedValue(value: T) { - if (process.env.NODE_ENV !== 'production' && isValidElement(value)) { - let xmlDoc = document.implementation.createDocument(null, null); - return new LogValue([ - xmlTree(value), - { ['React element']: value } - ]); - - function xmlTree(node: T): Node { - if (isValidElement(node)) { - let type = reactElementName(node.type) - .split('(').at(-1)!.split(')')[0] // Unwrap HOC names - .replace(/^[0-9-]/, '_$&') // Escape leading number or dash - .replace(/[^a-zA-Z0-9:_.-]/g, '_'); // Escape rest of element name - let el = xmlDoc.createElement(type || ':unknown:'); - for (let child of Children.toArray((node.props as any).children)) - el.appendChild(xmlTree(child)); - return el; - } - return document.createTextNode(String(node)); +function loggedReactValue(value: unknown) { + if (process.env.NODE_ENV !== 'production' && isValidElement(value)) { + let xmlDoc = document.implementation.createDocument(null, null) + return new LogValue([ + xmlTree(value), + { ['React element']: value } + ]) + + function xmlTree(node: unknown): Node { + if (isValidElement(node)) { + let type = reactElementName(node.type) + .split('(').at(-1)!.split(')')[0] // Unwrap HOC names + .replace(/^[0-9-]/, '_$&') // Escape leading number or dash + .replace(/[^a-zA-Z0-9:_.-]/g, '_'); // Escape rest of element name + let el = xmlDoc.createElement(type || ':unknown:') + for (let child of Children.toArray((node.props as any).children)) + el.appendChild(xmlTree(child)) + return el } + return document.createTextNode(String(node)) + } - function reactElementName(elementType: any) { - switch (typeof elementType) { - case 'string': - return elementType; - case 'function': - return nameOfFunction(elementType); - case 'symbol': - return String(elementType); - case 'object': - if ('_context' in elementType) return 'Context.Provider'; - if (elementType.displayName) return elementType.displayName; - if (elementType.type?.displayName) return elementType.type.displayName; - } + function reactElementName(elementType: any) { + switch (typeof elementType) { + case 'string': + return elementType + case 'function': + return nameOfFunction(elementType) + case 'symbol': + return String(elementType) + case 'object': + if ('_context' in elementType) return 'Context.Provider' + if (elementType.displayName) return elementType.displayName + if (elementType.type?.displayName) return elementType.type.displayName } } - return value; } -} + return value +} diff --git a/packages/event-reduce-react/src/signals/hooks.ts b/packages/event-reduce-react/src/signals/hooks.ts deleted file mode 100644 index f71ae3b..0000000 --- a/packages/event-reduce-react/src/signals/hooks.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { asyncEvent, Atom, derive, event, IDerivation, IReduction, reduce } from "event-reduce/signals" -import { isModel, Model } from "event-reduce/signals/models" -import { Reduction } from "event-reduce/signals/reduction" -import { trackManagedSourceCreation } from "event-reduce/signals/tracking" -import { dispose, emptyArray } from "event-reduce/utils" -import { DependencyList, useEffect, useMemo, useRef, useState } from "react" - -/** - * Creates a model that persists across renders of the component. - * @param unobservableDependencies - A list of dependencies that are not observable. The model will be re-created when any of these change. -*/ -export function useModel(createModel: () => T, unobservableDependencies?: DependencyList) { - let model = useMemo(createModel, unobservableDependencies ?? emptyArray) as Model - if (!isModel(model)) - throw new InvalidModelError() - useEffect(() => () => model[dispose]?.(), unobservableDependencies ?? emptyArray) - return model -} - -export function useEvent(name?: string) { - return useState(() => event(name))[0] -} - -export function useAsyncEvent(name?: string) { - return useState(() => asyncEvent(name))[0] -} - -/** - * Creates a derived value that persists across renders of the component. - * @param unobservableDependencies - A list of dependencies that are not observable. The derived value will be updated when any of these change. - */ -export function useDerived(getValue: () => T, name?: string): IDerivation -export function useDerived(getValue: () => T, unobservableDependencies?: DependencyList, name?: string): IDerivation -export function useDerived(getValue: () => T, nameOrUnobservableDependencies?: string | DependencyList, name?: string): IDerivation { - let unobservableDependencies: DependencyList | undefined - [name, unobservableDependencies] = typeof nameOrUnobservableDependencies == 'string' - ? [nameOrUnobservableDependencies, undefined] - : [name, nameOrUnobservableDependencies] - - let derived = useState(() => derive(getValue, name))[0] - - useMemo(() => derived.update(getValue, 'render'), unobservableDependencies!) - - return derived -} - -export function useReduced(initial: T, name?: string): IReduction { - let sourceCreation = trackManagedSourceCreation() // It's fine for this reduction to be loose, because it will be disposed on unmount - let reduction = useState(() => reduce(initial, name))[0] - sourceCreation[dispose]() - useEffect(() => () => (reduction as Reduction)[dispose]?.(), []) - return reduction -} - -export function useObservedProps(values: T, name: string = '(anonymous observed values)') { - let observableValues = useState(() => ({} as { [K in keyof T]?: Atom }))[0] - let nameBase = (name || '') + '.' - - // Update any values that are already being observed - for (let [key, atom] of Object.entries(observableValues) as [keyof T, Atom][]) - atom.setValue(values[key]) - - let latestValues = useRef(values) - latestValues.current = values - - // Create observable values as properties are accessed - return new Proxy({} as T, { - get(_, key) { - return (observableValues[key as keyof T] - ??= new Atom(nameBase + String(key), - (latestValues.current)[key as keyof T])) - .value - } - }) -} - -export function useAtom(value: T, name: string = '(anonymous observed value)') { - let observableValue = useState(() => new Atom(name, value))[0] - observableValue.setValue(value) - return observableValue -} - -class InvalidModelError extends Error { - constructor() { super("Invalid model created in useModel. A model must be an instance of a class decorated with @model.") } -} diff --git a/packages/event-reduce-react/src/signals/index.ts b/packages/event-reduce-react/src/signals/index.ts deleted file mode 100644 index 4798efb..0000000 --- a/packages/event-reduce-react/src/signals/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./hooks"; -export * from "./rendering"; \ No newline at end of file diff --git a/packages/event-reduce-react/src/signals/rendering.ts b/packages/event-reduce-react/src/signals/rendering.ts deleted file mode 100644 index ce9093c..0000000 --- a/packages/event-reduce-react/src/signals/rendering.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { LogValue } from "event-reduce/logging" -import { Effect, log, startLogging } from "event-reduce/signals" -import { ISource } from "event-reduce/signals/sources" -import { beginTransition } from "event-reduce/signals/tracking" -import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/utils" -import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useState } from "react" -import { useSyncExternalStore } from "use-sync-external-store/shim" - -export function Reactive(props: { name?: string; children: () => ReactNode; }): ReactElement { - return useReactive(props.name || 'Derived', () => createElement(Fragment, { children: props.children() })) -} - -export function reactive(component: (...args: Args) => ReactElement | null) { - let componentName = nameOfFunction(component) || 'ReactiveComponent' - - const reactiveComponentName = `reactive(${componentName})` - return { - [reactiveComponentName]: (...args: Args) => useReactive(componentName, () => component(...args)) - }[reactiveComponentName] -} - -export function useReactive(deriveValue: () => T): T -export function useReactive(name: string, deriveValue: () => T): T -export function useReactive(nameOrDeriveValue: string | (() => T), maybeDeriveValue?: () => T): T { - let [name, deriveValue] = typeof nameOrDeriveValue == 'string' - ? [nameOrDeriveValue, maybeDeriveValue!] - : ['ReactiveValue', nameOrDeriveValue] - - let renderEffect = useState(() => new RenderEffect(name))[0] - useSyncExternalStore(renderEffect.subscribe, renderEffect.triggerSnapshot) - return renderEffect.run(deriveValue) -} - -class RenderEffect extends Effect { - private _observers = new Set<() => void>() - private _trigger: ISource | null = null - private _triggerState = { triggeredBy: "(nothing)" } - private _previousValue?: T - - constructor(displayName: string) { - super(displayName, constant(undefined!), (source) => { - log('⚛️🚩 (render invalidated)', this.displayName, [source]) - this._trigger = source - this._triggerState = { triggeredBy: source.path } - for (let subscriber of this._observers) - subscriber() - }) - this.attach() - } - - subscribe = (observer: () => void) => { - this._observers.add(observer) - return () => this._observers.delete(observer) - } - - triggerSnapshot = () => this._triggerState - - override run(deriveValue: () => T) { - let previousValue = this._previousValue - let nextValue!: T - let trigger = this._trigger - this._trigger = null - - let transition = beginTransition(this, true) - let logging = startLogging('⚛️ (render)', this.displayName, emptyArray, () => ({ - Trigger: trigger?.path ?? 'render', - Previous: loggedReactValue(previousValue), - Current: loggedReactValue(nextValue), - })) - - try { - nextValue = super.run(deriveValue) - if (process.env.NODE_ENV !== 'production') - this._previousValue = nextValue - return nextValue - } finally { - logging[dispose]() - transition[dispose]() - } - } -} - -function loggedReactValue(value: unknown) { - if (process.env.NODE_ENV !== 'production' && isValidElement(value)) { - let xmlDoc = document.implementation.createDocument(null, null) - return new LogValue([ - xmlTree(value), - { ['React element']: value } - ]) - - function xmlTree(node: unknown): Node { - if (isValidElement(node)) { - let type = reactElementName(node.type) - .split('(').at(-1)!.split(')')[0] // Unwrap HOC names - .replace(/^[0-9-]/, '_$&') // Escape leading number or dash - .replace(/[^a-zA-Z0-9:_.-]/g, '_'); // Escape rest of element name - let el = xmlDoc.createElement(type || ':unknown:') - for (let child of Children.toArray((node.props as any).children)) - el.appendChild(xmlTree(child)) - return el - } - return document.createTextNode(String(node)) - } - - function reactElementName(elementType: any) { - switch (typeof elementType) { - case 'string': - return elementType - case 'function': - return nameOfFunction(elementType) - case 'symbol': - return String(elementType) - case 'object': - if ('_context' in elementType) return 'Context.Provider' - if (elementType.displayName) return elementType.displayName - if (elementType.type?.displayName) return elementType.type.displayName - } - } - } - return value -} diff --git a/packages/event-reduce/package.json b/packages/event-reduce/package.json index 7a16942..3bf9131 100644 --- a/packages/event-reduce/package.json +++ b/packages/event-reduce/package.json @@ -1,15 +1,15 @@ -{ - "name": "event-reduce", - "description": "State management based on reducing observable events into state", - "version": "0.7.5", - "author": "Simon Oxtoby", - "homepage": "https://github.com/soxtoby/event-reduce", - "repository": { - "type": "git", - "url": "https://github.com/soxtoby/event-reduce.git", - "directory": "packages/event-reduce" - }, - "license": "MIT", +{ + "name": "event-reduce", + "description": "State management based on reducing observable events into state", + "version": "0.7.5", + "author": "Simon Oxtoby", + "homepage": "https://github.com/soxtoby/event-reduce", + "repository": { + "type": "git", + "url": "https://github.com/soxtoby/event-reduce.git", + "directory": "packages/event-reduce" + }, + "license": "MIT", "main": "./lib/index.js", "types": "./lib/index.d.ts", "exports": { @@ -18,16 +18,6 @@ "bun": "./src/index.ts", "default": "./lib/index.js" }, - "./signals": { - "types": "./lib/signals/index.d.ts", - "bun": "./src/signals/index.ts", - "default": "./lib/signals/index.js" - }, - "./signals/*": { - "types": "./lib/signals/*.d.ts", - "bun": "./src/signals/*.ts", - "default": "./lib/signals/*.js" - }, "./*": { "types": "./lib/*.d.ts", "bun": "./src/*.ts", @@ -35,28 +25,14 @@ }, "./package.json": "./package.json" }, - "typesVersions": { - "*": { - "signals": [ - "lib/signals/index.d.ts" - ], - "signals/*": [ - "lib/signals/*" - ], - "*": [ - "lib/*" - ] - } + "scripts": { + "prepack": "cp ../../readme.md . && cp ../../LICENSE ." }, - "scripts": { - "prepack": "cp ../../readme.md . && cp ../../LICENSE ." - }, - "files": [ + "files": [ "lib", "src" ], - "devDependencies": { - "@types/node": "*", - "typescript": "~5" - } + "devDependencies": { + "typescript": "~5" + } } diff --git a/packages/event-reduce/src/signals/atom.ts b/packages/event-reduce/src/atom.ts similarity index 93% rename from packages/event-reduce/src/signals/atom.ts rename to packages/event-reduce/src/atom.ts index 763a5a8..ef25720 100644 --- a/packages/event-reduce/src/signals/atom.ts +++ b/packages/event-reduce/src/atom.ts @@ -1,8 +1,8 @@ -import { dispose } from "../utils"; import { derive, IDerivation } from "./derivation"; import { ISource, Source } from "./sources"; import { getState, State } from "./state"; import { beginTransition } from "./tracking"; +import { dispose } from "./utils"; export class Atom extends Source implements ISource { private _json: IDerivation> diff --git a/packages/event-reduce/src/cleanup.ts b/packages/event-reduce/src/cleanup.ts deleted file mode 100644 index ee20686..0000000 --- a/packages/event-reduce/src/cleanup.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { log } from "./logging"; -import { getObservableValues, getStateProperties, isModel } from "./models"; -import { ObservableValue } from "./observableValue"; -import { dispose, emptyArray, isPlainObject, nameOfFunction } from "./utils"; - -/** value -> owners */ -const ownerRegistry = new WeakMap>(); - -type Model = object; -type TrackableValue = Model | ObservableValue; - -export function changeOwnedValue(owner: TrackableValue, oldValue: unknown, newValue: unknown) { - if (oldValue !== newValue) { - let oldTrackableValues = findTrackableValues(oldValue); - let newTrackableValues = findTrackableValues(newValue); - - for (let oldTrackableValue of oldTrackableValues) - if (!newTrackableValues.has(oldTrackableValue)) - removeOwner(owner, oldTrackableValue); - - for (let newTrackableValue of newTrackableValues) - if (!oldTrackableValues.has(newTrackableValue)) - addOwner(owner, newTrackableValue); - } -} - -function addOwner(owner: TrackableValue, value: TrackableValue) { - ownerRegistry.get(value)?.add(owner) - ?? ownerRegistry.set(value, new Set([owner])); -} - -function removeOwner(owner: TrackableValue, value: TrackableValue) { - let owners = ownerRegistry.get(value); - owners?.delete(owner); - if (!owners?.size) - disposeModel(value); -} - -export function disposeModel(model: Model) { - log('🗑️ (dispose)', nameOfFunction(model.constructor), emptyArray, undefined, () => { - ownerRegistry.delete(model); - if (model instanceof ObservableValue) { - model[dispose](); - } else if (isModel(model)) { - for (let value of Object.values(getObservableValues(model))) - removeOwner(model, value); - for (let key of getStateProperties(model)) - changeOwnedValue(model, model[key as keyof unknown], undefined); - } - }); -} - -function findTrackableValues(value: unknown, trackableValues: Set = new Set(), searchedIn: Set = new Set()) { - if (!searchedIn.has(value)) { - searchedIn.add(value); - - if (value instanceof ObservableValue || isModel(value)) { - trackableValues.add(value); - } else if (Array.isArray(value)) { - for (let item of value) - findTrackableValues(item, trackableValues, searchedIn); - } else if (isPlainObject(value) && !cleanupOptions.skipCleanup(value)) { - for (let key in value) - findTrackableValues(value[key as keyof unknown], trackableValues, searchedIn); - } - } - - return trackableValues; -} - -const cleanupOptions = { - /** Override this to avoid trying to clean up objects that are known not to contain any state. */ - skipCleanup(value: object) { return false; } -}; - -export { cleanupOptions }; diff --git a/packages/event-reduce/src/derivation.ts b/packages/event-reduce/src/derivation.ts index 21d1286..9dac280 100644 --- a/packages/event-reduce/src/derivation.ts +++ b/packages/event-reduce/src/derivation.ts @@ -1,189 +1,119 @@ -import { EventFn, IEventClass, isEvent } from "./events"; -import { log, sourceTree } from "./logging"; -import { isEventsClass } from "./models"; -import { IObservable } from "./observable"; -import { IObservableValue, ObservableValue, collectAccessedValues, startTrackingScope } from "./observableValue"; -import { Unsubscribe } from "./types"; -import { constant, emptyArray, unsubscribeAll, using } from "./utils"; +import { Effect } from "./effect" +import { startLogging } from "./logging" +import { IObserver, ISource, Source } from "./sources" +import { getState, State } from "./state" +import { beginTransition, iterationSource, trackManagedSourceCreation, trackSourceAccessed } from "./tracking" +import { dispose, emptyArray, nameOfFunction } from "./utils" -let currentlyRunningDerivation = null as IObservableValue | null; +export interface IDerivation extends ISource, IObserver { + readonly value: T -const anonymousDerivationName = constant("(anonymous derivation)"); + /** + * Forces the value to be re-calculated. + * @param deriveValue Optional function to use to derive the value. If not provided, the original derivation function will be used. + * @param reason Optional reason for the update. If not invalidated by a source change, this will be used as the reason for the update. + **/ + update(deriveValue?: () => T, reason?: string): void +} -export function derive(getDerivedValue: () => T, name?: string, valuesEqual?: (previous: T, next: T) => boolean) { - return new Derivation(name ? constant(name) : anonymousDerivationName, getDerivedValue, valuesEqual); +export function derive(derive: () => T, displayName?: string): IDerivation +export function derive(derive: () => T, valuesEqual: (a: T, b: T) => boolean, displayName?: string): IDerivation +export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, b: T) => boolean), maybeDisplayName?: string): IDerivation { + let [skipUpdate, displayName] = typeof displayNameOrSkip == 'function' + ? [displayNameOrSkip, maybeDisplayName] + : [Object.is, displayNameOrSkip] + return new Derivation((displayName ?? nameOfFunction(derive)) || Derivation.defaultName, derive, skipUpdate) } -export class Derivation extends ObservableValue implements IObservableValue { - protected _state: DerivationState = 'uninitialized'; - private _sources = [] as ObservableValue[]; - private _sourceSubscriptions = [] as Unsubscribe[]; - protected _invalidatingSource?: IObservable; - private _sourceVersion = 0; +export class Derivation extends Source implements IDerivation { + private _derive: Effect + private _json?: IDerivation> + static defaultName = "(anonymous derivation)" constructor( - getDisplayName: () => string, - private _deriveValue: () => T, - valuesEqual?: (previous: T, next: T) => boolean + displayName: string, + derive: () => T, + private _skipUpdate = Object.is ) { - super(getDisplayName, undefined!, valuesEqual); + super(displayName) + this._derive = new Effect(displayName, derive, () => { + if (this._observers.size) + this.update() + }) } - override get sources() { return this._sources; } - - override get version() { - this.reconcile(); - return super.version; - } - - override get value() { - this.reconcile(); - return super.value; + toJSON() { + // Lazy init to avoid infinite loop in Derivation constructor + return (this._json ??= derive(() => getState(this.value), `toJSON(${this.displayName})`)).value } - private reconcile() { - if (this._state != 'settled' && this._state != 'updating') { - if (this._state == 'uninitialized' || this.sources.some(this.isNewerVersion.bind(this))) - this.update(); - this.onSettled(); - } - } + get sources() { return this._derive.sources } - private isNewerVersion(source: ObservableValue) { - return source.version > this._sourceVersion; - } + override subscribe(observer: IObserver): void { + if (!this._observers.size) + this._derive.attach() - override setValue(value: T) { - this.onSettled(); - super.setValue(value); - } + if (this.lastUpdated < 0) + this.update() // Subscriber may never be notified if we don't collect any sources - private onSettled() { - this._state = 'settled'; - delete this._invalidatingSource; + super.subscribe(observer) } - get invalidatedBy() { return this._invalidatingSource?.displayName; } + override unsubscribe(observer: IObserver): void { + super.unsubscribe(observer) - /** - * Forces the value to be re-calculated. - * @param deriveValue Optional function to use to derive the value. If not provided, the original derivation function will be used. - * @param reason Optional reason for the update. If not invalidated by a source change, this will be used as the reason for the update. - **/ - update(deriveValue?: () => T, reason?: string) { - using(startTrackingScope(), () => { - let trigger = this._invalidatingSource; - let triggerRef = trigger && new WeakRef(trigger); - let previousValue = this._value; - let value!: T; - - this._state = 'updating'; // Ensures that sources unsettled or updated during this update don't trigger further updates - - log(this.getUpdateMessage(), this.displayName, emptyArray, () => ({ - Previous: this.loggedValue(previousValue), - Current: this.loggedValue(value), - Container: this.container, - Sources: sourceTree(this.sources), - TriggeredBy: trigger - ? { name: trigger.displayName, get observable() { return triggerRef!.deref() ?? "No longer in memory"; } } - : reason - }), () => { - let newSources = collectAccessedValues(() => { - let previouslyRunningDerivation = currentlyRunningDerivation; - currentlyRunningDerivation = this; - try { - value = (deriveValue ?? this._deriveValue)(); - } finally { - currentlyRunningDerivation = previouslyRunningDerivation; - } - }); - - if (process.env.NODE_ENV !== 'production') { - if (isEvent(value) || isEventsClass(value)) - throw new DerivedEventsError(this, value); - } - - this._sources.length = 0; - for (let source of newSources) - this._sources.push(source); - this._sourceVersion = Math.max(0, ...this.sources.map(s => s.version)); - this.clearSourceInfo(); - this.onSourcesUpdated(); - - this.setValue(value); - }); - }); + if (!this._observers.size) + this.detach() } - protected onSourcesUpdated() { - this.unsubscribeFromSources(); - this._sourceSubscriptions = this.subscribeToSources(); - } + detach() { this._derive.detach() } - protected subscribeToSources() { - let subscriptions = [] as Unsubscribe[]; + override getSources() { return this._derive.getSources() } - for (let source of this._sources) { - subscriptions.push( - source.subscribe(this.onSourceValueChanged.bind(this, source), this.displayNameGetter), - source.unsettled.subscribe(this.onSourceUnsettled.bind(this), this.displayNameGetter) - ); - } + sourceUpdated(source: ISource, value: S) { this._derive.sourceUpdated(source, value) } - return subscriptions; - } - - protected onSourceUnsettled() { - if (this._state == 'settled') { - this._state = 'indeterminate'; - // Not logging this because it'd be too noisy, - // and it's more of an implementation detail than a change in the model - this.notifyObserversUnsettled(); + override get value() { + // Notifying tracker before checking for updates so circular reference is detected early + let access = trackSourceAccessed(this) + + try { + if (this._derive.isStale) + this.update() + } finally { + access[dispose]() } - } - protected onSourceValueChanged(source: IObservableValue) { - this._invalidatingSource = source; - - if (this.isObserved) - this.reconcile(); - else - log(this.getInvalidatedMessage(), this.displayName, emptyArray, () => ({ - Previous: this.loggedValue(this._value), - Container: this.container, - Sources: sourceTree(this.sources) - })); + return this._value // Not using super.value to avoid duplicate tracker notification } - protected getInvalidatedMessage() { return '🔗🚩 (derivation invalidated)'; } - - protected getUpdateMessage() { return '🔗 (derivation)'; } - - protected loggedValue(value: T): unknown { return value; } + protected override skipUpdate(nextValue: T) { return this._skipUpdate(this._value, nextValue) } - override unsubscribeFromSources() { - unsubscribeAll(this._sourceSubscriptions); - this._sourceSubscriptions.length = 0; + update(deriveValue?: () => T, reason?: string) { + let previousValue = this._value + let nextValue!: T + let trigger = this._derive.invalidatingSource + + let transition = beginTransition(this, !iterationSource) // Derivations are always safe to access, and only initiate a transition if necessary + let sourceCreation = trackManagedSourceCreation() + let logging = startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ + Path: this.path, + Trigger: trigger?.path ?? '(unknown)', + Previous: previousValue, + Current: nextValue + })) + try { + this.setValue(nextValue = this._derive.run(deriveValue)) + sourceCreation.verifySourceManagement() + } finally { + logging[dispose]() + sourceCreation[dispose]() + transition[dispose]() + } } -} - -type DerivationState = 'uninitialized' | 'indeterminate' | 'updating' | 'settled'; -export function ensureNotInsideDerivation(sideEffect: string) { - if (currentlyRunningDerivation) - throw new SideEffectInDerivationError(currentlyRunningDerivation, sideEffect); -} - -export class SideEffectInDerivationError extends Error { - constructor( - public derivation: IObservableValue, - public sideEffect: string - ) { super(`Derivation ${derivation.displayName} triggered side effect ${sideEffect}. Derivations cannot have side effects.`); } -} - -export class DerivedEventsError extends Error { - constructor( - public derivation: IObservableValue, - public value: EventFn | object - ) { super(`Derivation ${derivation.displayName} returned event or events class ${isEvent(value) ? value.displayName : value}. Events cannot be state.`); } + override[dispose]() { + this._derive[dispose]() + this._json?.[dispose]() + super[dispose]() + } } \ No newline at end of file diff --git a/packages/event-reduce/src/devtools.ts b/packages/event-reduce/src/devtools.ts index 432630c..a9dc735 100644 --- a/packages/event-reduce/src/devtools.ts +++ b/packages/event-reduce/src/devtools.ts @@ -1,37 +1,37 @@ -import { getState, setState } from "./state"; -import { isObject } from "./utils"; +import { getState, State } from "./state" +import { isObject } from "./utils" -let modelDevTools = new Map(); +let modelDevTools = new Map() -export function enableDevTools(model: object, options?: string | IDevToolsOptions) { +export function enableDevTools(model: Model, { restoreState, ...options }: IDevToolsOptions = {}) { if (process.env.NODE_ENV !== 'production') { if (!modelDevTools.has(model) && (window as any).__REDUX_DEVTOOLS_EXTENSION__) { - let dev = (window as any).__REDUX_DEVTOOLS_EXTENSION__.connect(typeof options == 'string' ? { name: options } : options); - modelDevTools.set(model, dev); - dev.init(getState(model)); + let dev = (window as any).__REDUX_DEVTOOLS_EXTENSION__.connect(options) + modelDevTools.set(model, dev) + dev.init(getState(model)) let unsubscribe = dev.subscribe((event: any) => { if (event.type == 'DISPATCH') - setState(model, JSON.parse(event.state)); - }); + restoreState?.(model, JSON.parse(event.state)) + }) return () => { - modelDevTools.delete(model); - unsubscribe(); + modelDevTools.delete(model) + unsubscribe() } } } - return () => { }; + return () => { } } export function sendEvent(name: string, arg: any) { if (process.env.NODE_ENV !== 'production') { if (modelDevTools.size) { let event = isObject(arg) - ? getState(arg, { name }) - : { value: arg }; + ? getState(arg) + : { value: arg } if ('type' in event) - event['.type'] = event.type; - event.type = name; - modelDevTools.forEach((dev, model) => dev.send(event, getState(model, { includeDerivedProperties: true }))); + event['.type'] = event.type + event.type = name + modelDevTools.forEach((dev, model) => dev.send(event, getState(model))) } } } @@ -39,39 +39,45 @@ export function sendEvent(name: string, arg: any) { /** * redux-devtools-extension options from https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md */ -export interface IDevToolsOptions { +export interface IDevToolsOptions { /** * The instance name to be shown on the monitor page. Default value is document.title. * If not specified and there's no document title, it will consist of tabId and instanceId. */ - name?: string; + name?: string + + /** + * Callback to restore state, used when time traveling or importing state from a file. Takes the deserialized state as an argument. + * event-reduce specific + */ + restoreState?: (model: Model, state: State) => void /** * In ms. If more than one action is dispatched in the indicated interval, all new actions will be collected and sent at once. * It is the joint between performance and speed. When set to 0, all actions will be sent instantly. * Set it to a higher value when experiencing perf issues (also maxAge to a lower value). Default is 500 ms. */ - latency?: number; + latency?: number /** * Maximum allowed actions to be stored in the history tree. The oldest actions are removed once maxAge is reached. * It's critical for performance. Default is 50. */ - maxAge?: number; + maxAge?: number /** * If set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code. * You can use a function (with action object as argument) which should return new Error().stack string, getting the stack outside of reducers. * Default to false. */ - trace?: boolean | ((action: unknown) => string); + trace?: boolean | ((action: unknown) => string) /** * Maximum stack trace frames to be stored (in case trace option was provided as true). By default it's 10. * Note that, because extension's calls are excluded, the resulted frames could be 1 less. * If trace option is a function, traceLimit will have no effect, as it's supposed to be handled there. */ - traceLimit?: number; + traceLimit?: number /** * undefined - will use regular JSON.stringify to send data (it's the fast mode). @@ -84,15 +90,15 @@ export interface IDevToolsOptions { * For function key you can also specify a custom function which handles serialization. */ options: { - date?: boolean; - regex?: boolean; - undefined?: boolean; - nan?: boolean; - infinity?: boolean; - error?: boolean; - symbol?: boolean; - map?: boolean; - set?: boolean; + date?: boolean + regex?: boolean + undefined?: boolean + nan?: boolean + infinity?: boolean + error?: boolean + symbol?: boolean + map?: boolean + set?: boolean function?: boolean | ((fn: Function) => string) } @@ -100,54 +106,54 @@ export interface IDevToolsOptions { * JSON replacer function used for both actions and states stringify. * You can specify a data type by adding a __serializedType__ key. So you can deserialize it back while importing or persisting data. */ - replacer?: (key: string, value: unknown) => any; + replacer?: (key: string, value: unknown) => any /** * JSON reviver function used for parsing the imported actions and states. */ - reviver?: (key: string, value: unknown) => any; + reviver?: (key: string, value: unknown) => any /** * Automatically serialize/deserialize immutablejs via remotedev-serialize. Should be set to the default export of immutablejs. */ - immutable?: any; + immutable?: any /** * ImmutableJS Record classes used to make possible restore its instances back when importing, persisting. */ - refs?: any[]; + refs?: any[] } /** * Function which takes action object and id number as arguments, and should return action object back. */ - actionSanitizer?: (action: unknown, id: number) => any; + actionSanitizer?: (action: unknown, id: number) => any /** * Function which takes state object and index as arguments, and should return state object back. */ - stateSanitizer?: (state: unknown, index: number) => any; + stateSanitizer?: (state: unknown, index: number) => any /** * String or array of strings as regex - actions types to be hidden in the monitors. * If actionsWhitelist specified, actionsBlacklist is ignored. */ - actionsBlacklist?: string | string[]; + actionsBlacklist?: string | string[] /** * String or array of strings as regex - actions types to be shown in the monitors. * If actionsWhitelist specified, actionsBlacklist is ignored. */ - actionsWhitelist?: string | string[]; + actionsWhitelist?: string | string[] /** * Called for every action before sending, takes state and action object, and returns true in case it allows sending the current data to the monitor. * Use it as a more advanced version of actionsBlacklist/actionsWhitelist parameters. */ - predicate?: (state: unknown, action: unknown) => boolean; + predicate?: (state: unknown, action: unknown) => boolean /** * Auto pauses when the extension’s window is not opened, and so has zero impact on your app when not in use. Default is false. */ - autoPause?: boolean; + autoPause?: boolean } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/effect.ts b/packages/event-reduce/src/effect.ts similarity index 92% rename from packages/event-reduce/src/signals/effect.ts rename to packages/event-reduce/src/effect.ts index d973644..bcd3394 100644 --- a/packages/event-reduce/src/signals/effect.ts +++ b/packages/event-reduce/src/effect.ts @@ -1,6 +1,6 @@ -import { dispose } from "../utils" import type { IObserver, ISource } from "./sources" -import { currentIteration, collectAccessedSources } from "./tracking" +import { collectAccessedSources, currentIteration } from "./tracking" +import { dispose } from "./utils" export class Effect implements IObserver { private _attached = false diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index 2ea3c62..1cd56cf 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -1,226 +1,180 @@ -import { ensureNotInsideDerivation } from "./derivation"; -import { log, logEvent } from "./logging"; -import { IObservable, ObservableOperation } from "./observable"; -import { ISubject, Subject } from "./subject"; -import { Scoped } from "./types"; -import { NamedBase, constant, filteredName, matchesScope, scopedName } from "./utils"; - -export interface IEventBase { - displayName: string; - container?: any; -} - -/** Event implementation */ -export interface IEventClass extends IEventBase { - next(...args: any): any; -} - -export interface IEvent extends IObservable, IEventBase { - (eventValue: TIn): TIn; - scope(this: IEvent, scope: Scope): IEvent, ObjectOut>; -} - -export interface IAsyncObservables { - readonly started: IObservable>; - readonly resolved: IObservable>; - readonly rejected: IObservable>; -} - -export interface IFilterableAsyncObservables extends IAsyncObservables, IEventBase { - filter(predicate: (context: Context) => boolean): IFilterableAsyncObservables; -} +import { startLogging } from "./logging" +import { type ISource, Source, SourceValue } from "./sources" +import { beginTransition } from "./tracking" +import { Scoped } from "./types" +import { dispose, matchesScope } from "./utils" -export interface IAsyncEvent extends IFilterableAsyncObservables { - >(promise: Promise, context: ContextIn): Promise; - scope(this: IAsyncEvent, scope: Scope): IAsyncEvent, ObjectContext>; +export interface IEvent extends IEventFunction { + scope>(scope: Scope): IEvent, Out> } -export interface AsyncStart { - promise: PromiseLike; - context: Context; +export interface IEventFunction extends ISource { + (...args: Args): Return } -export interface AsyncResult { - result: Result; - context: Context; -} - -export interface AsyncError { - error: any; - context: Context; -} - -export function event(name?: string): IEvent { - return makeEventFunction(new EventSubject(name ? constant(name) : anonymousEventName)); -} - -class EventBase extends ObservableOperation { - container?: any; - - scope(this: IEvent, scope: Scope) { - return makeEventFunction(new ScopedEventSubject(this, scope)); - } +export function event(name: string = "(anonymous event)"): IEvent { + return makeEventFunction(name, function event(emit: (value: T) => void, value: T) { + let logging = startLogging('⚡ (event)', name, [value], () => ({ Path: this.path })) + try { + emit(value) + } finally { + logging?.[dispose]() + } + return value + }, function eventMembers(e: IEventFunction) { + return { + scope>(scope: Scope): IEvent, T> { + return bindEvent(e, + `${e.displayName}.scoped(${JSON.stringify(scope)})`, + (value: Scoped) => e({ ...value, ...scope } as In), + matchesScope(scope), + eventMembers) as IEvent, T> + } + } + }) as IEvent } -class EventSubject extends EventBase implements IEventClass { - private _subject: ISubject; - - constructor(getDisplayName: () => string) { - let subject = new Subject(() => this.displayName); - super(getDisplayName, [subject], observer => subject.subscribe(observer.next, observer.getDisplayName)); - this._subject = subject; - } - - next(value: T) { - fireEvent('⚡ (event)', this.displayName, value, () => ({ Container: this.container }), - () => this._subject.next(value)); - return value; - } +export interface IAsyncEvent extends IEventFunction, [promise: PromiseLike, context: Context]> { + started: ISource> + resolved: ISource> + rejected: ISource> + scope>(scope: Scope): IAsyncEvent> } -class ScopedEventSubject extends EventBase implements IEventClass { - constructor(private _source: IEvent, private _scope: Scope) { - super(() => scopedName(_source.displayName, _scope), [_source], - observer => _source.subscribe(value => matchesScope(_scope, value) && observer.next(value), observer.getDisplayName)); - } +export type AsyncValue = + | AsyncStart + | AsyncResult + | AsyncError - next(partial: Scoped) { - // Using plain log so only the unscoped event is logged as an event - log('{⚡} (scoped event)', this.displayName, [partial], () => ({ Scope: this._scope, Container: this.container }), - () => this._source({ ...partial, ...this._scope } as ObjectIn)); - return partial; - } +export interface AsyncStart { + context: Context + promise: PromiseLike } -const anonymousAsyncEventName = constant("(anonymous async event)"); - -/** Creates an event that takes promises as inputs, along with an optional context value */ -export function asyncEvent(name?: string): IAsyncEvent { - return makeEventFunction(new AsyncEvent(name ? constant(name) : anonymousAsyncEventName)); +export interface AsyncResult { + context: Context + result: Result } -abstract class FilterableAsyncObservables extends NamedBase { - container?: any; - - abstract readonly started: IObservable>; - abstract readonly resolved: IObservable>; - abstract readonly rejected: IObservable>; - - filter(predicate: (context: Context) => boolean): IFilterableAsyncObservables { - return new FilteredAsyncObservables(this, predicate); - } +export interface AsyncError { + context: Context + error: any } -class FilteredAsyncObservables extends FilterableAsyncObservables { - constructor(private _source: IFilterableAsyncObservables, private _predicate: (context: Context) => boolean) { - super(() => filteredName(_source.displayName, _predicate)); - - this.started = this._source.started.filter(s => this._predicate(s.context), () => `${this.displayName}.started`); - this.resolved = this._source.resolved.filter(r => this._predicate(r.context), () => `${this.displayName}.resolved`); - this.rejected = this._source.rejected.filter(r => this._predicate(r.context), () => `${this.displayName}.rejected`); - } - - readonly started: IObservable>; - readonly resolved: IObservable>; - readonly rejected: IObservable>; -} - -abstract class AsyncEventBase extends FilterableAsyncObservables { - scope(this: IAsyncEvent, scope: Scope) { - return makeEventFunction(new ScopedAsyncEvent(this, scope)); - } -} - -class AsyncEvent extends AsyncEventBase implements IEventClass { - readonly started = new Subject>(() => `${this.displayName}.started`); - readonly resolved = new Subject>(() => `${this.displayName}.resolved`); - readonly rejected = new Subject>(() => `${this.displayName}.rejected`); - - next>(promise: Promise, context: Context) { - fireEvent('⚡⌚ (async event)', this.displayName + '.started', { promise, context }, () => ({ Promise: promise, Container: this.container }), - () => this.started.next({ promise, context })); +export function asyncEvent(name: string = "(anonymous async event)"): IAsyncEvent { + return makeEventFunction(name, function asyncEvent

>( + this: IEventFunction, [promise: PromiseLike, context: Context]>, + emit: (value: AsyncValue) => void, + promise: P, + context: Context + ) { + let logging = startLogging('⚡⌚ (async event)', name, [promise, context], () => ({ Path: this.path, Promise: promise })) + try { + emit({ promise, context }) + } finally { + logging?.[dispose]() + } promise.then( - result => fireEvent('⚡✔ (async result)', this.displayName + '.resolved', { result, context }, () => ({ Promise: promise, Container: this.container }), - () => this.resolved.next({ result, context })), - error => fireEvent('⚡❌ (async error)', this.displayName + '.rejected', { error, context }, () => ({ Promise: promise, Container: this.container }), - () => this.rejected.next({ error, context }))); - - return promise; - } -} - -class ScopedAsyncEvent extends AsyncEventBase implements IEventClass { - constructor(private _source: IAsyncEvent, private _scope: Scope) { - super(() => scopedName(_source.displayName, _scope)); - - this.started = this._source.started.filter(s => matchesScope(this._scope, s.context), () => `${this.displayName}.started`); - this.resolved = this._source.resolved.filter(r => matchesScope(this._scope, r.context), () => `${this.displayName}.resolved`); - this.rejected = this._source.rejected.filter(e => matchesScope(this._scope, e.context), () => `${this.displayName}.rejected`); - } - - readonly started: IObservable>; - readonly resolved: IObservable>; - readonly rejected: IObservable>; - - next>(promise: Promise, partialContext: Scoped) { - // Using plain log so only the unscoped event is logged as an event - log('{⚡⌚} (scoped async event)', this.displayName, [partialContext], () => ({ Scope: this._scope, Container: this.container }), - () => this._source(promise, { ...this._scope, ...partialContext } as ContextIn)); - return promise; - } -} - -/** Converts an event class into a callable function */ -export function makeEventFunction(event: Event) { - Object.setPrototypeOf(eventFn, event); - eventFn[eventBrand] = true; + result => { + let val = { result, context } + let logging = startLogging('⚡✔ (async resolved)', name, [val], () => ({ Path: this.path, Promise: promise })) + try { + emit(val) + } finally { + logging?.[dispose]() + } + }, + error => { + let val = { error, context } + let logging = startLogging('⚡✘ (async rejected)', name, [val], () => ({ Path: this.path, Promise: promise })) + try { + emit(val) + } finally { + logging?.[dispose]() + } + }) + + return promise + }, function asyncEventMembers(e: IEventFunction, [promise: PromiseLike, context: CurrentContext]>) { + let setBaseDisplayName = e.setDisplayName + return { + started: e.filter(v => 'promise' in v, `${e.displayName}.started`), + resolved: e.filter(v => 'result' in v, `${e.displayName}.resolved`), + rejected: e.filter(v => 'error' in v, `${e.displayName}.rejected`), + scope>(scope: Scope): IAsyncEvent> { + return bindEvent(e, + `${e.displayName}.scoped(${JSON.stringify(scope)})`, + (promise: PromiseLike, context: Scoped) => e(promise, { ...context, ...scope } as CurrentContext), + v => matchesScope(scope, v.context), + asyncEventMembers) as IAsyncEvent> + }, + setDisplayName(name: string) { + setBaseDisplayName.call(e, name) + this.started.setDisplayName(`${name}.started`) + this.resolved.setDisplayName(`${name}.resolved`) + this.rejected.setDisplayName(`${name}.rejected`) + } + } + }) as IAsyncEvent +} + +/** Creates a callable event object. */ +export function makeEventFunction( + name: string, + fire: (this: IEventFunction, emit: (value: T) => void, ...args: Args) => Return, + getMembers?: AdditionalMembersFactory, Members> +): IEventFunction & Members { + let eventSource = new Event(name) as Event & IEventFunction + return makeSourceFunction(eventSource, event, getMembers) + + function event(...args: Args) { return fire.call(event as IEventFunction, eventSource.emit.bind(event), ...args) } +} + +/** Wrap an event with input and output filter. Useful for scoping. */ +export function bindEvent, Args extends any[], Return, Members extends object>( + event: E, + displayName: string, + fire: (...args: Args) => Return, + predicate: (value: SourceValue) => boolean, + getMembers?: AdditionalMembersFactory, Members> +) { + return makeSourceFunction(event.filter(predicate, displayName), fire, getMembers) +} + +function makeSourceFunction, Args extends any[], Return, Members extends object>( + innerEvent: Source, + fire: (...args: Args) => Return, + getMembers?: AdditionalMembersFactory +): IEventFunction, Args, Return> & Members { + let eventFn = fire as Source & IEventFunction, Args, Return> + Object.setPrototypeOf(eventFn, innerEvent); eventFn.apply = Function.prototype.apply; - Object.defineProperty(eventFn, 'displayName', { // Delegate to prototype, since already initialised Subjects' displayNames will have captured the prototype as 'this' - get() { return event.displayName; }, - set(value: string) { event.displayName = value; } - }); - return eventFn as EventFn; - - function eventFn(...args: any) { - return event.next.apply(eventFn, args); - } + eventFn.call = Function.prototype.call; + return getMembers + ? Object.assign(eventFn, getMembers(eventFn)) + : eventFn as Source & IEventFunction, Args, Return> & Members } -export function isEvent(event: unknown): event is EventFn { - return typeof event == 'function' - && eventBrand in event; -} - -export type EventFn = Event & Event['next'] & { [eventBrand]: true }; +export type AdditionalMembersFactory, Members extends object> = + (source: Source & IEventFunction, any>) => Members -/** Logs event and ensures no other events are run at the same time. */ -export function fireEvent(type: string, displayName: string, arg: any, getInfo: (() => object) | undefined, runEvent: () => void) { - logEvent(type, displayName, arg, getInfo, () => { +class Event extends Source { + emit(value: T) { + let transition = beginTransition(this, true) try { - if (currentlyFiringEvent) - throw new ChainedEventsError(currentlyFiringEvent, displayName); - - ensureNotInsideDerivation(displayName || anonymousEvent); - - currentlyFiringEvent = displayName || anonymousEvent; - runEvent(); + this.setValue(value) } finally { - currentlyFiringEvent = null; + this._value = undefined! // Events shouldn't hold on to their values + transition[dispose]() } - }); + } + override isValidAsState = false + override skipUpdate(nextValue: T) { return false } + protected override updateChildren(previousValue: T, nextValue: T) { } // Value won't be held onto } -let currentlyFiringEvent = null as string | null; -const anonymousEvent = "(anonymous event)"; -const anonymousEventName = constant(anonymousEvent); -const eventBrand = Symbol('IsEvent'); - -export class ChainedEventsError extends Error { - constructor( - public currentEvent: string, - public newEvent = anonymousEvent - ) { - super(`Events should not be fired in response to other events. Fired '${newEvent}' in response to '${currentEvent}'.`) - } +export function isEvent(value: any): value is IEventFunction { + return typeof value == 'function' + && value instanceof Event } \ No newline at end of file diff --git a/packages/event-reduce/src/index.ts b/packages/event-reduce/src/index.ts index ae18593..76a8016 100644 --- a/packages/event-reduce/src/index.ts +++ b/packages/event-reduce/src/index.ts @@ -1,12 +1,10 @@ -export { derive } from "./derivation"; -export { enableDevTools } from "./devtools"; -export { AsyncError, AsyncResult, AsyncStart, IAsyncEvent, IAsyncObservables, IEvent, IEventBase, IFilterableAsyncObservables, asyncEvent, event, fireEvent, makeEventFunction } from "./events"; -export { enableLogging } from "./logging"; -export { derived, events, extend, model, reduced, state } from "./models"; -export { IObservable, IObserver, Observable, Observe, allSources, merge } from "./observable"; -export { IObservableValue, valueChanged } from "./observableValue"; -export { IBoundReduction, IReduction, reduce } from "./reduction"; -export { State, getState, setState } from "./state"; -export { ISubject, Subject } from "./subject"; -export { Unsubscribe } from "./types"; -export { NamedBase, filteredName, matchesScope, scopedName } from "./utils"; +export { Atom } from "./atom" +export { derive, type IDerivation } from "./derivation" +export { enableDevTools } from "./devtools" +export { Effect } from "./effect" +export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IEvent } from "./events" +export { enableLogging, log, startLogging } from "./logging" +export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" +export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" +export { type IObserver, type ISource } from "./sources" +export { getState, type State } from "./state" diff --git a/packages/event-reduce/src/logging.ts b/packages/event-reduce/src/logging.ts index a7aae54..75bc8a9 100644 --- a/packages/event-reduce/src/logging.ts +++ b/packages/event-reduce/src/logging.ts @@ -1,12 +1,18 @@ -import { sendEvent } from "./devtools"; -import { IObservable, Observable } from "./observable"; -import { RestoreSubject } from "./reduction"; -import { emptyArray } from "./utils"; +import { dispose, nullDisposable } from "./utils"; let loggingEnabled = false; let logStack = [[]] as LogItem[][]; +type LogItem = LogMessage | LogGroup; + +type LogMessage = any[]; + +type LogGroup = { + message: LogMessage; + children: LogItem[]; +} + /** * For logged values that have their own specific log formatting. * `args` will be passed as separate arguments to `console.log` @@ -17,90 +23,47 @@ export function enableLogging(enable = true) { loggingEnabled = enable; } -export function logEvent(type: string, displayName: string, arg: any, getInfo: (() => object) | undefined, runEvent: () => void) { - log(type, displayName, [arg || ''], getInfo, runEvent); - sendEvent(displayName, arg); -} - -export function log(type: string, displayName: string, args: readonly any[], getInfo?: () => Info, work?: () => void) { - if (process.env.NODE_ENV !== 'production') { - if (!loggingEnabled) - return void work?.(); - - if (work) { - logStack.push([]); - - try { - work(); - } finally { - logMessage(logStack.pop()!); - } - } else { - logMessage(); - } - - if (logStack.length == 1) { - flushLogs(logStack[0]); - logStack = [[]]; - } - - function logMessage(childLogs: LogItem[] = []) { - let group = { - message: [`${displayName} %c${type}`, 'color: grey; font-weight: normal;', ...args], - children: Object.entries(getInfo?.() ?? {} as Info) - .map(([key, value]) => value instanceof LogValue - ? [`${key}:`, ...value.args] - : [`${key}:`, value]) - .concat(childLogs) - }; - logStack.at(-1)!.push(group); - } - - function flushLogs(logs: LogItem[]) { - for (let log of logs) { - if (Array.isArray(log)) { - console.log(...log); - } else if (!log.children.length) { - console.log(...log.message); - } else { - console.groupCollapsed(...log.message); - flushLogs(log.children); - console.groupEnd(); - } - } - } +export function startLogging(type: string, displayName: string, args: readonly any[], getInfo?: () => Info): Disposable { + if (process.env.NODE_ENV !== 'production' && loggingEnabled) { + logStack.push([]); + return { [dispose]() { logMessage(type, displayName, args, getInfo, logStack.pop()!); } } } else { - return void work?.(); + return nullDisposable } } -type LogItem = LogMessage | LogGroup; - -type LogMessage = any[]; - -type LogGroup = { - message: LogMessage; - children: LogItem[]; +export function log(type: string, displayName: string, args: readonly any[], getInfo?: () => Info) { + if (process.env.NODE_ENV !== 'production' && loggingEnabled) + logMessage(type, displayName, args, getInfo); } -export interface ISourceInfo { - readonly name: string; - readonly sources: readonly ISourceInfo[]; - readonly observable: IObservable | string; +function logMessage(type: string, displayName: string, args: readonly any[], getInfo?: () => Info, childLogs: LogItem[] = []) { + let group = { + message: [`${displayName} %c${type}`, 'color: grey; font-weight: normal;', ...args], + children: Object.entries(getInfo?.() ?? {} as Info) + .map(([key, value]) => value instanceof LogValue + ? [`${key}:`, ...value.args] + : [`${key}:`, value]) + .concat(childLogs) + }; + logStack.at(-1)!.push(group); + + if (logStack.length == 1) { + flushLogs(logStack[0]); + logStack = [[]]; + } } -export function sourceTree(sources: readonly IObservable[]): readonly ISourceInfo[] { - if (process.env.NODE_ENV !== 'production') - return sources - .filter(s => !(s instanceof RestoreSubject)) // Since every reduction has a restore source, this is just noise - .map(s => { - let source = new WeakRef(s); - return { - name: s.displayName, - sources: (s as Observable).sourceInfo, - get observable() { return source.deref() ?? "No longer in memory"; } - } - }); - else - return emptyArray; +function flushLogs(logs: LogItem[]) { + for (let log of logs) { + if (Array.isArray(log)) { + console.log(...log); + } else if (!log.children.length) { + console.log(...log.message); + } else { + console.groupCollapsed(...log.message); + flushLogs(log.children); + console.groupEnd(); + } + } } \ No newline at end of file diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index 51a61bc..ec1c56e 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -1,17 +1,74 @@ -import { changeOwnedValue } from "./cleanup"; -import { Derivation, derive } from "./derivation"; -import { isEvent } from "./events"; -import { ObservableValue, ValueIsNotObservableError, getUnderlyingObservable, protectionAgainstAccessingValueWithCommonSource } from "./observableValue"; -import { Reduction, reduce } from "./reduction"; -import { StringKey } from "./types"; -import { argsAre, constant, dispose, emptyArray, getAllPropertyDescriptors, getOrAdd, isFunction, isObject, isPlainObject, isString, isUndefined, nameOfFunction } from "./utils"; - -const ModelClassBrand = Symbol('IsModel'); -const EventsClassBrand = Symbol('IsEventsClass'); -const StateProperties = Symbol('StateProperties'); -const ObservablePropertyFactories = Symbol('ObservablePropertyInitializers'); -const LegacyObservablePropertyFactories = Symbol('ObservableValueFactories'); -const ObservableValues = Symbol('ObservableValues'); +import { Derivation, derive, IDerivation } from "./derivation" +import { isEvent } from "./events" +import { IReduction, Reduction } from "./reduction" +import { ISource, isSource, SourceValue } from "./sources" +import { getState } from "./state" +import { collectAccessedSources, getChildNodes, getUnderlyingSource, trackManagedSourceCreation, updateChildren } from "./tracking" +import { argsAre, constant, dispose, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "./utils" + +const modelBrand = Symbol('model class') +const modelProperties = Symbol('model properties') + +export interface Model extends Disposable { + [modelProperties]: Record +} + +export type ModelProperty = ISource | StateProperty + +export function isModel(value: unknown): value is Model { + return isObject(value) + && modelProperties in value +} + +/** instance -> key -> factory */ +let instancePropertyFactories = new WeakMap ModelProperty>>() +/** prototype -> key -> factory */ +let prototypePropertyFactories = new WeakMap ModelProperty>>() +/** + * instance -> getter -> property source + * Required for getters to cache their source without being overridden in sub-classes +*/ +let getterCache = new WeakMap>>() + + +/** Creates a loose model object. Not required if your model is already decorated with \@{@link model}. */ +export function createModel(create: () => T): T & Model { + let sourceCreation = trackManagedSourceCreation() + let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this + try { + let instance = create() + if (isModel(instance)) + return instance + let model = Object.create(instance, { + [modelProperties]: { + value: Object.fromEntries( + getChildNodes(instance) + .entries() + .flatMap(([node, path]) => isSource(node) ? [[path, node]] : [])) + }, + [dispose]: { + value() { + Object.values(this[modelProperties]) + .filter(isDisposable) + .forEach(p => p[dispose]()) + if (isDisposable(instance)) + instance[dispose]() + } + } + }) + updateChildren(model, null, instance) + sourceCreation.verifySourceManagement() + return model + } finally { + accesses[dispose]() + sourceCreation[dispose]() + } +} + +export function disposeModel(model: object) { + if (isModel(model)) + model[dispose]() +} /** * Marks a class as a model. @@ -20,211 +77,252 @@ const ObservableValues = Symbol('ObservableValues'); * The model instance will take ownership of all observable and state properties. **/ export function model any>(target: Class): Class { - (target as any)[ModelClassBrand] = true; - - let legacyFactories = getOrAddLegacyObservablePropertyFactories(target.prototype); - - const className = nameOfFunction(target); - return { + const className = nameOfFunction(target) + let thisCtor: new (...args: any[]) => any + return thisCtor = { [className]: class extends target { + static [modelBrand] = true constructor(...args: any[]) { - let protection = protectionAgainstAccessingValueWithCommonSource(); // Introduces new tracking scope, while still protecting against accessing values derived from the current event + let sourceCreation = trackManagedSourceCreation() + let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this try { - super(...args); - - let observableValues = getOrAddObservableValues(this); - - // New decorators - let propertyFactories = getOrAddObservablePropertyFactories(this); - for (let key of Object.keys(propertyFactories)) - observableValues[key] = defineObservableProperty(this, key, propertyFactories[key]); - - // Legacy decorators - for (let key of Object.getOwnPropertyNames(legacyFactories)) // Super class could have its own value factories - observableValues[key] = defineObservableProperty(this, key, legacyFactories[key]); - } finally { - protection[dispose](); - } - - for (let value of Object.values(getObservableValues(this))) - changeOwnedValue(this, undefined, value); - - for (let key of getStateProperties(this)) { - let value = this[key]; - if (process.env.NODE_ENV !== 'production') { - if (isEvent(value) || isEventsClass(value)) - throw new EventsMarkedAsStateError(this, key); + super(...args) + + let model = this as any as Model + + let properties = {} as Record + + let prototypeFactories = getPrototypePropertyFactories(target.prototype) + for (let key of Object.keys(prototypeFactories)) + properties[key] = initializeModelProperty(model, key, prototypeFactories[key]) + + let instanceFactories = getInstancePropertyFactories(model) + for (let [key, factory] of Object.entries(instanceFactories)) + properties[key] = initializeModelProperty(model, key, factory) + + updateChildren(model, null, properties) // Sets up children with correct paths + sourceCreation.verifySourceManagement() + + // Layer this class' properties onto the super class' properties + model[modelProperties] = Object.assign( + Object.create(model[modelProperties] ?? {}), + properties) + + instancePropertyFactories.delete(model) // No longer needed, and avoids sub-models re-initializing properties + + // Outer-most constructor setup + let outerMostModelCtor = getPrototypeChain(new.target).find(ctor => modelBrand in ctor) + if (outerMostModelCtor == thisCtor) { + getterCache.delete(model) // Only needed during construction + + let json = derive(() => { + let obj = {} as Record + for (let key in model[modelProperties]) + obj[key] = model[modelProperties][key] + return obj + }, `toJSON(${className})`) + + Object.assign(this, { + toJSON() { return super.toJSON?.() ?? json.value }, + [dispose]() { + // Make sure to cover all the properties in the prototype chain + for (let layer of getPrototypeChain(model[modelProperties])) + for (let property of Object.values(layer)) + updateChildren(model, property, null) + json[dispose]() + super[dispose]?.() + } + }) } - changeOwnedValue(this, undefined, this[key]); + } finally { + accesses[dispose]() + sourceCreation[dispose]() } } } - }[className]; + }[className] } -function defineObservableProperty(model: any, key: string, createObservableValue: (instance: any) => ObservableValue) { - let observableValue = createObservableValue(model); - observableValue.container = model; - Object.defineProperty(model, key, { - get() { return observableValue.value; }, - enumerable: true, - configurable: true - }); - return observableValue; +function initializeModelProperty

(instance: Model, key: PropertyKey, factory: (instance: any) => P) { + let property = factory(instance) + if (isSource(property)) { // No need to do this for state properties + Object.defineProperty(instance, key, { + // Bind directly to the property for cleaner call stack + get: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(property), 'value')!.get!.bind(property), + enumerable: true, + configurable: true + }) + } else if (process.env.NODE_ENV !== 'production' + && (isEvent(property.value) || isEventsClass(property.value)) + ) { + throw new EventsMarkedAsStateError(instance, key) + } + return property } -export function reduced(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): ClassAccessorDecoratorResult; -export function reduced(getter: () => Value, context: ClassGetterDecoratorContext): void; -export function reduced(prototype: object, key: string | symbol): void; -export function reduced(...args: - | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] - | [() => Value, ClassGetterDecoratorContext] +export function reduced(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): ClassAccessorDecoratorResult +export function reduced(getter: () => Value, context: ClassGetterDecoratorContext): void +export function reduced(prototype: object, key: string | symbol): void +export function reduced(...args: + | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] + | [() => Value, ClassGetterDecoratorContext] | [Object, string | symbol] ) { if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { // Auto-accessor property decorator - let [_, context] = args; + let [target, context] = args return { - init(this: Model, initial: Value) { - assertIsModel(this, context.name); - - let reduction = getUnderlyingObservable(initial); - if (!(reduction instanceof Reduction)) - throw new InvalidReducedPropertyError(context.name); - - getOrAddObservablePropertyFactories(this)[context.name] = constant(reduction); - - return initial; + init(this: Target, initial: Value) { + let reduction = getUnderlyingReduction(this, context.name, () => initial) + getModelGetterCache(this).set(target.get, reduction) + getInstancePropertyFactories(this)[context.name] = constant(reduction) + return initial }, - get(this: Model) { - return getObservableValues(this)?.[context.name as string].value as Value; - } - }; + get(this: Target) { return (getModelGetterCache(this).get(target.get) as Reduction)!.value }, + } } else if (argsAre(args, isFunction, isDecoratorContext)) { // Getter property decorator - let [getter, context] = args; - - context.addInitializer(function (this: Model) { - assertIsModel(this, context.name); + let [getter, context] = args + return cachedGetter(getter, context, instance => getUnderlyingReduction(instance, context.name, getter)) - getOrAddObservablePropertyFactories(this)[context.name] = getterReduction.bind(null, this, context.name, getter); - }); } else { // Legacy decorator - let [prototype, key] = args; - let property = Object.getOwnPropertyDescriptor(prototype, key)!; + let [prototype, key] = args + let property = Object.getOwnPropertyDescriptor(prototype, key)! if (property?.get) { - let getter = property.get; - getOrAddLegacyObservablePropertyFactories(prototype)[key] = instance => getterReduction(instance, key, getter); + let getter = property.get + + getPrototypePropertyFactories(prototype)[key] = instance => getUnderlyingReduction(instance, key, getter) + return { - get(this: object) { - let observableValue = getObservableValue(this, key as string); - if (!observableValue) - throw new MissingModelDecoratorError(this, key); - return observableValue.value; - }, + get(this: Target) { throw new MissingModelDecoratorError(model, key) }, enumerable: true, configurable: true - }; + } } else { - return observableValueProperty(key, Reduction, () => new InvalidReducedPropertyError(key)); + return sourceSetter(key, Reduction, () => new InvalidReducedPropertyError(key)) } } -} -function getterReduction(instance: object, key: string | symbol, getter: () => Value) { - let observableValue = getUnderlyingObservable(getter.call(instance)); - if (!(observableValue instanceof Reduction)) - throw new InvalidReducedPropertyError(key); - return observableValue; + function getUnderlyingReduction(instance: any, key: PropertyKey, getter: Function) { + let reduction = getUnderlyingSource(getter.call(instance)) + + if (!(reduction instanceof Reduction)) + throw new InvalidReducedPropertyError(key) + + if (reduction.displayName == Reduction.defaultName) + reduction.displayName = String(key) + + return reduction + } } -export function derived(getter: () => Value, context: ClassGetterDecoratorContext): void; -export function derived(valuesEqual: (previous: Value, next: Value) => boolean): { (getter: () => Value, context: ClassGetterDecoratorContext): void; (prototype: Object, key: string | symbol): void; }; -export function derived(prototype: object, key: string | symbol): void; -export function derived(...args: +export function derived(getter: () => Value, context: ClassGetterDecoratorContext): void +export function derived(valuesEqual: (previous: Value, next: Value) => boolean): { (getter: () => Value, context: ClassGetterDecoratorContext): void; (prototype: Object, key: string | symbol): void; } +export function derived(prototype: object, key: string | symbol): void +export function derived(...args: | [(previous: Value, next: Value) => boolean] - | [() => Value, ClassGetterDecoratorContext] + | [() => Value, ClassGetterDecoratorContext] | [object, string | symbol] ) { - let valuesEqual = undefined as ((previous: any, next: any) => boolean) | undefined; + let valuesEqual = undefined as ((previous: any, next: any) => boolean) | undefined if (argsAre(args, isFunction)) { - [valuesEqual] = args; - return decorate; + [valuesEqual] = args + return decorate } else if (argsAre(args, isFunction, isDecoratorContext)) { - return decorate(...args); + return decorate(...args) } else { - return decorate(...args); + return decorate(...args) } - function decorate(getter: () => Value, context: ClassGetterDecoratorContext): void; - function decorate(prototype: object, key: string | symbol): void; - function decorate(...args: - | [() => Value, ClassGetterDecoratorContext] + function decorate(getter: () => Value, context: ClassGetterDecoratorContext): void + function decorate(prototype: object, key: string | symbol): void + function decorate(...args: + | [() => Value, ClassGetterDecoratorContext] | [object, string | symbol] ) { if (argsAre(args, isFunction, isDecoratorContext)) { // Getter property decorator - let [getter, context] = args; - context.addInitializer(function (this: Model) { - assertIsModel(this, context.name); - - getOrAddObservablePropertyFactories(this)[context.name] = () => derive(getter.bind(this), String(context.name), valuesEqual); - }); + let [getter, context] = args + return cachedGetter(getter, context, instance => derive(getter.bind(instance), valuesEqual ?? Object.is, String(context.name))) } else { // Legacy decorator - let [prototype, key] = args; - let property = Object.getOwnPropertyDescriptor(prototype, key)!; + let [prototype, key] = args + let property = Object.getOwnPropertyDescriptor(prototype, key)! if (property?.get) { - let getter = property.get; - let createDerivation = (instance: any) => derive(getter.bind(instance), String(key), valuesEqual); - getOrAddLegacyObservablePropertyFactories(prototype)[key] = createDerivation; + let getter = property.get + let createDerivation = (instance: any) => derive(getter.bind(instance), valuesEqual ?? Object.is, String(key)) + getPrototypePropertyFactories(prototype)[key] = createDerivation return { - get() { return getOrSetObservableValue(this, key, () => createDerivation(this)).value; }, + get(this: Target) { throw new MissingModelDecoratorError(model, key) }, enumerable: true, configurable: true } } - return observableValueProperty(key, Derivation, () => new InvalidDerivedPropertyError(key)); + return sourceSetter(key, Derivation, () => new InvalidDerivedPropertyError(key)) } } } -function observableValueProperty>( - key: string | symbol, - type: new (...args: any) => Type, - createInvalidPropertyError: () => Error -): PropertyDescriptor { - return { set }; - - function set(this: any, value: any) { - let observableValue = getUnderlyingObservable(value); - if (!observableValue || !(observableValue instanceof type)) - throw createInvalidPropertyError(); - observableValue.displayName = String(key); - observableValue.container = this; - getOrAddObservableValues(this)[key as string] = observableValue; +function cachedGetter | IReduction>( + getter: () => SourceValue, + context: ClassGetterDecoratorContext>, + createSource: (instance: Model) => Source +) { + context.addInitializer(function (this: Model) { + getInstancePropertyFactories(this)[context.name] = cachedSource.bind(this) + }) + + return cachedGetter + + /** Ensures source is only created once during model construction. */ + function cachedGetter(this: Model) { + return cachedSource.call(this).value + } + + function cachedSource(this: Model) { + let cache = getModelGetterCache(this) + let source = cache.get(getter) as Source + if (!source) + cache.set(getter, source = createSource(this)) + return source + } +} + +function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISource; defaultName: string; }, errorFactory: () => Error): PropertyDescriptor { + return { set } + + function set(this: object, value: unknown) { + let source = getUnderlyingSource(value) + if (!(source instanceof sourceType)) + throw errorFactory() + + if (source.displayName == sourceType.defaultName) + source.setDisplayName(String(key)) + Object.defineProperty(this, key, { - get: () => observableValue!.value, + get: () => { throw new MissingModelDecoratorError(model, key) }, set, enumerable: true, configurable: true - }); + }) } } -export function state(value: undefined, context: ClassFieldDecoratorContext): void; -export function state(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): void; +/** Mark a property as part of the model's state. */ +export function state(value: undefined, context: ClassFieldDecoratorContext): void +/** Mark a property as part of the model's state. */ +export function state(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): void /** Mark a constructor parameter as a state property by specifying its name. */ -export function state(property: Key): (target: Function, ...rest: any) => void; //..rest: any to support both constructor parameters and classes +export function state(property: Key): (target: Function, ...rest: any) => void //..rest: any to support both constructor parameters and classes /** Mark a property as a state property. */ -export function state(target: object, key: string): void; -export function state(...args: +export function state(target: object, key: string): void +export function state(...args: | string[] | [undefined, ClassFieldDecoratorContext] | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] @@ -233,107 +331,42 @@ export function state(...args: ) { if (argsAre(args, isObject, isString)) { // Legacy decorator - let [instance, key] = args; - addStateProp(instance.constructor.prototype, key); + let [prototype, key] = args + getPrototypePropertyFactories(prototype)[key] = instance => new StateProperty(instance, key) } else if (argsAre(args, isUndefined, isClassFieldDecoratorContext)) { // Field decorator - let [_, context] = args; - context.addInitializer(function (this: Model) { addStateProp(Object.getPrototypeOf(this), String(context.name)); }); + let [_, context] = args + context.addInitializer(function (this: Model) { + getInstancePropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) + }) } else if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { // Auto-accessor property decorator - let [_, context] = args; - context.addInitializer(function (this: Model) { addStateProp(Object.getPrototypeOf(this), String(context.name)); }); + let [_, context] = args + context.addInitializer(function (this: Model) { + getInstancePropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) + }) } else { // Class decorator, or legacy constructor parameter decorator return function decorate(ctor: new (...ctorArgs: any[]) => any) { for (let key of args) - addStateProp(ctor.prototype, key); + getPrototypePropertyFactories(ctor.prototype)[key] = instance => new StateProperty(instance, key) } } } -function addStateProp(prototype: any, key: string) { - getOrAdd(prototype, StateProperties, () => [] as string[]) - .push(key); -} - -function isClassFieldDecoratorContext(context: ClassFieldDecoratorContext | unknown): context is ClassFieldDecoratorContext { - return isDecoratorContext(context) && context.kind == 'field'; -} - -function isClassAccessorDecoratorTarget(context: ClassAccessorDecoratorTarget | unknown): context is ClassAccessorDecoratorTarget { - return isObject(context) && !!(context as ClassAccessorDecoratorTarget).get; -} - -function isClassAccessorDecoratorContext(context: ClassAccessorDecoratorContext | unknown): context is ClassAccessorDecoratorContext { - return isDecoratorContext(context) && context.kind == 'accessor'; -} - -function isDecoratorContext(context: ClassMemberDecoratorContext | unknown): context is DecoratorContext { - return isObject(context) && !!(context as ClassMemberDecoratorContext).kind; -} - -function assertIsModel(model: object, propertyName: string | symbol) { - if (!(ModelClassBrand in model.constructor)) - throw new MissingModelDecoratorError(model, propertyName); -} - -export function isModel(model: T): model is T & object { - return isObject(model) - && (ModelClassBrand in Object.getPrototypeOf(model) // TODO only check this - the other checks are for legacy decorators with legacy fields - || ObservableValues in model - || !!getStateProperties(model).length); -} - -export function extend(reducedValue: T) { - let source = getUnderlyingObservable(reducedValue); - if (source && source instanceof Reduction) - return reduce(reducedValue).on(source, (_, val) => val); - throw new ValueIsNotObservableError(reducedValue); -} - -export function getOrSetObservableValue(instance: any, key: string | symbol, createObservableValue: () => ObservableValue) { - return getOrAdd(getOrAddObservableValues(instance), key, () => Object.assign(createObservableValue(), { container: instance })); -} - -function getOrAddObservableValues(instance: any) { - return getOrAdd(instance, ObservableValues, () => ({} as Record>)); -} - -export function getObservableValue(instance: any, key: string) { - let observableValues = getObservableValues(instance); - return Object.hasOwn(observableValues, key) - ? observableValues[key] - : undefined; -} - -export function getObservableValues(instance: any): { [property: string]: ObservableValue } { - return isObject(instance) && ObservableValues in instance - ? instance[ObservableValues] - : {}; -} - -/** - * Initializing observable properties before construction is complete can cause problems if they access an uninitialized field, - * so we store the initializers on the instance and run them after construction. - **/ -function getOrAddObservablePropertyFactories(instance: any) { - return getOrAdd ObservableValue>>(instance, ObservablePropertyFactories, () => ({})); -} +export class StateProperty implements Disposable { + constructor( + private _instance: any, + public readonly key: PropertyKey + ) { + updateChildren(_instance, null, this.value) + } -/** Same as getOrAddObservablePropertyInitializers, but for old experimental decorators, which only have access to the prototype. */ -function getOrAddLegacyObservablePropertyFactories(prototype: any) { - return getOrAdd ObservableValue>>(prototype, LegacyObservablePropertyFactories, - base => base ? Object.create(base) : {}); // Because we're dealing with prototypes, we need to inherit from base class' factories -} + toJSON() { return getState(this.value) } -export function getStateProperties(model: T): readonly string[] { - if (isPlainObject(model)) - return emptyArray; + get value() { return this._instance[this.key] } - let prototype = Object.getPrototypeOf(model); - return getStateProperties(prototype) - .concat(prototype[StateProperties] as StringKey[] | undefined || emptyArray); + [dispose]() { updateChildren(this._instance, this.value, null) } } /** @@ -342,65 +375,106 @@ export function getStateProperties(model: T): readonly string[] { * Events returned from getters will be snapshotted, so the getter function is only called once. */ export let events = (target: T): T => { - const className = nameOfFunction(target); + const className = nameOfFunction(target) return { [className]: class extends target { constructor(...args: any[]) { - super(...args); + super(...args) for (let [key, property] of getAllPropertyDescriptors(this)) { - let value = this[key]; + let value = this[key] if (isEvent(value)) { - value.displayName = key; - value.container = this; + value.setDisplayName(key) + updateChildren(this as any as EventsClass, null, value, `.${key}`) if (property.get) Object.defineProperty(this, key, { value, configurable: true, enumerable: true - }); + }) } } - (this as any)[EventsClassBrand] = true; + (this as any)[eventsBrand] = true } } - }[className]; + }[className] } -export function isEventsClass(value: unknown): value is Object { +const eventsBrand = Symbol('events class') + +export interface EventsClass { + [eventsBrand]: true +} + +export function isEventsClass(value: unknown): value is EventsClass { return value instanceof Object - && EventsClassBrand in value; + && eventsBrand in value +} + +export function getModelProperties(model: Model): Record { + return model[modelProperties] +} + +function isClassFieldDecoratorContext(context: ClassFieldDecoratorContext | unknown): context is ClassFieldDecoratorContext { + return isDecoratorContext(context) && context.kind == 'field' +} + +function isClassAccessorDecoratorTarget(context: ClassAccessorDecoratorTarget | unknown): context is ClassAccessorDecoratorTarget { + return isObject(context) && !!(context as ClassAccessorDecoratorTarget).get +} + +function isClassAccessorDecoratorContext(context: ClassAccessorDecoratorContext | unknown): context is ClassAccessorDecoratorContext { + return isDecoratorContext(context) && context.kind == 'accessor' +} + +function isDecoratorContext(context: ClassMemberDecoratorContext | unknown): context is DecoratorContext { + return isObject(context) && !!(context as ClassMemberDecoratorContext).kind +} + +/** For new decorators which have access to the instance. */ +function getInstancePropertyFactories(instance: object): Record ModelProperty> { + return getOrAddWeakEntry(instancePropertyFactories, instance, () => ({})) +} + +/** For class decorators and legacy decorators that don't have access to the instance. */ +function getPrototypePropertyFactories(prototype: object): Record ModelProperty> { + return getOrAddWeakEntry(prototypePropertyFactories, prototype, () => ({})) +} + +/** For caching getters during model construction, so accessing them doesn't create extra sources. */ +function getModelGetterCache(instance: object): Map { + return getOrAddWeakEntry(getterCache, instance, () => new Map()) } export class MissingModelDecoratorError extends Error { constructor( public model: object, - public property: string | symbol + public property: PropertyKey ) { - super(`${nameOfFunction(model.constructor)} class must be marked as a @model to use @reduced, @derived, or @state properties.`); + super(`${nameOfFunction(model.constructor)} class must be marked as a @model to use @reduced, @derived, or @state properties.`) } } -export class EventsMarkedAsStateError extends Error { +export class InvalidReducedPropertyError extends Error { constructor( - public model: object, - public property: string | symbol + public property: PropertyKey ) { - super(`Model property ${String(property)} returns an event or events class, and cannot be marked as @state.`); + super(`@reduced property '${String(property)}' must return, or be set to, the value of a reduction.`) } } -export class InvalidReducedPropertyError extends Error { +export class InvalidDerivedPropertyError extends Error { constructor( - public property: string | symbol + public property: PropertyKey ) { - super(`@reduced property '${String(property)}' must return, or be set to, the value of a reduction.`); + super(`@derived property ${String(property)} must have a getter or be set to the value of a derivation.`) } } -export class InvalidDerivedPropertyError extends Error { +export class EventsMarkedAsStateError extends Error { constructor( - public property: string | symbol + public model: object, + public property: PropertyKey ) { - super(`@derived property ${String(property)} must have a getter or be set to the value of a derivation.`); + super(`Model property ${String(property)} returns an event or events class, and cannot be marked as @state.`) } } \ No newline at end of file diff --git a/packages/event-reduce/src/observable.ts b/packages/event-reduce/src/observable.ts deleted file mode 100644 index fd3579d..0000000 --- a/packages/event-reduce/src/observable.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { ISourceInfo, sourceTree } from "./logging"; -import { Unsubscribe } from "./types"; -import { constant, dispose, emptyArray, filteredName, NamedBase, nameOfCallback } from "./utils"; - -export type Observe = (value: T) => void; - -export interface IObservable { - subscribe(observe: Observe, getObserverName?: () => string): Unsubscribe; - [dispose](): void; - filter(condition: (value: T) => boolean, getDisplayName?: () => string): IObservable; - map(select: (value: T) => U, getDisplayName?: () => string): IObservable; - filterMap(select: (value: T) => U | undefined, getDisplayName?: () => string): IObservable; - - displayName: string; - readonly sources: readonly IObservable[]; -} - -export interface IObserver { - getDisplayName(): string; - next: Observe; -} - -export function merge(observables: IObservable[]): IObservable { - let getDisplayName = () => `merged(${observables.map(o => o.displayName).join(', ')})`; - return new ObservableOperation(getDisplayName, observables, - observer => { - let unsubscribes = observables.map(o => o.subscribe(value => observer.next(value), getDisplayName)); - return () => unsubscribes.forEach(u => u()); - }); -} - -const anonymousObserverName = constant("(anonymous observer)"); - -export class Observable extends NamedBase { - protected _observers = new Set>(); - private _sourceInfo?: readonly ISourceInfo[]; - - get sources() { return emptyArray as readonly IObservable[]; } - - get sourceInfo() { return this._sourceInfo ??= sourceTree(this.sources); } - - protected clearSourceInfo() { - delete this._sourceInfo; - for (let observer of this._observers) - if (observer instanceof Observable) - observer.clearSourceInfo(); - } - - get isObserved() { return this._observers.size > 0; } - - subscribe(observe: Observe, getObserverName = anonymousObserverName): Unsubscribe { - let observer = { getDisplayName: getObserverName, next: observe }; - this._observers.add(observer); - return this.unsubscribe.bind(this, observer); - } - - protected unsubscribe(observer: IObserver) { - this._observers.delete(observer); - } - - [dispose]() { - this.unsubscribeFromSources(); - this._observers.clear(); - } - - protected notifyObservers(value: T) { - Array.from(this._observers).forEach(o => o.next(value)); - } - - unsubscribeFromSources() { } - - filter(condition: (value: T) => boolean, getDisplayName: () => string = () => filteredName(this.displayName, condition)): IObservable { - return new ObservableOperation(getDisplayName, [this], - observer => this.subscribe(value => condition(value) && observer.next(value), getDisplayName)); - } - - map(select: (value: T) => U, getDisplayName: () => string = () => `${this.displayName}.map(${nameOfCallback(select)})`): IObservable { - return new ObservableOperation(getDisplayName, [this], - observer => this.subscribe(value => observer.next(select(value)), getDisplayName)); - } - - /** Maps values to a new type, filtering out undefined results. */ - filterMap(select: (value: T) => U | undefined, getDisplayName: () => string = () => `${this.displayName}.filterMap(${nameOfCallback(select)})`): IObservable { - return new ObservableOperation(getDisplayName, [this], - observer => this.subscribe(value => { - let mapped = select(value); - if (mapped !== undefined) - observer.next(mapped); - }, getDisplayName)); - } -} - -export class ObservableOperation extends Observable { - private _unsubscribeFromSources?: Unsubscribe; - - constructor( - getDisplayName: () => string, - private _sources: readonly IObservable[], - private readonly _subscribeToSources: (observer: IObserver) => Unsubscribe - ) { - super(getDisplayName); - } - - override get sources() { return this._sources; } - - override subscribe(observer: Observe, getObserverName = anonymousObserverName): Unsubscribe { - let unsubscribe = super.subscribe(observer, getObserverName); - if (this._observers.size == 1) - this._unsubscribeFromSources = this._subscribeToSources({ getDisplayName: this.displayNameGetter, next: this.notifyObservers.bind(this) }); - return unsubscribe; - } - - protected override unsubscribe(observer: IObserver) { - super.unsubscribe(observer); - if (!this._observers.size) - this.unsubscribeFromSources(); - } - - override unsubscribeFromSources() { - this._unsubscribeFromSources?.(); - } -} - -export function allSources(sources: Iterable>) { - let allSources = new Set>(); - addSourcesRecursive(sources); - return allSources; - - function addSourcesRecursive(sources: Iterable>) { - for (let s of sources) { - if (!allSources.has(s)) { - allSources.add(s); - addSourcesRecursive(s.sources); - } - } - } -} - -export function pathToSource(leaves: Iterable>, root: IObservable): IObservable[] | undefined { - for (let l of leaves) { - if (l == root) - return [l]; - let path = pathToSource(l.sources, root); - if (path) - return path.concat([l]); - } -} - -export function isObservable(maybeObservable: any): maybeObservable is IObservable { - return maybeObservable && typeof maybeObservable.subscribe == 'function'; -} \ No newline at end of file diff --git a/packages/event-reduce/src/observableValue.ts b/packages/event-reduce/src/observableValue.ts deleted file mode 100644 index 1bdd5d5..0000000 --- a/packages/event-reduce/src/observableValue.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { changeOwnedValue } from "./cleanup"; -import { allSources, IObservable, Observable, pathToSource } from "./observable"; -import { Subject } from "./subject"; -import { Action } from "./types"; -import { constant, dispose, firstIntersection, using } from "./utils"; - -let valueAccessed: Subject; -let lastValueConsumed: Subject; -let lastAccess: ValueAccess | undefined; -let triggeringSources = new Set>; -let triggeringObservable: IObservable | undefined; -let latestVersion = 0; // Shared latest version, so no need to track version per source -const accessedObservableValuesName = constant("(accessed observable values)"); -const consumedLastObservableValueName = constant("(consumed last observable value)"); -const lastAccessedName = constant("(last accessed)"); - -startTrackingScope(); - -export interface IObservableValue extends IObservable { - readonly value: T; -} - -export class ObservableValue extends Observable implements IObservableValue { - private readonly _unsettled = new Subject(() => `${this.displayName}.unsettled`); - private _version = 0; - - constructor( - getDisplayName: () => string, - protected _value: T, - private _valuesEqual: (previous: T, next: T) => boolean = (a, b) => a === b - ) { - super(getDisplayName); - changeOwnedValue(this, undefined, _value); - } - - container?: any; - - /** Fired when a source value has changed, and this value _may_ not be up-to-date any more. */ - get unsettled() { return this._unsettled as IObservable; } - - /** Increased whenever the value changes. Latest version is shared, so this won't increase by just 1. */ - get version() { return this._version; } - - get value() { - if (process.env.NODE_ENV !== 'production') { - let commonSource = triggeringSources.size - ? firstIntersection(triggeringSources, allSources([this])) - : undefined; - - valueAccessed.next(commonSource - ? new AccessedValueWithCommonSourceError(commonSource, triggeringObservable!, this) - : this); - } else { - valueAccessed.next(this); - } - - return this._value; - } - - setValue(value: T) { - if (!this._valuesEqual(this._value, value)) { - changeOwnedValue(this, this._value, value); - this._value = value; - this._version = ++latestVersion; - this.notifyObserversUnsettled(); - this.notifyObservers(value); - } - } - - protected notifyObserversUnsettled() { this._unsettled.next(); } - - override[dispose]() { - super[dispose](); - changeOwnedValue(this, this._value, undefined); - // Keep the value, in case this is still being held onto (e.g. with useDeferredValue) - } -} - -export function collectAccessedValues(action: Action) { - let accessedValues: Set>; - using(valueAccessCollection(), collector => { - action(); - accessedValues = new Set(collector.accesses.map(accessedObservable)); - }); - return accessedValues!; -} - -export function protectionAgainstAccessingValueWithCommonSource(currentSource = triggeringObservable): Disposable { - if (process.env.NODE_ENV !== 'production' && currentSource) { - let outerTriggeringObservable = triggeringObservable; - let outerTriggeringSources = triggeringSources; - triggeringObservable = currentSource; - triggeringSources = new Set( - Array.from(outerTriggeringSources) - .concat(Array.from(allSources([currentSource])))); - let collector = valueAccessCollection(); - - return { - [dispose]() { - collector[dispose](); - triggeringObservable = outerTriggeringObservable; - triggeringSources = outerTriggeringSources; - - let invalidAccessError = collector.accesses.find(isAccessError); - if (invalidAccessError) - throw invalidAccessError; - } - } - } else { - return startTrackingScope(); // Still need to create a new scope so accesses aren't picked up by outer scope - } -} - -const accessedValueCollectionName = constant("(accessed value collection)"); - -function valueAccessCollection() { - let accesses = [] as ValueAccess[]; - let unsubscribeFromAccessed = valueAccessed.subscribe(accesses.push.bind(accesses), accessedValueCollectionName); - let unsubscribeFromConsumed = lastValueConsumed.subscribe(accesses.pop.bind(accesses)); - - return { - accesses, - [dispose]() { - unsubscribeFromAccessed(); - unsubscribeFromConsumed(); - } - } -} - -export function startTrackingScope(): Disposable { - let outerValueAccessed = valueAccessed; - let outerLastValueConsumed = lastValueConsumed; - - valueAccessed = new Subject(accessedObservableValuesName); - lastValueConsumed = new Subject(consumedLastObservableValueName); - let unsubscribeLastAccessed = valueAccessed.subscribe(setLastAccess, lastAccessedName); - - return { - [dispose]() { - unsubscribeLastAccessed(); - lastValueConsumed = outerLastValueConsumed; - valueAccessed = outerValueAccessed; - } - } -} - -function setLastAccess(access: ValueAccess) { - lastAccess = access; -} - -/** Allows subscribing to the changes of the specified observable value. */ -export function valueChanged(observableValue: T) { - let observable = getUnderlyingObservable(observableValue); - if (observable) - return observable; - throw new ValueIsNotObservableError(observableValue); -} - -export function getUnderlyingObservable(value: T): ObservableValue | undefined { - let lastAccessed = consumeLastAccessed(); - if (lastAccessed) { - let observableValue: unknown; - using(startTrackingScope(), () => { observableValue = lastAccessed.value; }); - if (observableValue == value) - return lastAccessed; - } -} - -export function consumeLastAccessed() { - if (lastAccess) { - let consumed = lastAccess; - lastValueConsumed.next(); - lastAccess = undefined; - return accessedObservable(consumed); - } -} - -type ValueAccess = ObservableValue | IAccessedObservablueError; -interface IAccessedObservablueError extends Error { readonly accessedObservable: ObservableValue }; - -function isAccessError(value: ValueAccess): value is IAccessedObservablueError { - return (value as IAccessedObservablueError).accessedObservable != undefined; -} - -function accessedObservable(value: ValueAccess): ObservableValue { - return (value as IAccessedObservablueError).accessedObservable ?? value; -} - -export class ValueIsNotObservableError extends Error { - constructor( - public value: unknown - ) { - super("Couldn't detect observable value. Make sure you pass in an observable value directly."); - } -} - -export class AccessedValueWithCommonSourceError extends Error implements IAccessedObservablueError { - constructor( - public commonSource: IObservable, - public triggeringObservable: IObservable, - public accessedObservable: ObservableValue - ) { - super(`Accessed an observable value derived from the same event being fired. -Fired: ${pathToSource([triggeringObservable], commonSource)!.map(o => o.displayName).join(' -> ')} -Accessed: ${pathToSource([accessedObservable], commonSource)!.map(o => o.displayName).join(' -> ')}`); - } -} \ No newline at end of file diff --git a/packages/event-reduce/src/reactions.ts b/packages/event-reduce/src/reactions.ts deleted file mode 100644 index 646e31f..0000000 --- a/packages/event-reduce/src/reactions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Action, Unsubscribe } from "./types"; - -export interface IReactionQueue { - add(reaction: Action): Unsubscribe; -} - -export class MicrotaskQueue implements IReactionQueue { - add(reaction: Action) { - let cancelled = false; - - queueMicrotask(function runReaction() { - if (!cancelled) - reaction(); - }); - - return () => cancelled = true; - } -} - -export const reactionQueue = { - /** Can be replaced for testing purposes */ - current: new MicrotaskQueue() as IReactionQueue -}; \ No newline at end of file diff --git a/packages/event-reduce/src/reduction.ts b/packages/event-reduce/src/reduction.ts index 60f194c..9a44145 100644 --- a/packages/event-reduce/src/reduction.ts +++ b/packages/event-reduce/src/reduction.ts @@ -1,138 +1,150 @@ -import { EventFn, IEventClass, isEvent } from "./events"; -import { log, sourceTree } from "./logging"; -import { isEventsClass } from "./models"; -import { IObservable, allSources, isObservable } from "./observable"; -import { IObservableValue, ObservableValue, protectionAgainstAccessingValueWithCommonSource, valueChanged } from "./observableValue"; -import { State, setState } from "./state"; -import { Subject } from "./subject"; -import { Unsubscribe } from "./types"; -import { emptyArray, isObject, using } from "./utils"; - -export function reduce(initial: TValue, displayName?: string): IReduction; -export function reduce(initial: TValue, events: TEvents, displayName?: string): IBoundReduction; -export function reduce(initial: TValue, events?: TEvents | string, displayName: string = '(anonymous reduction)'): IReduction { - return events && typeof events != 'string' ? new BoundReduction(() => displayName, initial, events) - : typeof events == 'string' ? new Reduction(() => events, initial) - : new Reduction(() => displayName, initial); +import { derive, IDerivation } from "./derivation" +import { startLogging } from "./logging" +import { allSources, IObserver, ISource, isSource, NotASourceValueError, Source } from "./sources" +import { getState, State } from "./state" +import { collectAccessedSources, currentIteration, getUnderlyingSource, managedSourceCreated, trackSourceAccessed, updateChildren } from "./tracking" +import { dispose, emptyArray } from "./utils" + +export interface IReduction extends ISource { + readonly value: T + on(source: ISource, reduce: Reducer): this + on(getSource: (events: Events) => ISource, reduce: Reducer): this } -type Reducer = (previous: TValue, eventValue: TEvent) => TValue; +export function reduce(initialValue: T, displayName?: string): IReduction +export function reduce(initialValue: T, events: Events, displayName?: string): IReduction +export function reduce(initialValue: T, eventsOrDisplayName?: Events | string, maybeDisplayName?: string): IReduction { + let [events, displayName] = typeof eventsOrDisplayName == 'string' + ? [undefined as Events, eventsOrDisplayName] + : [eventsOrDisplayName, maybeDisplayName] -export interface IReduction extends IObservableValue { - on(observable: IObservable, reduce: Reducer): this; - /** @deprecated use valueChanged function instead */ - onValueChanged(observableVaue: TValue, reduce: Reducer): this; - onRestore(reduce: Reducer>): this; + return new Reduction(displayName ?? Reduction.defaultName, initialValue, Object.is, events) } -export interface IBoundReduction extends IReduction { - on(observable: ((events: TEvents) => IObservable) | IObservable, reduce: Reducer): this; +/** Extends an existing source with reduction capabilities. */ +export function extendReduce(observableValue: T, displayName?: string): IReduction { + let source = getUnderlyingSource(observableValue) + if (source) + return reduce(observableValue, displayName ?? source.displayName).on(source, (_, val) => val) + throw new NotASourceValueError(observableValue) } -export class Reduction extends ObservableValue implements IReduction { - private _sources = new Map, Unsubscribe>(); - private _restore = new RestoreSubject>(() => `${this.displayName}.restored`); +export type Reducer = (current: Result, next: Source) => Result - constructor(getDisplayName: () => string, initial: T) { - super(getDisplayName, initial); +export class Reduction extends Source implements IReduction, IObserver { + sources = new Set>() + private _reducers = new Map, (current: T, next: any) => T>() + private _sourcesLastChecked = -1 + private _json: IDerivation> + static defaultName = "(anonymous reduction)" - this.onRestore((current, state) => { - if (isObject(current)) { - setState(current, state); - return current; - } - return state as T; - }); - } + constructor( + displayName: string, + initialValue: T, + private _isEqual = Object.is, + private _events?: Events + ) { + super(displayName) - override get sources() { return Array.from(this._sources.keys()); } + this._json = derive(() => getState(this.value), `toJSON(${this.displayName})`) - restore(state: State): void { - this._restore.next(state); + managedSourceCreated(this) + + this._value = initialValue + updateChildren(this, null, initialValue) } - on(observable: IObservable, reduce: Reducer) { - if (process.env.NODE_ENV !== 'production') { - if (allSources(observable.sources).has(this)) - throw new CircularSubscriptionError(this, observable); - } + toJSON() { return this._json.value; } - let unsubscribeExisting = this._sources.get(observable); - if (unsubscribeExisting) - unsubscribeExisting(); - - this._sources.set(observable, observable.subscribe(eventValue => { - let previousValue = this._value; - let value!: T; - - log('🧪 (reduction)', this.displayName, emptyArray, () => ({ - Previous: previousValue, - Current: value, - Container: this.container, - Sources: sourceTree(this.sources) - }), () => { - if (process.env.NODE_ENV !== 'production') { - using(protectionAgainstAccessingValueWithCommonSource(observable), () => { - value = reduce(this._value, eventValue); - }); - - if (isEvent(value) || isEventsClass(value)) - throw new ReducedEventsError(this, value); - } else { - value = reduce(this._value, eventValue); - } + on(source: ISource, reduce: Reducer): this + on(getSource: (events: Events) => ISource, reduce: Reducer): this + on(source: ISource | ((events: Events) => ISource), reduce: Reducer) { + if (!isSource(source)) + source = source(this._events!) - this.setValue(value) - }); - }, this.displayNameGetter)); + if (process.env.NODE_ENV !== 'production') { + if (allSources(source).find(s => s == this)) + throw new CircularSubscriptionError(this, source); + } - this.clearSourceInfo(); + source.subscribe(this) + this.sources.add(source) + this._reducers.set(source, reduce) + this._sourcesLastChecked = Math.max(this._sourcesLastChecked, source.lastUpdated) // Avoid firing reduction because of new source - return this; + return this } - onValueChanged(observableValue: TValue, reduce: Reducer) { - return this.on(valueChanged(observableValue), reduce); + override getSources() { return this.sources } + + sourceUpdated(source: ISource, value: S) { + if (source.lastUpdated > this.lastUpdated) { + let reduce = this._reducers.get(source) + if (reduce) { + let previousValue = this._value + let nextValue!: T + let logging = startLogging('🧪 (reduction)', this.displayName, emptyArray, () => ({ + Path: this.path, + Trigger: source.path, + Previous: previousValue, + Current: nextValue + })) + + try { + let tracker = collectAccessedSources(this) // Required for detecting circular references in reducers + try { + nextValue = reduce(this._value, value) + } finally { + tracker[dispose]() + } + + this.setValue(nextValue) + } finally { + logging[dispose]() + } + } + } } - onRestore(reduce: Reducer>): this { - return this.on(this._restore, reduce); + override get value() { + let access = trackSourceAccessed(this) // Notifying tracker before checking for updates so circular reference is detected early + + try { + if (this._sourcesLastChecked < currentIteration) { + let newerSource = Array.from(this.sources).find(s => s.lastUpdated > this._sourcesLastChecked) + if (newerSource) + this.sourceUpdated(newerSource, (newerSource as Source).value) + } + } finally { + access[dispose]() + } + + return this._value // Not using super.value to avoid duplicate tracker notification } - override unsubscribeFromSources() { - this._sources.forEach(unsub => unsub()); - this._sources.clear(); + override setValue(value: T) { + this._sourcesLastChecked = currentIteration + super.setValue(value) } -} -class BoundReduction extends Reduction implements IBoundReduction { - constructor( - getDisplayName: () => string, - initial: TValue, - private _events: TEvents = {} as TEvents - ) { super(getDisplayName, initial); } + override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue) } - override on(observable: ((events: TEvents) => IObservable) | IObservable, reduce: Reducer): this { - return super.on(isObservable(observable) ? observable : observable(this._events), reduce); + detach() { + for (let source of this.sources) + source.unsubscribe(this) } -} - -export class RestoreSubject extends Subject { } -export class CircularSubscriptionError extends Error { - constructor( - public reduction: IReduction, - public observable: IObservable - ) { - super(`Cannot subscribe to '${observable.displayName}', as it depends on this reduction, '${reduction.displayName}'.`); + override[dispose]() { + this.detach() + super[dispose]() } } -export class ReducedEventsError extends Error { +export class CircularSubscriptionError extends Error { constructor( - public reduction: IReduction, - public value: EventFn | object + public reduction: IReduction, + public source: ISource ) { - super(`Reduction ${reduction.displayName} reduced to event or events class ${isEvent(value) ? value.displayName : value}. Events cannot be state.`); + super(`Cannot subscribe to '${source.displayName}', as it depends on this reduction, '${reduction.displayName}'.`); } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/derivation.ts b/packages/event-reduce/src/signals/derivation.ts deleted file mode 100644 index 49088f3..0000000 --- a/packages/event-reduce/src/signals/derivation.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { dispose, emptyArray, nameOfFunction } from "../utils" -import { Effect } from "./effect" -import { startLogging } from "./logging" -import { IObserver, ISource, Source } from "./sources" -import { getState, State } from "./state" -import { beginTransition, iterationSource, trackManagedSourceCreation, trackSourceAccessed } from "./tracking" - -export interface IDerivation extends ISource, IObserver { - readonly value: T - - /** - * Forces the value to be re-calculated. - * @param deriveValue Optional function to use to derive the value. If not provided, the original derivation function will be used. - * @param reason Optional reason for the update. If not invalidated by a source change, this will be used as the reason for the update. - **/ - update(deriveValue?: () => T, reason?: string): void -} - -export function derive(derive: () => T, displayName?: string): IDerivation -export function derive(derive: () => T, valuesEqual: (a: T, b: T) => boolean, displayName?: string): IDerivation -export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, b: T) => boolean), maybeDisplayName?: string): IDerivation { - let [skipUpdate, displayName] = typeof displayNameOrSkip == 'function' - ? [displayNameOrSkip, maybeDisplayName] - : [Object.is, displayNameOrSkip] - return new Derivation((displayName ?? nameOfFunction(derive)) || Derivation.defaultName, derive, skipUpdate) -} - -export class Derivation extends Source implements IDerivation { - private _derive: Effect - private _json?: IDerivation> - static defaultName = "(anonymous derivation)" - - constructor( - displayName: string, - derive: () => T, - private _skipUpdate = Object.is - ) { - super(displayName) - this._derive = new Effect(displayName, derive, () => { - if (this._observers.size) - this.update() - }) - } - - toJSON() { - // Lazy init to avoid infinite loop in Derivation constructor - return (this._json ??= derive(() => getState(this.value), `toJSON(${this.displayName})`)).value - } - - get sources() { return this._derive.sources } - - override subscribe(observer: IObserver): void { - if (!this._observers.size) - this._derive.attach() - - if (this.lastUpdated < 0) - this.update() // Subscriber may never be notified if we don't collect any sources - - super.subscribe(observer) - } - - override unsubscribe(observer: IObserver): void { - super.unsubscribe(observer) - - if (!this._observers.size) - this.detach() - } - - detach() { this._derive.detach() } - - override getSources() { return this._derive.getSources() } - - sourceUpdated(source: ISource, value: S) { this._derive.sourceUpdated(source, value) } - - override get value() { - // Notifying tracker before checking for updates so circular reference is detected early - let access = trackSourceAccessed(this) - - try { - if (this._derive.isStale) - this.update() - } finally { - access[dispose]() - } - - return this._value // Not using super.value to avoid duplicate tracker notification - } - - protected override skipUpdate(nextValue: T) { return this._skipUpdate(this._value, nextValue) } - - update(deriveValue?: () => T, reason?: string) { - let previousValue = this._value - let nextValue!: T - let trigger = this._derive.invalidatingSource - - let transition = beginTransition(this, !iterationSource) // Derivations are always safe to access, and only initiate a transition if necessary - let sourceCreation = trackManagedSourceCreation() - let logging = startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ - Path: this.path, - Trigger: trigger?.path ?? '(unknown)', - Previous: previousValue, - Current: nextValue - })) - try { - this.setValue(nextValue = this._derive.run(deriveValue)) - sourceCreation.verifySourceManagement() - } finally { - logging[dispose]() - sourceCreation[dispose]() - transition[dispose]() - } - } - - override[dispose]() { - this._derive[dispose]() - this._json?.[dispose]() - super[dispose]() - } -} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/devtools.ts b/packages/event-reduce/src/signals/devtools.ts deleted file mode 100644 index 88a465c..0000000 --- a/packages/event-reduce/src/signals/devtools.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { getState, State } from "./state" -import { isObject } from "../utils" - -let modelDevTools = new Map() - -export function enableDevTools(model: Model, { restoreState, ...options }: IDevToolsOptions = {}) { - if (process.env.NODE_ENV !== 'production') { - if (!modelDevTools.has(model) && (window as any).__REDUX_DEVTOOLS_EXTENSION__) { - let dev = (window as any).__REDUX_DEVTOOLS_EXTENSION__.connect(options) - modelDevTools.set(model, dev) - dev.init(getState(model)) - let unsubscribe = dev.subscribe((event: any) => { - if (event.type == 'DISPATCH') - restoreState?.(model, JSON.parse(event.state)) - }) - return () => { - modelDevTools.delete(model) - unsubscribe() - } - } - } - return () => { } -} - -export function sendEvent(name: string, arg: any) { - if (process.env.NODE_ENV !== 'production') { - if (modelDevTools.size) { - let event = isObject(arg) - ? getState(arg) - : { value: arg } - if ('type' in event) - event['.type'] = event.type - event.type = name - modelDevTools.forEach((dev, model) => dev.send(event, getState(model))) - } - } -} - -/** - * redux-devtools-extension options from https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md - */ -export interface IDevToolsOptions { - /** - * The instance name to be shown on the monitor page. Default value is document.title. - * If not specified and there's no document title, it will consist of tabId and instanceId. - */ - name?: string - - /** - * Callback to restore state, used when time traveling or importing state from a file. Takes the deserialized state as an argument. - * event-reduce specific - */ - restoreState?: (model: Model, state: State) => void - - /** - * In ms. If more than one action is dispatched in the indicated interval, all new actions will be collected and sent at once. - * It is the joint between performance and speed. When set to 0, all actions will be sent instantly. - * Set it to a higher value when experiencing perf issues (also maxAge to a lower value). Default is 500 ms. - */ - latency?: number - - /** - * Maximum allowed actions to be stored in the history tree. The oldest actions are removed once maxAge is reached. - * It's critical for performance. Default is 50. - */ - maxAge?: number - - /** - * If set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code. - * You can use a function (with action object as argument) which should return new Error().stack string, getting the stack outside of reducers. - * Default to false. - */ - trace?: boolean | ((action: unknown) => string) - - /** - * Maximum stack trace frames to be stored (in case trace option was provided as true). By default it's 10. - * Note that, because extension's calls are excluded, the resulted frames could be 1 less. - * If trace option is a function, traceLimit will have no effect, as it's supposed to be handled there. - */ - traceLimit?: number - - /** - * undefined - will use regular JSON.stringify to send data (it's the fast mode). - * false - will handle also circular references. - * true - will handle also date, regex, undefined, primitives, error objects, symbols, maps, sets and functions. - */ - serialize?: boolean | { - /** - * You can indicate if to include (by setting as true). - * For function key you can also specify a custom function which handles serialization. - */ - options: { - date?: boolean - regex?: boolean - undefined?: boolean - nan?: boolean - infinity?: boolean - error?: boolean - symbol?: boolean - map?: boolean - set?: boolean - function?: boolean | ((fn: Function) => string) - } - - /** - * JSON replacer function used for both actions and states stringify. - * You can specify a data type by adding a __serializedType__ key. So you can deserialize it back while importing or persisting data. - */ - replacer?: (key: string, value: unknown) => any - - /** - * JSON reviver function used for parsing the imported actions and states. - */ - reviver?: (key: string, value: unknown) => any - - /** - * Automatically serialize/deserialize immutablejs via remotedev-serialize. Should be set to the default export of immutablejs. - */ - immutable?: any - - /** - * ImmutableJS Record classes used to make possible restore its instances back when importing, persisting. - */ - refs?: any[] - } - - /** - * Function which takes action object and id number as arguments, and should return action object back. - */ - actionSanitizer?: (action: unknown, id: number) => any - - /** - * Function which takes state object and index as arguments, and should return state object back. - */ - stateSanitizer?: (state: unknown, index: number) => any - - /** - * String or array of strings as regex - actions types to be hidden in the monitors. - * If actionsWhitelist specified, actionsBlacklist is ignored. - */ - actionsBlacklist?: string | string[] - - /** - * String or array of strings as regex - actions types to be shown in the monitors. - * If actionsWhitelist specified, actionsBlacklist is ignored. - */ - actionsWhitelist?: string | string[] - - /** - * Called for every action before sending, takes state and action object, and returns true in case it allows sending the current data to the monitor. - * Use it as a more advanced version of actionsBlacklist/actionsWhitelist parameters. - */ - predicate?: (state: unknown, action: unknown) => boolean - - /** - * Auto pauses when the extension’s window is not opened, and so has zero impact on your app when not in use. Default is false. - */ - autoPause?: boolean -} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/events.ts b/packages/event-reduce/src/signals/events.ts deleted file mode 100644 index 594e00d..0000000 --- a/packages/event-reduce/src/signals/events.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { Scoped } from "../types" -import { dispose, matchesScope } from "../utils" -import { startLogging } from "./logging" -import { type ISource, Source, SourceValue } from "./sources" -import { beginTransition } from "./tracking" - -export interface IEvent extends IEventFunction { - scope>(scope: Scope): IEvent, Out> -} - -export interface IEventFunction extends ISource { - (...args: Args): Return -} - -export function event(name: string = "(anonymous event)"): IEvent { - return makeEventFunction(name, function event(emit: (value: T) => void, value: T) { - let logging = startLogging('⚡ (event)', name, [value], () => ({ Path: this.path })) - try { - emit(value) - } finally { - logging?.[dispose]() - } - return value - }, function eventMembers(e: IEventFunction) { - return { - scope>(scope: Scope): IEvent, T> { - return bindEvent(e, - `${e.displayName}.scoped(${JSON.stringify(scope)})`, - (value: Scoped) => e({ ...value, ...scope } as In), - matchesScope(scope), - eventMembers) as IEvent, T> - } - } - }) as IEvent -} - -export interface IAsyncEvent extends IEventFunction, [promise: PromiseLike, context: Context]> { - started: ISource> - resolved: ISource> - rejected: ISource> - scope>(scope: Scope): IAsyncEvent> -} - -export type AsyncValue = - | AsyncStart - | AsyncResult - | AsyncError - -export interface AsyncStart { - context: Context - promise: PromiseLike -} - -export interface AsyncResult { - context: Context - result: Result -} - -export interface AsyncError { - context: Context - error: any -} - -export function asyncEvent(name: string = "(anonymous async event)"): IAsyncEvent { - return makeEventFunction(name, function asyncEvent

>( - this: IEventFunction, [promise: PromiseLike, context: Context]>, - emit: (value: AsyncValue) => void, - promise: P, - context: Context - ) { - let logging = startLogging('⚡⌚ (async event)', name, [promise, context], () => ({ Path: this.path, Promise: promise })) - try { - emit({ promise, context }) - } finally { - logging?.[dispose]() - } - - promise.then( - result => { - let val = { result, context } - let logging = startLogging('⚡✔ (async resolved)', name, [val], () => ({ Path: this.path, Promise: promise })) - try { - emit(val) - } finally { - logging?.[dispose]() - } - }, - error => { - let val = { error, context } - let logging = startLogging('⚡✘ (async rejected)', name, [val], () => ({ Path: this.path, Promise: promise })) - try { - emit(val) - } finally { - logging?.[dispose]() - } - }) - - return promise - }, function asyncEventMembers(e: IEventFunction, [promise: PromiseLike, context: CurrentContext]>) { - let setBaseDisplayName = e.setDisplayName - return { - started: e.filter(v => 'promise' in v, `${e.displayName}.started`), - resolved: e.filter(v => 'result' in v, `${e.displayName}.resolved`), - rejected: e.filter(v => 'error' in v, `${e.displayName}.rejected`), - scope>(scope: Scope): IAsyncEvent> { - return bindEvent(e, - `${e.displayName}.scoped(${JSON.stringify(scope)})`, - (promise: PromiseLike, context: Scoped) => e(promise, { ...context, ...scope } as CurrentContext), - v => matchesScope(scope, v.context), - asyncEventMembers) as IAsyncEvent> - }, - setDisplayName(name: string) { - setBaseDisplayName.call(e, name) - this.started.setDisplayName(`${name}.started`) - this.resolved.setDisplayName(`${name}.resolved`) - this.rejected.setDisplayName(`${name}.rejected`) - } - } - }) as IAsyncEvent -} - -/** Creates a callable event object. */ -export function makeEventFunction( - name: string, - fire: (this: IEventFunction, emit: (value: T) => void, ...args: Args) => Return, - getMembers?: AdditionalMembersFactory, Members> -): IEventFunction & Members { - let eventSource = new Event(name) as Event & IEventFunction - return makeSourceFunction(eventSource, event, getMembers) - - function event(...args: Args) { return fire.call(event as IEventFunction, eventSource.emit.bind(event), ...args) } -} - -/** Wrap an event with input and output filter. Useful for scoping. */ -export function bindEvent, Args extends any[], Return, Members extends object>( - event: E, - displayName: string, - fire: (...args: Args) => Return, - predicate: (value: SourceValue) => boolean, - getMembers?: AdditionalMembersFactory, Members> -) { - return makeSourceFunction(event.filter(predicate, displayName), fire, getMembers) -} - -function makeSourceFunction, Args extends any[], Return, Members extends object>( - innerEvent: Source, - fire: (...args: Args) => Return, - getMembers?: AdditionalMembersFactory -): IEventFunction, Args, Return> & Members { - let eventFn = fire as Source & IEventFunction, Args, Return> - Object.setPrototypeOf(eventFn, innerEvent); - eventFn.apply = Function.prototype.apply; - eventFn.call = Function.prototype.call; - return getMembers - ? Object.assign(eventFn, getMembers(eventFn)) - : eventFn as Source & IEventFunction, Args, Return> & Members -} - -export type AdditionalMembersFactory, Members extends object> = - (source: Source & IEventFunction, any>) => Members - -class Event extends Source { - emit(value: T) { - let transition = beginTransition(this, true) - try { - this.setValue(value) - } finally { - this._value = undefined! // Events shouldn't hold on to their values - transition[dispose]() - } - } - override isValidAsState = false - override skipUpdate(nextValue: T) { return false } - protected override updateChildren(previousValue: T, nextValue: T) { } // Value won't be held onto -} - -export function isEvent(value: any): value is IEventFunction { - return typeof value == 'function' - && value instanceof Event -} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/index.ts b/packages/event-reduce/src/signals/index.ts deleted file mode 100644 index 76a8016..0000000 --- a/packages/event-reduce/src/signals/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { Atom } from "./atom" -export { derive, type IDerivation } from "./derivation" -export { enableDevTools } from "./devtools" -export { Effect } from "./effect" -export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IEvent } from "./events" -export { enableLogging, log, startLogging } from "./logging" -export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" -export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" -export { type IObserver, type ISource } from "./sources" -export { getState, type State } from "./state" diff --git a/packages/event-reduce/src/signals/logging.ts b/packages/event-reduce/src/signals/logging.ts deleted file mode 100644 index 872c6bc..0000000 --- a/packages/event-reduce/src/signals/logging.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { dispose, nullDisposable } from "../utils"; - -let loggingEnabled = false; - -let logStack = [[]] as LogItem[][]; - -type LogItem = LogMessage | LogGroup; - -type LogMessage = any[]; - -type LogGroup = { - message: LogMessage; - children: LogItem[]; -} - -/** - * For logged values that have their own specific log formatting. - * `args` will be passed as separate arguments to `console.log` - **/ -export class LogValue { constructor(public args: unknown[]) { } } - -export function enableLogging(enable = true) { - loggingEnabled = enable; -} - -export function startLogging(type: string, displayName: string, args: readonly any[], getInfo?: () => Info): Disposable { - if (process.env.NODE_ENV !== 'production' && loggingEnabled) { - logStack.push([]); - return { [dispose]() { logMessage(type, displayName, args, getInfo, logStack.pop()!); } } - } else { - return nullDisposable - } -} - -export function log(type: string, displayName: string, args: readonly any[], getInfo?: () => Info) { - if (process.env.NODE_ENV !== 'production' && loggingEnabled) - logMessage(type, displayName, args, getInfo); -} - -function logMessage(type: string, displayName: string, args: readonly any[], getInfo?: () => Info, childLogs: LogItem[] = []) { - let group = { - message: [`${displayName} %c${type}`, 'color: grey; font-weight: normal;', ...args], - children: Object.entries(getInfo?.() ?? {} as Info) - .map(([key, value]) => value instanceof LogValue - ? [`${key}:`, ...value.args] - : [`${key}:`, value]) - .concat(childLogs) - }; - logStack.at(-1)!.push(group); - - if (logStack.length == 1) { - flushLogs(logStack[0]); - logStack = [[]]; - } -} - -function flushLogs(logs: LogItem[]) { - for (let log of logs) { - if (Array.isArray(log)) { - console.log(...log); - } else if (!log.children.length) { - console.log(...log.message); - } else { - console.groupCollapsed(...log.message); - flushLogs(log.children); - console.groupEnd(); - } - } -} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/models.ts b/packages/event-reduce/src/signals/models.ts deleted file mode 100644 index 77b11ac..0000000 --- a/packages/event-reduce/src/signals/models.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { argsAre, constant, dispose, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "../utils" -import { Derivation, derive, IDerivation } from "./derivation" -import { isEvent } from "./events" -import { IReduction, Reduction } from "./reduction" -import { ISource, isSource, SourceValue } from "./sources" -import { getState } from "./state" -import { collectAccessedSources, getChildNodes, getUnderlyingSource, trackManagedSourceCreation, updateChildren } from "./tracking" - -const modelBrand = Symbol('model class') -const modelProperties = Symbol('model properties') - -export interface Model extends Disposable { - [modelProperties]: Record -} - -export type ModelProperty = ISource | StateProperty - -export function isModel(value: unknown): value is Model { - return isObject(value) - && modelProperties in value -} - -/** instance -> key -> factory */ -let instancePropertyFactories = new WeakMap ModelProperty>>() -/** prototype -> key -> factory */ -let prototypePropertyFactories = new WeakMap ModelProperty>>() -/** - * instance -> getter -> property source - * Required for getters to cache their source without being overridden in sub-classes -*/ -let getterCache = new WeakMap>>() - - -/** Creates a loose model object. Not required if your model is already decorated with \@{@link model}. */ -export function createModel(create: () => T): T & Model { - let sourceCreation = trackManagedSourceCreation() - let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this - try { - let instance = create() - if (isModel(instance)) - return instance - let model = Object.create(instance, { - [modelProperties]: { - value: Object.fromEntries( - getChildNodes(instance) - .entries() - .flatMap(([node, path]) => isSource(node) ? [[path, node]] : [])) - }, - [dispose]: { - value() { - Object.values(this[modelProperties]) - .filter(isDisposable) - .forEach(p => p[dispose]()) - if (isDisposable(instance)) - instance[dispose]() - } - } - }) - updateChildren(model, null, instance) - sourceCreation.verifySourceManagement() - return model - } finally { - accesses[dispose]() - sourceCreation[dispose]() - } -} - -export function disposeModel(model: object) { - if (isModel(model)) - model[dispose]() -} - -/** - * Marks a class as a model. - * Initializes \@{@link reduced} and \@{@link derived} properties. - * Observable values accessed during construction will not be tracked. - * The model instance will take ownership of all observable and state properties. - **/ -export function model any>(target: Class): Class { - const className = nameOfFunction(target) - let thisCtor: new (...args: any[]) => any - return thisCtor = { - [className]: class extends target { - static [modelBrand] = true - constructor(...args: any[]) { - let sourceCreation = trackManagedSourceCreation() - let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this - try { - super(...args) - - let model = this as any as Model - - let properties = {} as Record - - let prototypeFactories = getPrototypePropertyFactories(target.prototype) - for (let key of Object.keys(prototypeFactories)) - properties[key] = initializeModelProperty(model, key, prototypeFactories[key]) - - let instanceFactories = getInstancePropertyFactories(model) - for (let [key, factory] of Object.entries(instanceFactories)) - properties[key] = initializeModelProperty(model, key, factory) - - updateChildren(model, null, properties) // Sets up children with correct paths - sourceCreation.verifySourceManagement() - - // Layer this class' properties onto the super class' properties - model[modelProperties] = Object.assign( - Object.create(model[modelProperties] ?? {}), - properties) - - instancePropertyFactories.delete(model) // No longer needed, and avoids sub-models re-initializing properties - - // Outer-most constructor setup - let outerMostModelCtor = getPrototypeChain(new.target).find(ctor => modelBrand in ctor) - if (outerMostModelCtor == thisCtor) { - getterCache.delete(model) // Only needed during construction - - let json = derive(() => { - let obj = {} as Record - for (let key in model[modelProperties]) - obj[key] = model[modelProperties][key] - return obj - }, `toJSON(${className})`) - - Object.assign(this, { - toJSON() { return super.toJSON?.() ?? json.value }, - [dispose]() { - // Make sure to cover all the properties in the prototype chain - for (let layer of getPrototypeChain(model[modelProperties])) - for (let property of Object.values(layer)) - updateChildren(model, property, null) - json[dispose]() - super[dispose]?.() - } - }) - } - } finally { - accesses[dispose]() - sourceCreation[dispose]() - } - } - } - }[className] -} - -function initializeModelProperty

(instance: Model, key: PropertyKey, factory: (instance: any) => P) { - let property = factory(instance) - if (isSource(property)) { // No need to do this for state properties - Object.defineProperty(instance, key, { - // Bind directly to the property for cleaner call stack - get: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(property), 'value')!.get!.bind(property), - enumerable: true, - configurable: true - }) - } else if (process.env.NODE_ENV !== 'production' - && (isEvent(property.value) || isEventsClass(property.value)) - ) { - throw new EventsMarkedAsStateError(instance, key) - } - return property -} - -export function reduced(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): ClassAccessorDecoratorResult -export function reduced(getter: () => Value, context: ClassGetterDecoratorContext): void -export function reduced(prototype: object, key: string | symbol): void -export function reduced(...args: - | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] - | [() => Value, ClassGetterDecoratorContext] - | [Object, string | symbol] -) { - if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { - // Auto-accessor property decorator - let [target, context] = args - - return { - init(this: Target, initial: Value) { - let reduction = getUnderlyingReduction(this, context.name, () => initial) - getModelGetterCache(this).set(target.get, reduction) - getInstancePropertyFactories(this)[context.name] = constant(reduction) - return initial - }, - get(this: Target) { return (getModelGetterCache(this).get(target.get) as Reduction)!.value }, - } - } else if (argsAre(args, isFunction, isDecoratorContext)) { - // Getter property decorator - let [getter, context] = args - return cachedGetter(getter, context, instance => getUnderlyingReduction(instance, context.name, getter)) - - } else { - // Legacy decorator - let [prototype, key] = args - let property = Object.getOwnPropertyDescriptor(prototype, key)! - - if (property?.get) { - let getter = property.get - - getPrototypePropertyFactories(prototype)[key] = instance => getUnderlyingReduction(instance, key, getter) - - return { - get(this: Target) { throw new MissingModelDecoratorError(model, key) }, - enumerable: true, - configurable: true - } - } else { - return sourceSetter(key, Reduction, () => new InvalidReducedPropertyError(key)) - } - } - - function getUnderlyingReduction(instance: any, key: PropertyKey, getter: Function) { - let reduction = getUnderlyingSource(getter.call(instance)) - - if (!(reduction instanceof Reduction)) - throw new InvalidReducedPropertyError(key) - - if (reduction.displayName == Reduction.defaultName) - reduction.displayName = String(key) - - return reduction - } -} - -export function derived(getter: () => Value, context: ClassGetterDecoratorContext): void -export function derived(valuesEqual: (previous: Value, next: Value) => boolean): { (getter: () => Value, context: ClassGetterDecoratorContext): void; (prototype: Object, key: string | symbol): void; } -export function derived(prototype: object, key: string | symbol): void -export function derived(...args: - | [(previous: Value, next: Value) => boolean] - | [() => Value, ClassGetterDecoratorContext] - | [object, string | symbol] -) { - let valuesEqual = undefined as ((previous: any, next: any) => boolean) | undefined - - if (argsAre(args, isFunction)) { - [valuesEqual] = args - return decorate - } else if (argsAre(args, isFunction, isDecoratorContext)) { - return decorate(...args) - } else { - return decorate(...args) - } - - function decorate(getter: () => Value, context: ClassGetterDecoratorContext): void - function decorate(prototype: object, key: string | symbol): void - function decorate(...args: - | [() => Value, ClassGetterDecoratorContext] - | [object, string | symbol] - ) { - if (argsAre(args, isFunction, isDecoratorContext)) { - // Getter property decorator - let [getter, context] = args - return cachedGetter(getter, context, instance => derive(getter.bind(instance), valuesEqual ?? Object.is, String(context.name))) - } else { - // Legacy decorator - let [prototype, key] = args - let property = Object.getOwnPropertyDescriptor(prototype, key)! - - if (property?.get) { - let getter = property.get - let createDerivation = (instance: any) => derive(getter.bind(instance), valuesEqual ?? Object.is, String(key)) - getPrototypePropertyFactories(prototype)[key] = createDerivation - return { - get(this: Target) { throw new MissingModelDecoratorError(model, key) }, - enumerable: true, - configurable: true - } - } - - return sourceSetter(key, Derivation, () => new InvalidDerivedPropertyError(key)) - } - } -} - -function cachedGetter | IReduction>( - getter: () => SourceValue, - context: ClassGetterDecoratorContext>, - createSource: (instance: Model) => Source -) { - context.addInitializer(function (this: Model) { - getInstancePropertyFactories(this)[context.name] = cachedSource.bind(this) - }) - - return cachedGetter - - /** Ensures source is only created once during model construction. */ - function cachedGetter(this: Model) { - return cachedSource.call(this).value - } - - function cachedSource(this: Model) { - let cache = getModelGetterCache(this) - let source = cache.get(getter) as Source - if (!source) - cache.set(getter, source = createSource(this)) - return source - } -} - -function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISource; defaultName: string; }, errorFactory: () => Error): PropertyDescriptor { - return { set } - - function set(this: object, value: unknown) { - let source = getUnderlyingSource(value) - if (!(source instanceof sourceType)) - throw errorFactory() - - if (source.displayName == sourceType.defaultName) - source.setDisplayName(String(key)) - - Object.defineProperty(this, key, { - get: () => { throw new MissingModelDecoratorError(model, key) }, - set, - enumerable: true, - configurable: true - }) - } -} - -/** Mark a property as part of the model's state. */ -export function state(value: undefined, context: ClassFieldDecoratorContext): void -/** Mark a property as part of the model's state. */ -export function state(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): void -/** Mark a constructor parameter as a state property by specifying its name. */ -export function state(property: Key): (target: Function, ...rest: any) => void //..rest: any to support both constructor parameters and classes -/** Mark a property as a state property. */ -export function state(target: object, key: string): void -export function state(...args: - | string[] - | [undefined, ClassFieldDecoratorContext] - | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] - | [string] - | [object, string] -) { - if (argsAre(args, isObject, isString)) { - // Legacy decorator - let [prototype, key] = args - getPrototypePropertyFactories(prototype)[key] = instance => new StateProperty(instance, key) - } else if (argsAre(args, isUndefined, isClassFieldDecoratorContext)) { - // Field decorator - let [_, context] = args - context.addInitializer(function (this: Model) { - getInstancePropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) - }) - } else if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { - // Auto-accessor property decorator - let [_, context] = args - context.addInitializer(function (this: Model) { - getInstancePropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) - }) - } else { - // Class decorator, or legacy constructor parameter decorator - return function decorate(ctor: new (...ctorArgs: any[]) => any) { - for (let key of args) - getPrototypePropertyFactories(ctor.prototype)[key] = instance => new StateProperty(instance, key) - } - } -} - -export class StateProperty implements Disposable { - constructor( - private _instance: any, - public readonly key: PropertyKey - ) { - updateChildren(_instance, null, this.value) - } - - toJSON() { return getState(this.value) } - - get value() { return this._instance[this.key] } - - [dispose]() { updateChildren(this._instance, this.value, null) } -} - -/** - * Marks a class as an events class. - * Automatically populates the names of event properties. - * Events returned from getters will be snapshotted, so the getter function is only called once. - */ -export let events = (target: T): T => { - const className = nameOfFunction(target) - return { - [className]: class extends target { - constructor(...args: any[]) { - super(...args) - for (let [key, property] of getAllPropertyDescriptors(this)) { - let value = this[key] - if (isEvent(value)) { - value.setDisplayName(key) - updateChildren(this as any as EventsClass, null, value, `.${key}`) - if (property.get) - Object.defineProperty(this, key, { - value, - configurable: true, - enumerable: true - }) - } - } - (this as any)[eventsBrand] = true - } - } - }[className] -} - -const eventsBrand = Symbol('events class') - -export interface EventsClass { - [eventsBrand]: true -} - -export function isEventsClass(value: unknown): value is EventsClass { - return value instanceof Object - && eventsBrand in value -} - -export function getModelProperties(model: Model): Record { - return model[modelProperties] -} - -function isClassFieldDecoratorContext(context: ClassFieldDecoratorContext | unknown): context is ClassFieldDecoratorContext { - return isDecoratorContext(context) && context.kind == 'field' -} - -function isClassAccessorDecoratorTarget(context: ClassAccessorDecoratorTarget | unknown): context is ClassAccessorDecoratorTarget { - return isObject(context) && !!(context as ClassAccessorDecoratorTarget).get -} - -function isClassAccessorDecoratorContext(context: ClassAccessorDecoratorContext | unknown): context is ClassAccessorDecoratorContext { - return isDecoratorContext(context) && context.kind == 'accessor' -} - -function isDecoratorContext(context: ClassMemberDecoratorContext | unknown): context is DecoratorContext { - return isObject(context) && !!(context as ClassMemberDecoratorContext).kind -} - -/** For new decorators which have access to the instance. */ -function getInstancePropertyFactories(instance: object): Record ModelProperty> { - return getOrAddWeakEntry(instancePropertyFactories, instance, () => ({})) -} - -/** For class decorators and legacy decorators that don't have access to the instance. */ -function getPrototypePropertyFactories(prototype: object): Record ModelProperty> { - return getOrAddWeakEntry(prototypePropertyFactories, prototype, () => ({})) -} - -/** For caching getters during model construction, so accessing them doesn't create extra sources. */ -function getModelGetterCache(instance: object): Map { - return getOrAddWeakEntry(getterCache, instance, () => new Map()) -} - -export class MissingModelDecoratorError extends Error { - constructor( - public model: object, - public property: PropertyKey - ) { - super(`${nameOfFunction(model.constructor)} class must be marked as a @model to use @reduced, @derived, or @state properties.`) - } -} - -export class InvalidReducedPropertyError extends Error { - constructor( - public property: PropertyKey - ) { - super(`@reduced property '${String(property)}' must return, or be set to, the value of a reduction.`) - } -} - -export class InvalidDerivedPropertyError extends Error { - constructor( - public property: PropertyKey - ) { - super(`@derived property ${String(property)} must have a getter or be set to the value of a derivation.`) - } -} - -export class EventsMarkedAsStateError extends Error { - constructor( - public model: object, - public property: PropertyKey - ) { - super(`Model property ${String(property)} returns an event or events class, and cannot be marked as @state.`) - } -} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/reduction.ts b/packages/event-reduce/src/signals/reduction.ts deleted file mode 100644 index c8e185a..0000000 --- a/packages/event-reduce/src/signals/reduction.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { dispose, emptyArray } from "../utils" -import { derive, IDerivation } from "./derivation" -import { startLogging } from "./logging" -import { allSources, IObserver, ISource, isSource, NotASourceValueError, Source } from "./sources" -import { getState, State } from "./state" -import { collectAccessedSources, currentIteration, getUnderlyingSource, managedSourceCreated, trackSourceAccessed, updateChildren } from "./tracking" - -export interface IReduction extends ISource { - readonly value: T - on(source: ISource, reduce: Reducer): this - on(getSource: (events: Events) => ISource, reduce: Reducer): this -} - -export function reduce(initialValue: T, displayName?: string): IReduction -export function reduce(initialValue: T, events: Events, displayName?: string): IReduction -export function reduce(initialValue: T, eventsOrDisplayName?: Events | string, maybeDisplayName?: string): IReduction { - let [events, displayName] = typeof eventsOrDisplayName == 'string' - ? [undefined as Events, eventsOrDisplayName] - : [eventsOrDisplayName, maybeDisplayName] - - return new Reduction(displayName ?? Reduction.defaultName, initialValue, Object.is, events) -} - -/** Extends an existing source with reduction capabilities. */ -export function extendReduce(observableValue: T, displayName?: string): IReduction { - let source = getUnderlyingSource(observableValue) - if (source) - return reduce(observableValue, displayName ?? source.displayName).on(source, (_, val) => val) - throw new NotASourceValueError(observableValue) -} - -export type Reducer = (current: Result, next: Source) => Result - -export class Reduction extends Source implements IReduction, IObserver { - sources = new Set>() - private _reducers = new Map, (current: T, next: any) => T>() - private _sourcesLastChecked = -1 - private _json: IDerivation> - static defaultName = "(anonymous reduction)" - - constructor( - displayName: string, - initialValue: T, - private _isEqual = Object.is, - private _events?: Events - ) { - super(displayName) - - this._json = derive(() => getState(this.value), `toJSON(${this.displayName})`) - - managedSourceCreated(this) - - this._value = initialValue - updateChildren(this, null, initialValue) - } - - toJSON() { return this._json.value; } - - on(source: ISource, reduce: Reducer): this - on(getSource: (events: Events) => ISource, reduce: Reducer): this - on(source: ISource | ((events: Events) => ISource), reduce: Reducer) { - if (!isSource(source)) - source = source(this._events!) - - if (process.env.NODE_ENV !== 'production') { - if (allSources(source).find(s => s == this)) - throw new CircularSubscriptionError(this, source); - } - - source.subscribe(this) - this.sources.add(source) - this._reducers.set(source, reduce) - this._sourcesLastChecked = Math.max(this._sourcesLastChecked, source.lastUpdated) // Avoid firing reduction because of new source - - return this - } - - override getSources() { return this.sources } - - sourceUpdated(source: ISource, value: S) { - if (source.lastUpdated > this.lastUpdated) { - let reduce = this._reducers.get(source) - if (reduce) { - let previousValue = this._value - let nextValue!: T - let logging = startLogging('🧪 (reduction)', this.displayName, emptyArray, () => ({ - Path: this.path, - Trigger: source.path, - Previous: previousValue, - Current: nextValue - })) - - try { - let tracker = collectAccessedSources(this) // Required for detecting circular references in reducers - try { - nextValue = reduce(this._value, value) - } finally { - tracker[dispose]() - } - - this.setValue(nextValue) - } finally { - logging[dispose]() - } - } - } - } - - override get value() { - let access = trackSourceAccessed(this) // Notifying tracker before checking for updates so circular reference is detected early - - try { - if (this._sourcesLastChecked < currentIteration) { - let newerSource = Array.from(this.sources).find(s => s.lastUpdated > this._sourcesLastChecked) - if (newerSource) - this.sourceUpdated(newerSource, (newerSource as Source).value) - } - } finally { - access[dispose]() - } - - return this._value // Not using super.value to avoid duplicate tracker notification - } - - override setValue(value: T) { - this._sourcesLastChecked = currentIteration - super.setValue(value) - } - - override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue) } - - detach() { - for (let source of this.sources) - source.unsubscribe(this) - } - - override[dispose]() { - this.detach() - super[dispose]() - } -} - -export class CircularSubscriptionError extends Error { - constructor( - public reduction: IReduction, - public source: ISource - ) { - super(`Cannot subscribe to '${source.displayName}', as it depends on this reduction, '${reduction.displayName}'.`); - } -} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/state.ts b/packages/event-reduce/src/signals/state.ts deleted file mode 100644 index 5c42f41..0000000 --- a/packages/event-reduce/src/signals/state.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { isObject } from "../utils" - -export type State = T extends { toJSON(): infer S } - ? State - : T - -export function getState(model: T): State { - return isObject(model) && typeof (model as any).toJSON == 'function' - ? (model as any).toJSON() - : model as State -} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/testing.ts b/packages/event-reduce/src/signals/testing.ts deleted file mode 100644 index 0cf45b1..0000000 --- a/packages/event-reduce/src/signals/testing.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { dispose, isObject } from "../utils" -import { Atom } from "./atom" -import { Derivation } from "./derivation" -import { event } from "./events" -import { getModelProperties, isModel } from "./models" -import { Reduction } from "./reduction" -import { isSource, Source } from "./sources" -import { beginTransition } from "./tracking" - -const targetAccessor = Symbol('target') - -export function mutable(model: T): Mutable { - if (isModel(model)) { - let properties = getModelProperties(model) - - for (let key in properties) { - let property = properties[key] - if (isSource(property)) { - Object.defineProperty(model, key, { - get() { return (property as Source).value }, - set(value) { - if (property instanceof Reduction || property instanceof Derivation) - property.detach() - - if (property instanceof Source) { - let transition = beginTransition(property, true) - try { - property.setValue(value) - } finally { - transition[dispose]() - } - } - }, - enumerable: Object.getOwnPropertyDescriptor(model, key)?.enumerable ?? true, - configurable: true - }) - } - } - - (model as any)[targetAccessor] = model - - if (!('target' in model)) - Object.defineProperty(model, 'target', { value: model, configurable: true }) - } - return model as Mutable -} - -export function modelProxy(initialState: T): Mutable -export function modelProxy(): Mutable -export function modelProxy(model: T = {} as T) { - if (!isObject(model) || Array.isArray(model)) - return model - - let properties = {} as Record> - - // Pre-populate observable values with plain value fields - Object.entries(Object.getOwnPropertyDescriptors(model)) - .forEach(([key, prop]) => { - if ('value' in prop) - properties[key] = new Atom(String(key), prop.value) - }) - - let proxy: T = new Proxy(model, { - get(target: T, key: PropertyKey, receiver: T) { - if (key == 'target' && !('target' in target) - || key == targetAccessor - ) { - return proxy - } - - if (properties.hasOwnProperty(key)) - return properties[key as string].value - - if (key in target) - return Reflect.get(target, key, receiver) - - return getOrAddObservableValue(key).value - }, - - set(target: T, key: PropertyKey, value: any) { - if (key == 'target') - return false - - getOrAddObservableValue(key).setValue(value) - return true - }, - - ownKeys(target: T) { - return Array.from(new Set(Object.keys(target).concat(Object.keys(properties)))) - }, - - has(target: T, key: PropertyKey) { - return key in target - || key in properties - } - }) - - return proxy - - function getOrAddObservableValue(key: PropertyKey): Atom { - return properties[String(key)] ??= new Atom(String(key), model[key as keyof T]) - } -} - -export type Mutable = - & { -readonly [P in keyof T]: T[P] } - & (T extends { target: any } - ? {} - : { - /** The model proxy as the original type */ - readonly target: T - }) - -export function eventProxy(): T -export function eventProxy void>(createEvent: () => TEvent): { [key: string]: TEvent } -export function eventProxy void, T = any>(createEvent: () => TEvent): T & { [P in keyof T]: TEvent } -export function eventProxy(createEvent: () => any = event) { - let event = createEvent() - return new Proxy(event, { - get(target: any, key: PropertyKey) { - if (typeof key == 'string') - return target[key] || (target[key] = eventProxy(createEvent)) - else if (key == targetAccessor) - return target - }, - - apply(target: any, thisArg: any, argArray?: any) { - event(...argArray) - } - }) -} - -export function getTarget(proxy: T): T { - let target = (proxy as any)[targetAccessor] - if (!target) - throw new NotAProxyError() - return target -} - -export class NotAProxyError extends Error { - constructor() { super('Object is not a proxy created by event-reduce testing utilities.') } -} \ No newline at end of file diff --git a/packages/event-reduce/src/signals/sources.ts b/packages/event-reduce/src/sources.ts similarity index 96% rename from packages/event-reduce/src/signals/sources.ts rename to packages/event-reduce/src/sources.ts index 36731d1..e8e839d 100644 --- a/packages/event-reduce/src/signals/sources.ts +++ b/packages/event-reduce/src/sources.ts @@ -1,7 +1,7 @@ -import { dispose, emptyArray, identity, nameOfCallback, nameOfFunction } from "../utils" import type { IEventFunction } from "./events" import { isEventsClass } from "./models" import { currentIteration, getPath, iterationSource, trackSourceAccessed, updateChildren } from "./tracking" +import { dispose, emptyArray, identity, nameOfCallback, nameOfFunction } from "./utils" export interface ISource extends Disposable { readonly displayName: string diff --git a/packages/event-reduce/src/state.ts b/packages/event-reduce/src/state.ts index 4089d75..3aaa7e8 100644 --- a/packages/event-reduce/src/state.ts +++ b/packages/event-reduce/src/state.ts @@ -1,129 +1,11 @@ -import { getObservableValues, getStateProperties, isModel } from "./models"; -import { Reduction } from "./reduction"; -import { OmitValues, StringKey } from "./types"; -import { isObject, jsonPath } from "./utils"; +import { isObject } from "./utils" -export type State = - T extends Function ? never - : T extends Array ? State[] - : T extends object ? { [K in StringKey>]: State } - : T; +export type State = T extends { toJSON(): infer S } + ? State + : T -export type StateKey = Extract>; - -export interface IStateOptions { - /** - * Name of root model, used for errors - * @default $ - **/ - name?: string; - /** @default false */ - includeDerivedProperties?: boolean; - /** @default 'expect' in production, otherwise 'error' */ - circularReferences?: CircularReferenceHandling; -} - -export type CircularReferenceHandling = 'error' | 'warn' | 'expect'; - -export function getState(model: T, options: IStateOptions = {}): State { - return getStateRecursive(options, new Map(), options.name ?? '$', model); -} - -function getStateRecursive(options: IStateOptions, parentPath: Map, key: PropertyKey, model: T): State { - if (!isObject(model)) - return model as State; - - if (parentPath.has(model)) - return getCircularReference(options.circularReferences ?? (process.env.NODE_ENV === 'production' ? 'expect' : 'error'), parentPath, key, model); - - parentPath.set(model, key); - let state = Array.isArray(model) - ? getArrayState(options, parentPath, model) - : getObjectState(options, parentPath, model); - parentPath.delete(model); - return state; -} - -function getArrayState(options: IStateOptions, parentPath: Map, model: T & any[]) { - return model.map((value, key) => getStateRecursive(options, parentPath, key, value)) as State; -} - -function getObjectState(options: IStateOptions, parentPath: Map, model: T) { - let state = {} as State; - for (let key of getAllStatefulProperties(model, options.includeDerivedProperties)) { - let value = model[key]; - if (typeof value != 'function') - state[key] = getStateRecursive(options, parentPath, key, value) as State[StateKey]; - } - return state; -} - -function getCircularReference(handling: CircularReferenceHandling, parentPath: Map, key: PropertyKey, model: T) { - let circularPath = Array.from(parentPath) - .map(([value, key]) => [key, value] as [PropertyKey, unknown]) - .concat([[key, model]]); - - let circularPathKeys = circularPath.map(([key]) => key); - let referencedPathKeys = circularPathKeys.slice(0, circularPath.findIndex(([, v]) => v == model) + 1); - - if (handling == 'warn') - console.warn(CircularReferenceInStateError.message(circularPathKeys, referencedPathKeys), new Details(circularPath)); - - if (handling == 'error') - throw new CircularReferenceInStateError(circularPath, circularPathKeys, referencedPathKeys); - - return `` as State; -} - -// Just for naming in the console -class Details { constructor(public circularPath: (readonly [PropertyKey, unknown])[]) { } } - -export function setState(model: T, state: State) { - let observableProps = getReducedProperties(model); - let stateProps = getAllStatefulProperties(model) - .filter(key => key in (state as object)); - - stateProps.forEach(key => { - if (key in observableProps) - observableProps[key].restore(state[key]); - else if (isObject(model[key])) - setState(model[key], state[key] as State]>); - else - model[key] = state[key] as T[StateKey]; - }); - - return model; -} - -function getAllStatefulProperties(model: T, includeDerived = false): StateKey[] { - if (!isModel(model)) - return Object.keys(model as object) as StateKey[]; - let observableProps = includeDerived - ? Object.keys(getObservableValues(model) || {}) - : Object.keys(getReducedProperties(model)); - let explicitProps = getStateProperties(model); - return observableProps.concat(explicitProps) as StateKey[]; -} - -function getReducedProperties(model: T) { - let reducedProps = {} as Record, Reduction>; - for (let [key, observableValue] of Object.entries(getObservableValues(model))) { - if (observableValue instanceof Reduction) - reducedProps[key as StateKey] = observableValue; - } - return reducedProps; -} - -export class CircularReferenceInStateError extends Error { - constructor( - public circularPath: ([PropertyKey, unknown])[], - public circularPathKeys: PropertyKey[], - public referencedPathKeys: PropertyKey[] - ) { - super(CircularReferenceInStateError.message(circularPathKeys, referencedPathKeys)); - } - - static message(circularPathKeys: PropertyKey[], referencedPathKeys: PropertyKey[]) { - return `Detected circular reference in model: ${jsonPath(circularPathKeys)} -> ${jsonPath(referencedPathKeys)}`; - } +export function getState(model: T): State { + return isObject(model) && typeof (model as any).toJSON == 'function' + ? (model as any).toJSON() + : model as State } \ No newline at end of file diff --git a/packages/event-reduce/src/subject.ts b/packages/event-reduce/src/subject.ts deleted file mode 100644 index 8d6db34..0000000 --- a/packages/event-reduce/src/subject.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IObservable, Observable } from "./observable"; -import { constant } from "./utils"; - -export interface ISubject extends IObservable { - next(value: T): void; -} - -const anonymousSubjectName = constant("(anonymous subject)"); - -export class Subject extends Observable implements ISubject { - constructor(getDisplayName: () => string = anonymousSubjectName) { super(getDisplayName); } - next(value: T) { this.notifyObservers(value); } -} diff --git a/packages/event-reduce/src/testing.ts b/packages/event-reduce/src/testing.ts index 01ee0be..773e8ea 100644 --- a/packages/event-reduce/src/testing.ts +++ b/packages/event-reduce/src/testing.ts @@ -1,85 +1,104 @@ -import { event } from "./events"; -import { getObservableValue } from "./models"; -import { ObservableValue } from "./observableValue"; -import { getAllPropertyDescriptors, isObject } from "./utils"; +import { Atom } from "./atom" +import { Derivation } from "./derivation" +import { event } from "./events" +import { getModelProperties, isModel } from "./models" +import { Reduction } from "./reduction" +import { isSource, Source } from "./sources" +import { beginTransition } from "./tracking" +import { dispose, isObject } from "./utils" + +const targetAccessor = Symbol('target') export function mutable(model: T): Mutable { - if (isObject(model) && !Array.isArray(model)) { - for (let [key, base] of getAllPropertyDescriptors(model)) { - let observableValue = getObservableValue(model, key)!; + if (isModel(model)) { + let properties = getModelProperties(model) - if (observableValue) { + for (let key in properties) { + let property = properties[key] + if (isSource(property)) { Object.defineProperty(model, key, { - get() { return observableValue.value; }, + get() { return (property as Source).value }, set(value) { - observableValue.unsubscribeFromSources(); - observableValue.setValue(value); + if (property instanceof Reduction || property instanceof Derivation) + property.detach() + + if (property instanceof Source) { + let transition = beginTransition(property, true) + try { + property.setValue(value) + } finally { + transition[dispose]() + } + } }, - enumerable: base.enumerable, + enumerable: Object.getOwnPropertyDescriptor(model, key)?.enumerable ?? true, configurable: true - }); + }) } } + (model as any)[targetAccessor] = model + if (!('target' in model)) - Object.defineProperty(model, 'target', { get: () => model, configurable: true }); + Object.defineProperty(model, 'target', { value: model, configurable: true }) } - return model as Mutable; + return model as Mutable } -export function modelProxy(initialState: T): Mutable; -export function modelProxy(): Mutable; +export function modelProxy(initialState: T): Mutable +export function modelProxy(): Mutable export function modelProxy(model: T = {} as T) { if (!isObject(model) || Array.isArray(model)) - return model; + return model - let observableValues = {} as Record>; + let properties = {} as Record> // Pre-populate observable values with plain value fields Object.entries(Object.getOwnPropertyDescriptors(model)) .forEach(([key, prop]) => { if ('value' in prop) - observableValues[key] = new ObservableValue(() => String(key), prop.value); - }); + properties[key] = new Atom(String(key), prop.value) + }) let proxy: T = new Proxy(model, { get(target: T, key: PropertyKey, receiver: T) { if (key == 'target' && !('target' in target) + || key == targetAccessor ) { - return proxy; + return proxy } - if (observableValues.hasOwnProperty(key)) - return observableValues[key as string].value; + if (properties.hasOwnProperty(key)) + return properties[key as string].value if (key in target) - return Reflect.get(target, key, receiver); + return Reflect.get(target, key, receiver) - return getOrAddObservableValue(key).value; + return getOrAddObservableValue(key).value }, set(target: T, key: PropertyKey, value: any) { - if (key == 'target' || key == 'readonly') - return false; + if (key == 'target') + return false - getOrAddObservableValue(key).setValue(value); - return true; + getOrAddObservableValue(key).setValue(value) + return true }, ownKeys(target: T) { - return Array.from(new Set(Object.keys(target).concat(Object.keys(observableValues)))); + return Array.from(new Set(Object.keys(target).concat(Object.keys(properties)))) }, has(target: T, key: PropertyKey) { return key in target - || key in observableValues; + || key in properties } - }); + }) - return proxy; + return proxy - function getOrAddObservableValue(key: PropertyKey): ObservableValue { - return observableValues[key as string] || (observableValues[key as string] = new ObservableValue(() => String(key), model[key as keyof T])); + function getOrAddObservableValue(key: PropertyKey): Atom { + return properties[String(key)] ??= new Atom(String(key), model[key as keyof T]) } } @@ -89,22 +108,35 @@ export type Mutable = ? {} : { /** The model proxy as the original type */ - readonly target: T; + readonly target: T }) -export function eventProxy(): T; -export function eventProxy void>(createEvent: () => TEvent): { [key: string]: TEvent }; -export function eventProxy void, T = any>(createEvent: () => TEvent): T & { [P in keyof T]: TEvent }; +export function eventProxy(): T +export function eventProxy void>(createEvent: () => TEvent): { [key: string]: TEvent } +export function eventProxy void, T = any>(createEvent: () => TEvent): T & { [P in keyof T]: TEvent } export function eventProxy(createEvent: () => any = event) { - let event = createEvent(); + let event = createEvent() return new Proxy(event, { get(target: any, key: PropertyKey) { if (typeof key == 'string') - return target[key] || (target[key] = eventProxy(createEvent)); + return target[key] || (target[key] = eventProxy(createEvent)) + else if (key == targetAccessor) + return target }, apply(target: any, thisArg: any, argArray?: any) { - event(...argArray); + event(...argArray) } - }); + }) +} + +export function getTarget(proxy: T): T { + let target = (proxy as any)[targetAccessor] + if (!target) + throw new NotAProxyError() + return target +} + +export class NotAProxyError extends Error { + constructor() { super('Object is not a proxy created by event-reduce testing utilities.') } } \ No newline at end of file diff --git a/packages/event-reduce/src/signals/tracking.ts b/packages/event-reduce/src/tracking.ts similarity index 96% rename from packages/event-reduce/src/signals/tracking.ts rename to packages/event-reduce/src/tracking.ts index c997a81..0d312e2 100644 --- a/packages/event-reduce/src/signals/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -1,8 +1,8 @@ -import { dispose, emptyArray, emptyMap, isDisposable, isObject, nameOfFunction, nullDisposable } from "../utils" import { Effect } from "./effect" import { log } from "./logging" import { type EventsClass, isEventsClass, isModel, type Model, StateProperty } from "./models" import { type IObserver, type ISource, isSource, Source } from "./sources" +import { dispose, emptyArray, emptyMap, isDisposable, isObject, nameOfFunction, nullDisposable } from "./utils" type GraphNode = ISource | Model | EventsClass diff --git a/tests-legacy/LegacyModel.test.ts b/tests-legacy/LegacyModel.test.ts index 0b6bbba..5c3dc13 100644 --- a/tests-legacy/LegacyModel.test.ts +++ b/tests-legacy/LegacyModel.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" -import { asyncEvent, derive, derived, event, events, extendReduce, model, reduce, reduced, state } from "event-reduce/signals" -import { EventsMarkedAsStateError } from "event-reduce/signals/models" +import { asyncEvent, derive, derived, event, events, extendReduce, model, reduce, reduced, state } from "event-reduce" +import { EventsMarkedAsStateError } from "event-reduce/models" describe("legacy models", () => { let increment: ReturnType> @@ -235,4 +235,4 @@ describe("events decorator", () => { expect((e as EventsMarkedAsStateError).property).toBe('events') } }) -}) +}) diff --git a/tests-legacy/LegacyState.test.ts b/tests-legacy/LegacyState.test.ts index 5651aee..93e2835 100644 --- a/tests-legacy/LegacyState.test.ts +++ b/tests-legacy/LegacyState.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { derived, getState, reduce, reduced, state } from "event-reduce/signals"; +import { derived, getState, reduce, reduced, state } from "event-reduce"; describe("legacy state", () => { class BaseModel { @@ -59,4 +59,4 @@ describe("legacy state", () => { subClassProperty: 'subClass' })); }); -}); +}); diff --git a/tests/Atom.test.ts b/tests/Atom.test.ts index 80855b1..5ecb59a 100644 --- a/tests/Atom.test.ts +++ b/tests/Atom.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { Atom } from "event-reduce/signals"; -import { getUnderlyingSource } from "event-reduce/signals/tracking"; +import { Atom } from "event-reduce"; +import { getUnderlyingSource } from "event-reduce/tracking"; import { MockObserver } from "./MockObserver"; describe("Atom", () => { diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index 4e4cac9..febe029 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test" -import { Atom, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce/signals" -import { getModelProperties, isModel } from "event-reduce/signals/models" -import { StringKey } from "event-reduce/types" -import { dispose } from "event-reduce/utils" +import { Atom, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce" +import { getModelProperties, isModel } from "event-reduce/models" +import { StringKey } from "event-reduce/types" +import { dispose } from "event-reduce/utils" describe("subscription cleanup", () => { @model diff --git a/tests/Derivation.test.ts b/tests/Derivation.test.ts index 1765ae5..0642080 100644 --- a/tests/Derivation.test.ts +++ b/tests/Derivation.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { Atom, derive, event, events, IDerivation } from "event-reduce/signals"; -import { InvalidStateError } from "event-reduce/signals/sources"; -import { getUnderlyingSource, SideEffectDuringReactionError } from "event-reduce/signals/tracking"; +import { Atom, derive, event, events, IDerivation } from "event-reduce"; +import { InvalidStateError } from "event-reduce/sources"; +import { getUnderlyingSource, SideEffectDuringReactionError } from "event-reduce/tracking"; import { MockObserver } from "./MockObserver"; describe("derive", () => { @@ -135,4 +135,4 @@ test("derivation that depends on other derivations only updates once", () => { source.setValue("b"); outer.value; expect(outerCalc).toHaveBeenCalledTimes(2); -}); +}); diff --git a/tests/Event.test.ts b/tests/Event.test.ts index 35fe8fb..b781586 100644 --- a/tests/Event.test.ts +++ b/tests/Event.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart } from 'event-reduce/signals'; -import { SideEffectDuringReactionError } from "event-reduce/signals/tracking"; +import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart } from 'event-reduce'; +import { SideEffectDuringReactionError } from "event-reduce/tracking"; import { MockObserver } from "./MockObserver"; describe("event", () => { @@ -129,4 +129,4 @@ describe("asyncEvent", () => { expect(rejected.sourceUpdated).toHaveBeenCalledWith(sut.rejected, expect.objectContaining({ error, context })); }); }); -}); +}); diff --git a/tests/Example.test.tsx b/tests/Example.test.tsx index 8d75c8a..5959300 100644 --- a/tests/Example.test.tsx +++ b/tests/Example.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, test } from "bun:test"; import { Counter, CounterEvents, CounterModel } from "event-reduce-example/Counter"; import { CounterList, CounterListEvents, CounterListModel } from "event-reduce-example/CounterList"; -import { eventProxy, Mutable, mutable } from "event-reduce/signals/testing"; +import { eventProxy, Mutable, mutable } from "event-reduce/testing"; describe("CounterListModel", () => { let events: CounterListEvents; @@ -177,4 +177,4 @@ describe("Counter", () => { expect(sut.getByTestId('count').textContent).toBe('100'); }); }); -}); +}); diff --git a/tests/Hooks.test.ts b/tests/Hooks.test.ts index de36ebb..a8f8969 100644 --- a/tests/Hooks.test.ts +++ b/tests/Hooks.test.ts @@ -1,9 +1,9 @@ import { renderHook, RenderHookResult } from "@testing-library/react"; import { beforeEach, describe, expect, test } from "bun:test"; -import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react/signals"; -import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce/signals"; -import { mutable } from "event-reduce/signals/testing"; -import { IDerivation } from "event-reduce/signals"; +import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react"; +import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce"; +import { mutable } from "event-reduce/testing"; +import { IDerivation } from "event-reduce"; import { MockObserver } from "./MockObserver"; describe("useEvent", () => { diff --git a/tests/MockObserver.ts b/tests/MockObserver.ts index 2ec748e..43aedb1 100644 --- a/tests/MockObserver.ts +++ b/tests/MockObserver.ts @@ -1,8 +1,8 @@ import { mock } from "bun:test"; -import { IObserver, ISource } from "event-reduce/signals"; +import { IObserver, ISource } from "event-reduce"; export class MockObserver implements IObserver { displayName = 'mock observer' sources = new Set>() sourceUpdated: (source: ISource, value: T) => void = mock<(source: ISource, value: T) => void>() -} +} diff --git a/tests/Model.test.ts b/tests/Model.test.ts index 4646c0d..6c356ac 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { asyncEvent, derive, event, events, model, reduce, reduced, state } from "event-reduce/signals"; -import { Derivation } from "event-reduce/signals/derivation"; -import { EventsMarkedAsStateError } from "event-reduce/signals/models"; +import { asyncEvent, derive, event, events, model, reduce, reduced, state } from "event-reduce"; +import { Derivation } from "event-reduce/derivation"; +import { EventsMarkedAsStateError } from "event-reduce/models"; import { decrement, ExtendedModel } from "./Model.test/ExtendedModel"; import { increment, TestModel } from "./Model.test/TestModel"; @@ -168,4 +168,4 @@ describe("events decorator", () => { expect((e as EventsMarkedAsStateError).property).toBe('events'); } }); -}); +}); diff --git a/tests/Model.test/ExtendedModel.ts b/tests/Model.test/ExtendedModel.ts index a9ee1ec..dc03f6f 100644 --- a/tests/Model.test/ExtendedModel.ts +++ b/tests/Model.test/ExtendedModel.ts @@ -1,4 +1,4 @@ -import { event, extendReduce, model, reduced } from "event-reduce/signals"; +import { event, extendReduce, model, reduced } from "event-reduce"; import { TestModel } from "./TestModel"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -18,4 +18,4 @@ export class ExtendedModel extends TestModel { override accessor accessorProp = extendReduce(super.accessorProp) .on(decrement, c => c - 1) .value -} +} diff --git a/tests/Model.test/TestModel.ts b/tests/Model.test/TestModel.ts index c75e2e3..6f73922 100644 --- a/tests/Model.test/TestModel.ts +++ b/tests/Model.test/TestModel.ts @@ -1,4 +1,4 @@ -import { derive, derived, event, model, reduce, reduced } from "event-reduce/signals"; +import { derive, derived, event, model, reduce, reduced } from "event-reduce"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -39,4 +39,4 @@ export class TestModel { .on(derive(() => this.derivedProperty), (_, d) => d) .value; } -} +} diff --git a/tests/Reduction.test.ts b/tests/Reduction.test.ts index e051b3b..33641bc 100644 --- a/tests/Reduction.test.ts +++ b/tests/Reduction.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { Atom, createModel, event, events, IReduction, reduce } from 'event-reduce/signals'; -import { CircularSubscriptionError } from "event-reduce/signals/reduction"; -import { InvalidStateError } from "event-reduce/signals/sources"; -import { CircularDependencyError, collectAccessedSources } from "event-reduce/signals/tracking"; -import { dispose } from "event-reduce/utils"; +import { Atom, createModel, event, events, IReduction, reduce } from 'event-reduce'; +import { CircularSubscriptionError } from "event-reduce/reduction"; +import { InvalidStateError } from "event-reduce/sources"; +import { CircularDependencyError, collectAccessedSources } from "event-reduce/tracking"; +import { dispose } from "event-reduce/utils"; import { MockObserver } from "./MockObserver"; describe("reduce", () => { @@ -199,4 +199,4 @@ describe("reduce", () => { expect((e as InvalidStateError).value).toBe(eventsValue); } }); -}); +}); diff --git a/tests/Rendering.test.ts b/tests/Rendering.test.ts index 1feaafc..e753bc7 100644 --- a/tests/Rendering.test.ts +++ b/tests/Rendering.test.ts @@ -1,7 +1,7 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { useReactive } from "event-reduce-react/signals"; -import { Atom } from "event-reduce/signals"; +import { useReactive } from "event-reduce-react"; +import { Atom } from "event-reduce"; describe('useReactive', () => { let source: Atom; diff --git a/tests/Source.test.ts b/tests/Source.test.ts index 6a5562a..b415517 100644 --- a/tests/Source.test.ts +++ b/tests/Source.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { Atom, type ISource } from "event-reduce/signals"; +import { Atom, type ISource } from "event-reduce"; import { MockObserver } from "./MockObserver"; describe("Source", () => { diff --git a/tests/State.test.ts b/tests/State.test.ts index 44b2d9b..0783c3e 100644 --- a/tests/State.test.ts +++ b/tests/State.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { getState } from "event-reduce/signals"; +import { getState } from "event-reduce"; import { TestModel } from "./State.test/TestModel"; describe("state", () => { @@ -19,4 +19,4 @@ describe("state", () => { mergedModel: { value: 'merged' }, })); }); -}); +}); diff --git a/tests/State.test/BaseModel.ts b/tests/State.test/BaseModel.ts index 0b1bd82..e7ccbd7 100644 --- a/tests/State.test/BaseModel.ts +++ b/tests/State.test/BaseModel.ts @@ -1,4 +1,4 @@ -import { derived, model, reduce, reduced, state } from "event-reduce/signals"; +import { derived, model, reduce, reduced, state } from "event-reduce"; import { ChildModel } from "./ChildModel"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -29,4 +29,4 @@ export class BaseModel { mergedModel = new ChildModel('merged'); ignoredValue = 'ignored'; -} +} diff --git a/tests/State.test/ChildModel.ts b/tests/State.test/ChildModel.ts index 5c544ab..8eb26bd 100644 --- a/tests/State.test/ChildModel.ts +++ b/tests/State.test/ChildModel.ts @@ -1,4 +1,4 @@ -import { model, reduce, reduced } from "event-reduce/signals"; +import { model, reduce, reduced } from "event-reduce"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -8,4 +8,4 @@ export class ChildModel { @reduced get value() { return reduce(this._initialValue).value; } -} +} diff --git a/tests/State.test/TestModel.ts b/tests/State.test/TestModel.ts index 288d8cb..791ab39 100644 --- a/tests/State.test/TestModel.ts +++ b/tests/State.test/TestModel.ts @@ -1,4 +1,4 @@ -import { model, state } from "event-reduce/signals"; +import { model, state } from "event-reduce"; import { BaseModel } from "./BaseModel"; // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -7,4 +7,4 @@ import { BaseModel } from "./BaseModel"; export class TestModel extends BaseModel { @state subClassProperty = 'subClass'; -} +} diff --git a/tests/Subject.test.ts b/tests/Subject.test.ts deleted file mode 100644 index c1c7103..0000000 --- a/tests/Subject.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { Subject } from 'event-reduce/subject'; - -describe("Subject", () => { - let sut: Subject; - - beforeEach(() => { - sut = new Subject(() => 'test'); - }); - - describe("when subscribed to", () => { - let observer1: Mock<(value: number) => void>; - let observer2: Mock<(value: number) => void>; - let unsub1: () => void; - let unsub2: () => void; - - beforeEach(() => { - observer1 = mock(); - observer2 = mock(); - unsub1 = sut.subscribe(observer1); - unsub2 = sut.subscribe(observer2); - }); - - test("when provided a value, passes value to subscribers", () => { - sut.next(1); - - expect(observer1).toHaveBeenCalledWith(1); - expect(observer2).toHaveBeenCalledWith(1); - }); - - describe("when unsubscribed", () => { - beforeEach(() => { - unsub1(); - }); - - test("when provided a value, doesn't pass value to unsubscribed subscriber", () => { - sut.next(2); - - expect(observer1).not.toHaveBeenCalled(); - expect(observer2).toHaveBeenCalledWith(2); - }); - }); - }); -}); diff --git a/tests/Testing.test.ts b/tests/Testing.test.ts index 27da6d3..7b537b2 100644 --- a/tests/Testing.test.ts +++ b/tests/Testing.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" -import { derived, disposeModel, event, events, model, reduce, reduced } from "event-reduce/signals" -import { eventProxy, getTarget, modelProxy, Mutable, mutable } from "event-reduce/signals/testing" +import { derived, disposeModel, event, events, model, reduce, reduced } from "event-reduce" +import { eventProxy, getTarget, modelProxy, Mutable, mutable } from "event-reduce/testing" import { MockObserver } from "./MockObserver" describe("mutable", () => { @@ -126,4 +126,4 @@ describe("eventProxy", () => { expect(handler.sourceUpdated).toHaveBeenCalledWith(custom.foo, expect.objectContaining({ value: 'foo' })) }) -}) +}) diff --git a/tests/setup.ts b/tests/setup.ts index 23bfbd2..6d51eb2 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,3 +1,3 @@ -import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { GlobalRegistrator } from "@happy-dom/global-registrator" -GlobalRegistrator.register(); \ No newline at end of file +GlobalRegistrator.register() \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index ceb7807..cdcdd44 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,7 +1,8 @@ { "compilerOptions": { "target": "es2024", - "module": "commonjs", + "module": "nodenext", + "moduleResolution": "nodenext", "lib": ["ES2024", "ESNext.Collection", "ESNext.Iterator", "ESNext.Disposable", "dom"], "jsx": "react-jsx", "strict": true, From eaa7a2f37518d63c70f1c63e597b593cf8e45e42 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 29 Mar 2026 10:46:21 +1000 Subject: [PATCH 40/83] Merge function --- packages/event-reduce/src/events.ts | 4 ++-- packages/event-reduce/src/index.ts | 2 +- packages/event-reduce/src/models.ts | 3 ++- packages/event-reduce/src/sources.ts | 28 ++++++++++++++++++++-------- packages/event-reduce/src/types.ts | 18 ++++++++++-------- packages/event-reduce/src/utils.ts | 7 ------- tests/Cleanup.test.ts | 3 +-- 7 files changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index 1cd56cf..4249cb7 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -1,7 +1,7 @@ import { startLogging } from "./logging" -import { type ISource, Source, SourceValue } from "./sources" +import { type ISource, Source } from "./sources" import { beginTransition } from "./tracking" -import { Scoped } from "./types" +import { Scoped, SourceValue } from "./types" import { dispose, matchesScope } from "./utils" export interface IEvent extends IEventFunction { diff --git a/packages/event-reduce/src/index.ts b/packages/event-reduce/src/index.ts index 76a8016..69500cd 100644 --- a/packages/event-reduce/src/index.ts +++ b/packages/event-reduce/src/index.ts @@ -6,5 +6,5 @@ export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, export { enableLogging, log, startLogging } from "./logging" export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" -export { type IObserver, type ISource } from "./sources" +export { merge, type IObserver, type ISource } from "./sources" export { getState, type State } from "./state" diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index ec1c56e..53b6f64 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -1,9 +1,10 @@ import { Derivation, derive, IDerivation } from "./derivation" import { isEvent } from "./events" import { IReduction, Reduction } from "./reduction" -import { ISource, isSource, SourceValue } from "./sources" +import { ISource, isSource } from "./sources" import { getState } from "./state" import { collectAccessedSources, getChildNodes, getUnderlyingSource, trackManagedSourceCreation, updateChildren } from "./tracking" +import { SourceValue } from "./types" import { argsAre, constant, dispose, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "./utils" const modelBrand = Symbol('model class') diff --git a/packages/event-reduce/src/sources.ts b/packages/event-reduce/src/sources.ts index e8e839d..22c21e5 100644 --- a/packages/event-reduce/src/sources.ts +++ b/packages/event-reduce/src/sources.ts @@ -1,6 +1,7 @@ import type { IEventFunction } from "./events" import { isEventsClass } from "./models" import { currentIteration, getPath, iterationSource, trackSourceAccessed, updateChildren } from "./tracking" +import { MergedSources } from "./types" import { dispose, emptyArray, identity, nameOfCallback, nameOfFunction } from "./utils" export interface ISource extends Disposable { @@ -28,8 +29,6 @@ export function isSource(value: any): value is ISource { return value instanceof Source } -export type SourceValue> = Source extends ISource ? T : never - export abstract class Source implements ISource { protected readonly _observers = new Set>() protected _value!: T @@ -96,25 +95,38 @@ export abstract class Source implements ISource { } export function bind(displayName: string, source: ISource, transform: (value: In) => Out, predicate?: (value: Out) => boolean) { - return new BoundSource(displayName, source, transform, predicate) + return new BoundSource(displayName, [source], transform, predicate) +} + +export function merge[]>(...sources: Sources): MergedSources +export function merge[]>(displayName: string, ...sources: Sources): MergedSources +export function merge[]>(...sources: [string, ...ISource[]] | ISource[]): MergedSources { + let displayName: string; + if (typeof sources[0] == 'string') { + displayName = sources[0] as string + sources = sources.slice(1) as ISource[] + } else { + displayName = `merge(${(sources as ISource[]).map(s => s.displayName).join(', ')})` + } + return new BoundSource(displayName, sources as ISource[], identity) } class BoundSource extends Source { constructor( displayName: string, - private _source: ISource, + private _sources: ISource[], private _transform: (value: In) => Out, private _predicate?: (value: Out) => boolean ) { super(displayName) - _source.subscribe(this) + _sources.forEach(source => source.subscribe(this)) } - override getSources() { return [this._source] } + override getSources() { return this._sources } override subscribe(observer: IObserver): void { if (!this._observers.size) - this._source.subscribe(this) + this._sources.forEach(source => source.subscribe(this)) super.subscribe(observer) } @@ -123,7 +135,7 @@ class BoundSource extends Source { super.unsubscribe(observer) if (!this._observers.size) - this._source.unsubscribe(this) + this._sources.forEach(source => source.unsubscribe(this)) } sourceUpdated(source: ISource, value: In): void { diff --git a/packages/event-reduce/src/types.ts b/packages/event-reduce/src/types.ts index 734cdd2..fae44fb 100644 --- a/packages/event-reduce/src/types.ts +++ b/packages/event-reduce/src/types.ts @@ -1,12 +1,14 @@ +import type { ISource } from "./sources"; + export type Scoped> = & Omit - & { [K in keyof Scope]?: K extends keyof T ? T[K] : never; }; + & { [K in keyof Scope]?: K extends keyof T ? T[K] : never } + +export type MergedSources[]> = ISource> -export type StringKey = Extract; -export type OmitValues = FromEntries, [any, Omitted]>>; -export type FromEntries = { [Entry in Entries as Entry[0]]: Entry[1]; }; -export type Entries = ValueOf<{ [K in keyof T]: [K, T[K]] }>; -export type ValueOf = T[keyof T]; +type MergedValues[]> = + Sources extends [ISource, ...infer Rest extends ISource[]] + ? First | MergedValues + : never -export type Action = () => void; -export type Unsubscribe = () => void; \ No newline at end of file +export type SourceValue> = Source extends ISource ? T : never diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index f2e5788..cfccd68 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -1,5 +1,3 @@ -import { Unsubscribe } from "./types"; - export const emptyArray = Object.freeze([]) as readonly any[]; export const emptySet = Object.freeze(new Set()) as ReadonlySet @@ -123,11 +121,6 @@ export function* getPrototypeChain(obj: object) { } } -export function unsubscribeAll(unsubscribes: Unsubscribe[]) { - for (let unsubscribe of unsubscribes) - unsubscribe(); -} - export function isDisposable(value: any): value is Disposable { return isObject(value) && dispose in value diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index febe029..6bc9fa9 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test" import { Atom, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce" import { getModelProperties, isModel } from "event-reduce/models" -import { StringKey } from "event-reduce/types" import { dispose } from "event-reduce/utils" describe("subscription cleanup", () => { @@ -316,7 +315,7 @@ describe("subscription cleanup", () => { expect(disposeExternallyOwnedPropValue).not.toHaveBeenCalled() }) - function spyOnDispose(model: T, property: StringKey) { + function spyOnDispose(model: T, property: keyof T) { if (isModel(model)) { let observableValue = getModelProperties(model)[property]! if (dispose in observableValue) From a555606d71d1665b7c1319a118f0d3043fb2bad2 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:30:44 +1000 Subject: [PATCH 41/83] Changing export paths back to /lib/ for backwards compatibility with old module resolution --- packages/event-reduce-react/package.json | 2 +- packages/event-reduce-react/src/hooks.ts | 8 ++++---- packages/event-reduce-react/src/rendering.ts | 8 ++++---- packages/event-reduce/package.json | 2 +- tests-legacy/LegacyModel.test.ts | 2 +- tests/Atom.test.ts | 2 +- tests/Cleanup.test.ts | 4 ++-- tests/Derivation.test.ts | 4 ++-- tests/Event.test.ts | 2 +- tests/Example.test.tsx | 2 +- tests/Hooks.test.ts | 2 +- tests/Model.test.ts | 4 ++-- tests/Reduction.test.ts | 8 ++++---- tests/Testing.test.ts | 2 +- 14 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/event-reduce-react/package.json b/packages/event-reduce-react/package.json index 102cc48..91036bc 100644 --- a/packages/event-reduce-react/package.json +++ b/packages/event-reduce-react/package.json @@ -18,7 +18,7 @@ "bun": "./src/index.ts", "default": "./lib/index.js" }, - "./*": { + "./lib/*": { "types": "./lib/*.d.ts", "bun": "./src/*.ts", "default": "./lib/*.js" diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index bdce44c..42df3bf 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -1,8 +1,8 @@ import { asyncEvent, Atom, derive, event, IDerivation, IReduction, reduce } from "event-reduce" -import { isModel, Model } from "event-reduce/models" -import { Reduction } from "event-reduce/reduction" -import { trackManagedSourceCreation } from "event-reduce/tracking" -import { dispose, emptyArray } from "event-reduce/utils" +import { isModel, Model } from "event-reduce/lib/models" +import { Reduction } from "event-reduce/lib/reduction" +import { trackManagedSourceCreation } from "event-reduce/lib/tracking" +import { dispose, emptyArray } from "event-reduce/lib/utils" import { DependencyList, useEffect, useMemo, useRef, useState } from "react" /** diff --git a/packages/event-reduce-react/src/rendering.ts b/packages/event-reduce-react/src/rendering.ts index 03c7166..b78ad88 100644 --- a/packages/event-reduce-react/src/rendering.ts +++ b/packages/event-reduce-react/src/rendering.ts @@ -1,8 +1,8 @@ import { Effect, log, startLogging } from "event-reduce" -import { LogValue } from "event-reduce/logging" -import { ISource } from "event-reduce/sources" -import { beginTransition } from "event-reduce/tracking" -import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/utils" +import { LogValue } from "event-reduce/lib/logging" +import { ISource } from "event-reduce/lib/sources" +import { beginTransition } from "event-reduce/lib/tracking" +import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/lib/utils" import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useState } from "react" import { useSyncExternalStore } from "use-sync-external-store/shim" diff --git a/packages/event-reduce/package.json b/packages/event-reduce/package.json index 3bf9131..276fd67 100644 --- a/packages/event-reduce/package.json +++ b/packages/event-reduce/package.json @@ -18,7 +18,7 @@ "bun": "./src/index.ts", "default": "./lib/index.js" }, - "./*": { + "./lib/*": { "types": "./lib/*.d.ts", "bun": "./src/*.ts", "default": "./lib/*.js" diff --git a/tests-legacy/LegacyModel.test.ts b/tests-legacy/LegacyModel.test.ts index 5c3dc13..3a7f544 100644 --- a/tests-legacy/LegacyModel.test.ts +++ b/tests-legacy/LegacyModel.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" import { asyncEvent, derive, derived, event, events, extendReduce, model, reduce, reduced, state } from "event-reduce" -import { EventsMarkedAsStateError } from "event-reduce/models" +import { EventsMarkedAsStateError } from "event-reduce/lib/models" describe("legacy models", () => { let increment: ReturnType> diff --git a/tests/Atom.test.ts b/tests/Atom.test.ts index 5ecb59a..428c66d 100644 --- a/tests/Atom.test.ts +++ b/tests/Atom.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { Atom } from "event-reduce"; -import { getUnderlyingSource } from "event-reduce/tracking"; +import { getUnderlyingSource } from "event-reduce/lib/tracking"; import { MockObserver } from "./MockObserver"; describe("Atom", () => { diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index 6bc9fa9..464aa4b 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test" import { Atom, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce" -import { getModelProperties, isModel } from "event-reduce/models" -import { dispose } from "event-reduce/utils" +import { getModelProperties, isModel } from "event-reduce/lib/models" +import { dispose } from "event-reduce/lib/utils" describe("subscription cleanup", () => { @model diff --git a/tests/Derivation.test.ts b/tests/Derivation.test.ts index 0642080..644ad88 100644 --- a/tests/Derivation.test.ts +++ b/tests/Derivation.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; import { Atom, derive, event, events, IDerivation } from "event-reduce"; -import { InvalidStateError } from "event-reduce/sources"; -import { getUnderlyingSource, SideEffectDuringReactionError } from "event-reduce/tracking"; +import { InvalidStateError } from "event-reduce/lib/sources"; +import { getUnderlyingSource, SideEffectDuringReactionError } from "event-reduce/lib/tracking"; import { MockObserver } from "./MockObserver"; describe("derive", () => { diff --git a/tests/Event.test.ts b/tests/Event.test.ts index b781586..a852695 100644 --- a/tests/Event.test.ts +++ b/tests/Event.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart } from 'event-reduce'; -import { SideEffectDuringReactionError } from "event-reduce/tracking"; +import { SideEffectDuringReactionError } from "event-reduce/lib/tracking"; import { MockObserver } from "./MockObserver"; describe("event", () => { diff --git a/tests/Example.test.tsx b/tests/Example.test.tsx index 5959300..38e69b8 100644 --- a/tests/Example.test.tsx +++ b/tests/Example.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, test } from "bun:test"; import { Counter, CounterEvents, CounterModel } from "event-reduce-example/Counter"; import { CounterList, CounterListEvents, CounterListModel } from "event-reduce-example/CounterList"; -import { eventProxy, Mutable, mutable } from "event-reduce/testing"; +import { eventProxy, Mutable, mutable } from "event-reduce/lib/testing"; describe("CounterListModel", () => { let events: CounterListEvents; diff --git a/tests/Hooks.test.ts b/tests/Hooks.test.ts index a8f8969..fbf177e 100644 --- a/tests/Hooks.test.ts +++ b/tests/Hooks.test.ts @@ -2,7 +2,7 @@ import { renderHook, RenderHookResult } from "@testing-library/react"; import { beforeEach, describe, expect, test } from "bun:test"; import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react"; import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce"; -import { mutable } from "event-reduce/testing"; +import { mutable } from "event-reduce/lib/testing"; import { IDerivation } from "event-reduce"; import { MockObserver } from "./MockObserver"; diff --git a/tests/Model.test.ts b/tests/Model.test.ts index 6c356ac..b9db623 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; import { asyncEvent, derive, event, events, model, reduce, reduced, state } from "event-reduce"; -import { Derivation } from "event-reduce/derivation"; -import { EventsMarkedAsStateError } from "event-reduce/models"; +import { Derivation } from "event-reduce/lib/derivation"; +import { EventsMarkedAsStateError } from "event-reduce/lib/models"; import { decrement, ExtendedModel } from "./Model.test/ExtendedModel"; import { increment, TestModel } from "./Model.test/TestModel"; diff --git a/tests/Reduction.test.ts b/tests/Reduction.test.ts index 33641bc..1c705aa 100644 --- a/tests/Reduction.test.ts +++ b/tests/Reduction.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; import { Atom, createModel, event, events, IReduction, reduce } from 'event-reduce'; -import { CircularSubscriptionError } from "event-reduce/reduction"; -import { InvalidStateError } from "event-reduce/sources"; -import { CircularDependencyError, collectAccessedSources } from "event-reduce/tracking"; -import { dispose } from "event-reduce/utils"; +import { CircularSubscriptionError } from "event-reduce/lib/reduction"; +import { InvalidStateError } from "event-reduce/lib/sources"; +import { CircularDependencyError, collectAccessedSources } from "event-reduce/lib/tracking"; +import { dispose } from "event-reduce/lib/utils"; import { MockObserver } from "./MockObserver"; describe("reduce", () => { diff --git a/tests/Testing.test.ts b/tests/Testing.test.ts index 7b537b2..3276d84 100644 --- a/tests/Testing.test.ts +++ b/tests/Testing.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" import { derived, disposeModel, event, events, model, reduce, reduced } from "event-reduce" -import { eventProxy, getTarget, modelProxy, Mutable, mutable } from "event-reduce/testing" +import { eventProxy, getTarget, modelProxy, Mutable, mutable } from "event-reduce/lib/testing" import { MockObserver } from "./MockObserver" describe("mutable", () => { From ebe92f3ca62a2866007cbf880f6819c246da804d Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:31:18 +1000 Subject: [PATCH 42/83] useModel returns the same type created by callback --- packages/event-reduce-react/src/hooks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index 42df3bf..e0fd7b0 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -9,8 +9,8 @@ import { DependencyList, useEffect, useMemo, useRef, useState } from "react" * Creates a model that persists across renders of the component. * @param unobservableDependencies - A list of dependencies that are not observable. The model will be re-created when any of these change. */ -export function useModel(createModel: () => T, unobservableDependencies?: DependencyList) { - let model = useMemo(createModel, unobservableDependencies ?? emptyArray) as Model +export function useModel(createModel: () => T, unobservableDependencies?: DependencyList): T { + let model = useMemo(createModel, unobservableDependencies ?? emptyArray) if (!isModel(model)) throw new InvalidModelError() useEffect(() => () => model[dispose]?.(), unobservableDependencies ?? emptyArray) From 9d26ebe6f30fec45197e419c625bf8df9fed7ea7 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:32:02 +1000 Subject: [PATCH 43/83] useObservedProps updates props in useEffect to avoid side effects during render --- packages/event-reduce-react/src/hooks.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index e0fd7b0..775ec7b 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -57,8 +57,10 @@ export function useObservedProps(values: T, name: string = '(a let nameBase = (name || '') + '.' // Update any values that are already being observed - for (let [key, atom] of Object.entries(observableValues) as [keyof T, Atom][]) - atom.setValue(values[key]) + useEffect(() => { + for (let [key, atom] of Object.entries(observableValues) as [keyof T, Atom][]) + atom.setValue(values[key]) + }) let latestValues = useRef(values) latestValues.current = values From bcdac6cdd753964719ac602d0eea7e76043f8b00 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:32:49 +1000 Subject: [PATCH 44/83] Handling context components when logging react elements --- packages/event-reduce-react/src/rendering.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/event-reduce-react/src/rendering.ts b/packages/event-reduce-react/src/rendering.ts index b78ad88..00c99f9 100644 --- a/packages/event-reduce-react/src/rendering.ts +++ b/packages/event-reduce-react/src/rendering.ts @@ -91,7 +91,7 @@ function loggedReactValue(value: unknown) { function xmlTree(node: unknown): Node { if (isValidElement(node)) { let type = reactElementName(node.type) - .split('(').at(-1)!.split(')')[0] // Unwrap HOC names + ?.split('(').at(-1)!.split(')')[0] // Unwrap HOC names .replace(/^[0-9-]/, '_$&') // Escape leading number or dash .replace(/[^a-zA-Z0-9:_.-]/g, '_'); // Escape rest of element name let el = xmlDoc.createElement(type || ':unknown:') @@ -111,7 +111,7 @@ function loggedReactValue(value: unknown) { case 'symbol': return String(elementType) case 'object': - if ('_context' in elementType) return 'Context.Provider' + if ('_context' in elementType || ('Consumer' in elementType && 'Provider' in elementType)) return 'Context.Provider' if (elementType.displayName) return elementType.displayName if (elementType.type?.displayName) return elementType.type.displayName } From e5f12f6673fc9b168f5e3e503e52ae428841c5b9 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:37:27 +1000 Subject: [PATCH 45/83] Supressing logging when accessing toJSON derivations --- packages/event-reduce/src/atom.ts | 7 ++++--- packages/event-reduce/src/derivation.ts | 13 +++++++++---- packages/event-reduce/src/models.ts | 5 +++-- packages/event-reduce/src/reduction.ts | 7 ++++--- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/event-reduce/src/atom.ts b/packages/event-reduce/src/atom.ts index ef25720..950c9a0 100644 --- a/packages/event-reduce/src/atom.ts +++ b/packages/event-reduce/src/atom.ts @@ -1,11 +1,11 @@ -import { derive, IDerivation } from "./derivation"; +import { Derivation } from "./derivation"; import { ISource, Source } from "./sources"; import { getState, State } from "./state"; import { beginTransition } from "./tracking"; import { dispose } from "./utils"; export class Atom extends Source implements ISource { - private _json: IDerivation> + private _json: Derivation> constructor( displayName: string, @@ -13,8 +13,9 @@ export class Atom extends Source implements ISource { private _isEqual = Object.is ) { super(displayName); - this._json = derive(() => getState(this.value), `toJSON(${this.displayName})`) this.setValue(initialValue); + this._json = new Derivation(`toJSON(${this.displayName})`, () => getState(this.value)) + this._json.suppressLogging = true } toJSON() { return this._json.value; } diff --git a/packages/event-reduce/src/derivation.ts b/packages/event-reduce/src/derivation.ts index 9dac280..68eaa76 100644 --- a/packages/event-reduce/src/derivation.ts +++ b/packages/event-reduce/src/derivation.ts @@ -3,7 +3,7 @@ import { startLogging } from "./logging" import { IObserver, ISource, Source } from "./sources" import { getState, State } from "./state" import { beginTransition, iterationSource, trackManagedSourceCreation, trackSourceAccessed } from "./tracking" -import { dispose, emptyArray, nameOfFunction } from "./utils" +import { dispose, emptyArray, nameOfFunction, nullDisposable } from "./utils" export interface IDerivation extends ISource, IObserver { readonly value: T @@ -27,8 +27,9 @@ export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, export class Derivation extends Source implements IDerivation { private _derive: Effect - private _json?: IDerivation> + private _json?: Derivation> static defaultName = "(anonymous derivation)" + suppressLogging = false constructor( displayName: string, @@ -44,7 +45,11 @@ export class Derivation extends Source implements IDerivation { toJSON() { // Lazy init to avoid infinite loop in Derivation constructor - return (this._json ??= derive(() => getState(this.value), `toJSON(${this.displayName})`)).value + if (!this._json){ + this._json = new Derivation(`toJSON(${this.displayName})`, () => getState(this.value)) + this._json.suppressLogging = true + } + return this._json.value } get sources() { return this._derive.sources } @@ -95,7 +100,7 @@ export class Derivation extends Source implements IDerivation { let transition = beginTransition(this, !iterationSource) // Derivations are always safe to access, and only initiate a transition if necessary let sourceCreation = trackManagedSourceCreation() - let logging = startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ + let logging = this.suppressLogging ? nullDisposable : startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ Path: this.path, Trigger: trigger?.path ?? '(unknown)', Previous: previousValue, diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index 53b6f64..0764a15 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -116,12 +116,13 @@ export function model any>(target: Class): if (outerMostModelCtor == thisCtor) { getterCache.delete(model) // Only needed during construction - let json = derive(() => { + let json = new Derivation(`toJSON(${className})`, () => { let obj = {} as Record for (let key in model[modelProperties]) obj[key] = model[modelProperties][key] return obj - }, `toJSON(${className})`) + }) + json.suppressLogging = true Object.assign(this, { toJSON() { return super.toJSON?.() ?? json.value }, diff --git a/packages/event-reduce/src/reduction.ts b/packages/event-reduce/src/reduction.ts index 9a44145..7e547be 100644 --- a/packages/event-reduce/src/reduction.ts +++ b/packages/event-reduce/src/reduction.ts @@ -1,4 +1,4 @@ -import { derive, IDerivation } from "./derivation" +import { Derivation } from "./derivation" import { startLogging } from "./logging" import { allSources, IObserver, ISource, isSource, NotASourceValueError, Source } from "./sources" import { getState, State } from "./state" @@ -35,7 +35,7 @@ export class Reduction extends Source implements IReduction sources = new Set>() private _reducers = new Map, (current: T, next: any) => T>() private _sourcesLastChecked = -1 - private _json: IDerivation> + private _json: Derivation> static defaultName = "(anonymous reduction)" constructor( @@ -46,7 +46,8 @@ export class Reduction extends Source implements IReduction ) { super(displayName) - this._json = derive(() => getState(this.value), `toJSON(${this.displayName})`) + this._json = new Derivation(`toJSON(${this.displayName})`, () => getState(this.value)) + this._json.suppressLogging = true managedSourceCreated(this) From 6e6ced24d3e0807b0ce757fbf31555ba06b59fa1 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:37:47 +1000 Subject: [PATCH 46/83] Atom doesn't trigger a transition when constructed --- packages/event-reduce/src/atom.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/event-reduce/src/atom.ts b/packages/event-reduce/src/atom.ts index 950c9a0..c299b55 100644 --- a/packages/event-reduce/src/atom.ts +++ b/packages/event-reduce/src/atom.ts @@ -1,7 +1,7 @@ import { Derivation } from "./derivation"; import { ISource, Source } from "./sources"; import { getState, State } from "./state"; -import { beginTransition } from "./tracking"; +import { beginTransition, updateChildren } from "./tracking"; import { dispose } from "./utils"; export class Atom extends Source implements ISource { @@ -13,9 +13,10 @@ export class Atom extends Source implements ISource { private _isEqual = Object.is ) { super(displayName); - this.setValue(initialValue); this._json = new Derivation(`toJSON(${this.displayName})`, () => getState(this.value)) this._json.suppressLogging = true + updateChildren(this, undefined, initialValue) + this._value = initialValue // Avoid triggering a transition in constructor } toJSON() { return this._json.value; } From 0794941f772cd98624c56696392724db58353a6d Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:38:47 +1000 Subject: [PATCH 47/83] Catching exceptions when logging --- packages/event-reduce/src/logging.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/event-reduce/src/logging.ts b/packages/event-reduce/src/logging.ts index 75bc8a9..5329646 100644 --- a/packages/event-reduce/src/logging.ts +++ b/packages/event-reduce/src/logging.ts @@ -38,15 +38,19 @@ export function log(type: string, displayName: string, args } function logMessage(type: string, displayName: string, args: readonly any[], getInfo?: () => Info, childLogs: LogItem[] = []) { - let group = { - message: [`${displayName} %c${type}`, 'color: grey; font-weight: normal;', ...args], - children: Object.entries(getInfo?.() ?? {} as Info) - .map(([key, value]) => value instanceof LogValue - ? [`${key}:`, ...value.args] - : [`${key}:`, value]) - .concat(childLogs) - }; - logStack.at(-1)!.push(group); + try { + let group = { + message: [`${displayName} %c${type}`, 'color: grey; font-weight: normal;', ...args], + children: Object.entries(getInfo?.() ?? {} as Info) + .map(([key, value]) => value instanceof LogValue + ? [`${key}:`, ...value.args] + : [`${key}:`, value]) + .concat(childLogs) + }; + logStack.at(-1)!.push(group); + } catch (e) { + logStack.at(-1)!.push([`${displayName} ${type} (logging failed: ${e instanceof Error ? e.message : String(e)})`, ...args]); + } if (logStack.length == 1) { flushLogs(logStack[0]); From 43b717fb04d6df21ff059b57f4a501359becea52 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:39:55 +1000 Subject: [PATCH 48/83] Better typing for AsyncValues Makes it easier to use them externally --- packages/event-reduce/src/events.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index 4249cb7..0d0a47d 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -47,18 +47,21 @@ export type AsyncValue = | AsyncError export interface AsyncStart { + type: 'start' context: Context promise: PromiseLike } export interface AsyncResult { + type: 'result' context: Context result: Result } export interface AsyncError { + type: 'error' context: Context - error: any + error: unknown } export function asyncEvent(name: string = "(anonymous async event)"): IAsyncEvent { @@ -77,7 +80,7 @@ export function asyncEvent(name: string = "(anony promise.then( result => { - let val = { result, context } + let val = { type: 'result', result, context } satisfies AsyncResult let logging = startLogging('⚡✔ (async resolved)', name, [val], () => ({ Path: this.path, Promise: promise })) try { emit(val) @@ -86,7 +89,7 @@ export function asyncEvent(name: string = "(anony } }, error => { - let val = { error, context } + let val = { type: 'error', error, context } satisfies AsyncError let logging = startLogging('⚡✘ (async rejected)', name, [val], () => ({ Path: this.path, Promise: promise })) try { emit(val) @@ -99,9 +102,9 @@ export function asyncEvent(name: string = "(anony }, function asyncEventMembers(e: IEventFunction, [promise: PromiseLike, context: CurrentContext]>) { let setBaseDisplayName = e.setDisplayName return { - started: e.filter(v => 'promise' in v, `${e.displayName}.started`), - resolved: e.filter(v => 'result' in v, `${e.displayName}.resolved`), - rejected: e.filter(v => 'error' in v, `${e.displayName}.rejected`), + started: e.filter(e => e.type == 'start', `${e.displayName}.started`), + resolved: e.filter(e => e.type == 'result', `${e.displayName}.resolved`), + rejected: e.filter(e => e.type == 'error', `${e.displayName}.rejected`), scope>(scope: Scope): IAsyncEvent> { return bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, From 09104fa77dec10550b2c46f42ca374b7c93e3cad Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:40:24 +1000 Subject: [PATCH 49/83] Logging async start event with same format as other events --- packages/event-reduce/src/events.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index 0d0a47d..3f2f829 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -71,9 +71,10 @@ export function asyncEvent(name: string = "(anony promise: P, context: Context ) { - let logging = startLogging('⚡⌚ (async event)', name, [promise, context], () => ({ Path: this.path, Promise: promise })) + let val = { type: 'start', promise, context } satisfies AsyncStart + let logging = startLogging('⚡⌚ (async event)', name, [val], () => ({ Path: this.path, Promise: promise })) try { - emit({ promise, context }) + emit(val) } finally { logging?.[dispose]() } From 5f3584647f9493e9162cc7fbc128b276d51f0948 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:41:44 +1000 Subject: [PATCH 50/83] Reordering some overloads so they get picked up properly --- packages/event-reduce/src/reduction.ts | 2 +- packages/event-reduce/src/sources.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/event-reduce/src/reduction.ts b/packages/event-reduce/src/reduction.ts index 7e547be..ec203fb 100644 --- a/packages/event-reduce/src/reduction.ts +++ b/packages/event-reduce/src/reduction.ts @@ -7,8 +7,8 @@ import { dispose, emptyArray } from "./utils" export interface IReduction extends ISource { readonly value: T - on(source: ISource, reduce: Reducer): this on(getSource: (events: Events) => ISource, reduce: Reducer): this + on(source: ISource, reduce: Reducer): this } export function reduce(initialValue: T, displayName?: string): IReduction diff --git a/packages/event-reduce/src/sources.ts b/packages/event-reduce/src/sources.ts index 22c21e5..82dc119 100644 --- a/packages/event-reduce/src/sources.ts +++ b/packages/event-reduce/src/sources.ts @@ -14,8 +14,8 @@ export interface ISource extends Disposable { unsubscribe(observer: IObserver): void map(transform: (value: T) => Out, displayName?: string): ISource - filter(predicate: (value: T) => boolean, displayName?: string): ISource filter(predicate: (value: T) => value is Out, displayName?: string): ISource + filter(predicate: (value: T) => boolean, displayName?: string): ISource filterMap(transform: (value: T) => Out | undefined, displayName?: string): ISource } From 2e401b4a3c018abe655a448924f88db4a535c493 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:44:23 +1000 Subject: [PATCH 51/83] Fixes for legacy decorators Caching getters so they can be accessed during model construction Plain properties can be accessed after they're set during model construction Removed MissingModelDecoratorError since there's case where it's thrown anymore --- packages/event-reduce/src/models.ts | 67 ++++++++++++++++------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index 0764a15..77304a9 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -1,7 +1,7 @@ import { Derivation, derive, IDerivation } from "./derivation" import { isEvent } from "./events" import { IReduction, Reduction } from "./reduction" -import { ISource, isSource } from "./sources" +import { ISource, isSource, Source } from "./sources" import { getState } from "./state" import { collectAccessedSources, getChildNodes, getUnderlyingSource, trackManagedSourceCreation, updateChildren } from "./tracking" import { SourceValue } from "./types" @@ -186,7 +186,10 @@ export function reduced(...args: } else if (argsAre(args, isFunction, isDecoratorContext)) { // Getter property decorator let [getter, context] = args - return cachedGetter(getter, context, instance => getUnderlyingReduction(instance, context.name, getter)) + return cachedGetter( + getter, + factory => addInstancePropertyFactory(context, factory), + instance => getUnderlyingReduction(instance, context.name, getter)) } else { // Legacy decorator @@ -196,10 +199,11 @@ export function reduced(...args: if (property?.get) { let getter = property.get - getPrototypePropertyFactories(prototype)[key] = instance => getUnderlyingReduction(instance, key, getter) - return { - get(this: Target) { throw new MissingModelDecoratorError(model, key) }, + get: cachedGetter( + getter, + factory => addPrototypePropertyFactory(prototype, key, factory), + instance => getUnderlyingReduction(instance, key, getter)), enumerable: true, configurable: true } @@ -249,7 +253,10 @@ export function derived(...args: if (argsAre(args, isFunction, isDecoratorContext)) { // Getter property decorator let [getter, context] = args - return cachedGetter(getter, context, instance => derive(getter.bind(instance), valuesEqual ?? Object.is, String(context.name))) + return cachedGetter( + getter, + factory => addInstancePropertyFactory(context, factory), + instance => derive(getter.bind(instance), valuesEqual ?? Object.is, String(context.name))) } else { // Legacy decorator let [prototype, key] = args @@ -257,10 +264,11 @@ export function derived(...args: if (property?.get) { let getter = property.get - let createDerivation = (instance: any) => derive(getter.bind(instance), valuesEqual ?? Object.is, String(key)) - getPrototypePropertyFactories(prototype)[key] = createDerivation return { - get(this: Target) { throw new MissingModelDecoratorError(model, key) }, + get: cachedGetter( + getter, + factory => addPrototypePropertyFactory(prototype, key, factory), + instance => derive(getter.bind(instance), valuesEqual ?? Object.is, String(key))), enumerable: true, configurable: true } @@ -273,29 +281,35 @@ export function derived(...args: function cachedGetter | IReduction>( getter: () => SourceValue, - context: ClassGetterDecoratorContext>, + addPropertyFactory: (cachedSource: (instance: any) => Source) => void, createSource: (instance: Model) => Source ) { - context.addInitializer(function (this: Model) { - getInstancePropertyFactories(this)[context.name] = cachedSource.bind(this) - }) - - return cachedGetter + addPropertyFactory(cachedSource) /** Ensures source is only created once during model construction. */ - function cachedGetter(this: Model) { - return cachedSource.call(this).value + return function cachedGetter(this: Model) { + return cachedSource(this).value } - function cachedSource(this: Model) { - let cache = getModelGetterCache(this) + function cachedSource(instance: Model) { + let cache = getModelGetterCache(instance) let source = cache.get(getter) as Source if (!source) - cache.set(getter, source = createSource(this)) + cache.set(getter, source = createSource(instance)) return source } } +function addInstancePropertyFactory(context: ClassGetterDecoratorContext, cachedSource: (instance: any) => ISource) { + context.addInitializer(function (this: Model) { + getInstancePropertyFactories(this)[context.name] = cachedSource.bind(null, this) + }) +} + +function addPrototypePropertyFactory(prototype: object, key: PropertyKey, cachedSource: (instance: any) => ISource) { + getPrototypePropertyFactories(prototype)[key] = cachedSource +} + function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISource; defaultName: string; }, errorFactory: () => Error): PropertyDescriptor { return { set } @@ -307,8 +321,10 @@ function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISour if (source.displayName == sourceType.defaultName) source.setDisplayName(String(key)) + getInstancePropertyFactories(this)[key] = constant(source) + Object.defineProperty(this, key, { - get: () => { throw new MissingModelDecoratorError(model, key) }, + get: () => (source as Source).value, set, enumerable: true, configurable: true @@ -447,15 +463,6 @@ function getModelGetterCache(instance: object): Map { return getOrAddWeakEntry(getterCache, instance, () => new Map()) } -export class MissingModelDecoratorError extends Error { - constructor( - public model: object, - public property: PropertyKey - ) { - super(`${nameOfFunction(model.constructor)} class must be marked as a @model to use @reduced, @derived, or @state properties.`) - } -} - export class InvalidReducedPropertyError extends Error { constructor( public property: PropertyKey From 6ab428fa2b82d8e3cd9c7ce4dc66abe6b1b9fd20 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:45:09 +1000 Subject: [PATCH 52/83] Fixing model property binding for subclasses of regular sources --- packages/event-reduce/src/models.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index 77304a9..4f48999 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -150,7 +150,10 @@ function initializeModelProperty

(instance: Model, key: if (isSource(property)) { // No need to do this for state properties Object.defineProperty(instance, key, { // Bind directly to the property for cleaner call stack - get: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(property), 'value')!.get!.bind(property), + get: getPrototypeChain(property) + .map(proto => Object.getOwnPropertyDescriptor(proto, 'value')) + .find(value => !!value)! + .get!.bind(property), enumerable: true, configurable: true }) From dda1b386c203eb554d15aabcf0508c813b9653ad Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:45:22 +1000 Subject: [PATCH 53/83] Exporting BoundSource so it can be extended --- packages/event-reduce/src/sources.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/event-reduce/src/sources.ts b/packages/event-reduce/src/sources.ts index 82dc119..eae6783 100644 --- a/packages/event-reduce/src/sources.ts +++ b/packages/event-reduce/src/sources.ts @@ -111,16 +111,13 @@ export function merge[]>(...sources: [string, ...IS return new BoundSource(displayName, sources as ISource[], identity) } -class BoundSource extends Source { +export class BoundSource extends Source { constructor( displayName: string, - private _sources: ISource[], - private _transform: (value: In) => Out, - private _predicate?: (value: Out) => boolean - ) { - super(displayName) - _sources.forEach(source => source.subscribe(this)) - } + protected _sources: ISource[], + protected _transform: (value: In) => Out, + protected _predicate?: (value: Out) => boolean + ) { super(displayName) } override getSources() { return this._sources } @@ -146,7 +143,9 @@ class BoundSource extends Source { } } - override skipUpdate(value: Out) { return this._predicate?.(value) == false } + protected override updateChildren(previousValue: Out, nextValue: Out) { } // Value won't be held onto + + protected override skipUpdate(value: Out) { return this._predicate?.(value) == false } } export function* allSources(source: ISource): Generator> { From cf90c62a6fe70e4444996799e324e414ad64f747 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:46:34 +1000 Subject: [PATCH 54/83] Non-object state properties no longer cause errors --- packages/event-reduce/src/tracking.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts index 0d312e2..062d631 100644 --- a/packages/event-reduce/src/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -86,9 +86,11 @@ export function getChildNodes(value: object): ReadonlyMap { return new Map([[value, '']]) if (value instanceof StateProperty) - return new Map(getChildNodes(value.value) - .entries() - .map(([node, path]) => [node, `.${String(value.key)}${path}`])) + return isObject(value.value) + ? new Map(getChildNodes(value.value) + .entries() + .map(([node, path]) => [node, `.${String(value.key)}${path}`])) + : emptyMap if (Array.isArray(value)) return new Map(value From 4149dc410dcebbb684fbe5a0b2687c0bf2051450 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 1 Apr 2026 18:46:48 +1000 Subject: [PATCH 55/83] More instructions for UntrackedSourceError --- packages/event-reduce/src/tracking.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts index 062d631..c5571a4 100644 --- a/packages/event-reduce/src/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -220,5 +220,8 @@ export class SideEffectDuringReactionError extends Error { } export class UntrackedSourceError extends Error { - constructor(source: ISource) { super(`Leak detected: ${nameOfFunction(source.constructor)} '${source.displayName}' created without an owner.`) } // NOCOMMIT say what to do about it + constructor(source: ISource) { + super(`Leak detected: ${nameOfFunction(source.constructor)} '${source.displayName}' created without an owner. +Make sure reductions are defined on a model, and that the model is either a class decorated with @model, or created using createModel().`) + } } \ No newline at end of file From eb536cc8917447caf0ba5b047aaadef668535aa6 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 5 Apr 2026 12:16:26 +1000 Subject: [PATCH 56/83] Fix for property initializers accessing sub-class properties Properties register themselves with the model when accessed Only the outer-most @model constructor initializes properties --- packages/event-reduce/src/models.ts | 194 +++++++++++++++------------- tests/State.test.ts | 4 +- 2 files changed, 104 insertions(+), 94 deletions(-) diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index 4f48999..b0af373 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -5,7 +5,7 @@ import { ISource, isSource, Source } from "./sources" import { getState } from "./state" import { collectAccessedSources, getChildNodes, getUnderlyingSource, trackManagedSourceCreation, updateChildren } from "./tracking" import { SourceValue } from "./types" -import { argsAre, constant, dispose, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "./utils" +import { argsAre, constant, dispose, emptyArray, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "./utils" const modelBrand = Symbol('model class') const modelProperties = Symbol('model properties') @@ -21,15 +21,16 @@ export function isModel(value: unknown): value is Model { && modelProperties in value } -/** instance -> key -> factory */ -let instancePropertyFactories = new WeakMap ModelProperty>>() -/** prototype -> key -> factory */ -let prototypePropertyFactories = new WeakMap ModelProperty>>() +/** instance -> key -> provider */ +let instancePropertyProviders = new WeakMap ModelProperty>>() +/** prototype -> key -> provider */ +let prototypePropertyProviders = new WeakMap ModelProperty>>() /** * instance -> getter -> property source * Required for getters to cache their source without being overridden in sub-classes */ let getterCache = new WeakMap>>() +let registeredInstanceProperties = new WeakMap() /** Creates a loose model object. Not required if your model is already decorated with \@{@link model}. */ @@ -77,65 +78,70 @@ export function disposeModel(model: object) { * Observable values accessed during construction will not be tracked. * The model instance will take ownership of all observable and state properties. **/ -export function model any>(target: Class): Class { +export function model object>(target: Class): Class { const className = nameOfFunction(target) - let thisCtor: new (...args: any[]) => any + let thisCtor: Class return thisCtor = { [className]: class extends target { static [modelBrand] = true constructor(...args: any[]) { + // Model decorator is ignored on base classes + let outerMostModelCtor = getPrototypeChain(new.target).find(ctor => modelBrand in ctor) + if (thisCtor != outerMostModelCtor) { + super(...args) + return + } + let sourceCreation = trackManagedSourceCreation() let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this + let allProperties: readonly ModelProperty[] = emptyArray try { super(...args) let model = this as any as Model + let properties = model[modelProperties] = {} as Record + + let instanceProviders = Object.entries(getInstancePropertyProviders(model)) + for (let [key, getProperty] of instanceProviders) + properties[key] = getProperty() + + let prototypeProviders = getPrototypeChain(target.prototype).flatMap(p => Object.entries(getPrototypePropertyProviders(p))) + for (let [key, getProperty] of prototypeProviders) + properties[key] ??= getProperty(model) // Instance and sub-class properties take precedence over super-class properties + + for (let [key, property] of Object.entries(properties)) { + if (isSource(property)) { + Object.defineProperty(model, key, { + // Bind directly to the property for cleaner call stack + get: getPrototypeChain(property) + .map(proto => Object.getOwnPropertyDescriptor(proto, 'value')) + .find(value => !!value)! + .get!.bind(property), + enumerable: true, + configurable: true + }) + } + } - let properties = {} as Record - - let prototypeFactories = getPrototypePropertyFactories(target.prototype) - for (let key of Object.keys(prototypeFactories)) - properties[key] = initializeModelProperty(model, key, prototypeFactories[key]) - - let instanceFactories = getInstancePropertyFactories(model) - for (let [key, factory] of Object.entries(instanceFactories)) - properties[key] = initializeModelProperty(model, key, factory) - - updateChildren(model, null, properties) // Sets up children with correct paths + allProperties = registeredInstanceProperties.get(model) ?? [] // Includes overridden super-class properties + if (process.env.NODE_ENV !== 'production' && !allProperties.length) + console.warn(`Model ${className} has no @reduced, @derived, or @state properties. You can remove the @model decorator.`) + sourceCreation.verifySourceManagement() - - // Layer this class' properties onto the super class' properties - model[modelProperties] = Object.assign( - Object.create(model[modelProperties] ?? {}), - properties) - - instancePropertyFactories.delete(model) // No longer needed, and avoids sub-models re-initializing properties - - // Outer-most constructor setup - let outerMostModelCtor = getPrototypeChain(new.target).find(ctor => modelBrand in ctor) - if (outerMostModelCtor == thisCtor) { - getterCache.delete(model) // Only needed during construction - - let json = new Derivation(`toJSON(${className})`, () => { - let obj = {} as Record - for (let key in model[modelProperties]) - obj[key] = model[modelProperties][key] - return obj - }) - json.suppressLogging = true - - Object.assign(this, { - toJSON() { return super.toJSON?.() ?? json.value }, - [dispose]() { - // Make sure to cover all the properties in the prototype chain - for (let layer of getPrototypeChain(model[modelProperties])) - for (let property of Object.values(layer)) - updateChildren(model, property, null) - json[dispose]() - super[dispose]?.() - } - }) - } + + // These are no longer needed + registeredInstanceProperties.delete(model) + instancePropertyProviders.delete(model) + getterCache.delete(model) + + Object.assign(this, { + toJSON() { return super.toJSON?.() ?? properties }, + [dispose]() { + for (let property of allProperties) + updateChildren(model, property, null) + super[dispose]?.() + } + }) } finally { accesses[dispose]() sourceCreation[dispose]() @@ -145,26 +151,6 @@ export function model any>(target: Class): }[className] } -function initializeModelProperty

(instance: Model, key: PropertyKey, factory: (instance: any) => P) { - let property = factory(instance) - if (isSource(property)) { // No need to do this for state properties - Object.defineProperty(instance, key, { - // Bind directly to the property for cleaner call stack - get: getPrototypeChain(property) - .map(proto => Object.getOwnPropertyDescriptor(proto, 'value')) - .find(value => !!value)! - .get!.bind(property), - enumerable: true, - configurable: true - }) - } else if (process.env.NODE_ENV !== 'production' - && (isEvent(property.value) || isEventsClass(property.value)) - ) { - throw new EventsMarkedAsStateError(instance, key) - } - return property -} - export function reduced(accessor: ClassAccessorDecoratorTarget, context: ClassAccessorDecoratorContext): ClassAccessorDecoratorResult export function reduced(getter: () => Value, context: ClassGetterDecoratorContext): void export function reduced(prototype: object, key: string | symbol): void @@ -179,10 +165,12 @@ export function reduced(...args: return { init(this: Target, initial: Value) { - let reduction = getUnderlyingReduction(this, context.name, () => initial) - getModelGetterCache(this).set(target.get, reduction) - getInstancePropertyFactories(this)[context.name] = constant(reduction) - return initial + return cachedGetter( + context.name, + target.get, + factory => addInstancePropertyFactory(this, context.name, factory), + instance => getUnderlyingReduction(instance, context.name, () => initial) + ).call(this) }, get(this: Target) { return (getModelGetterCache(this).get(target.get) as Reduction)!.value }, } @@ -190,8 +178,9 @@ export function reduced(...args: // Getter property decorator let [getter, context] = args return cachedGetter( + context.name, getter, - factory => addInstancePropertyFactory(context, factory), + factory => addInstancePropertyFactoryInitializer(context, factory), instance => getUnderlyingReduction(instance, context.name, getter)) } else { @@ -204,6 +193,7 @@ export function reduced(...args: return { get: cachedGetter( + key, getter, factory => addPrototypePropertyFactory(prototype, key, factory), instance => getUnderlyingReduction(instance, key, getter)), @@ -257,8 +247,9 @@ export function derived(...args: // Getter property decorator let [getter, context] = args return cachedGetter( + context.name, getter, - factory => addInstancePropertyFactory(context, factory), + factory => addInstancePropertyFactoryInitializer(context, factory), instance => derive(getter.bind(instance), valuesEqual ?? Object.is, String(context.name))) } else { // Legacy decorator @@ -269,20 +260,22 @@ export function derived(...args: let getter = property.get return { get: cachedGetter( + key, getter, factory => addPrototypePropertyFactory(prototype, key, factory), instance => derive(getter.bind(instance), valuesEqual ?? Object.is, String(key))), enumerable: true, configurable: true } + } else { + return sourceSetter(key, Derivation, () => new InvalidDerivedPropertyError(key)) } - - return sourceSetter(key, Derivation, () => new InvalidDerivedPropertyError(key)) } } } function cachedGetter | IReduction>( + key: PropertyKey, getter: () => SourceValue, addPropertyFactory: (cachedSource: (instance: any) => Source) => void, createSource: (instance: Model) => Source @@ -298,19 +291,23 @@ function cachedGetter | IReduction>( let cache = getModelGetterCache(instance) let source = cache.get(getter) as Source if (!source) - cache.set(getter, source = createSource(instance)) + cache.set(getter, source = registerModelProperty(instance, key, createSource)) return source } } -function addInstancePropertyFactory(context: ClassGetterDecoratorContext, cachedSource: (instance: any) => ISource) { +function addInstancePropertyFactoryInitializer(context: ClassGetterDecoratorContext | ClassAccessorDecoratorContext, cachedSource: (instance: any) => ISource) { context.addInitializer(function (this: Model) { - getInstancePropertyFactories(this)[context.name] = cachedSource.bind(null, this) + addInstancePropertyFactory(this, context.name, cachedSource) }) } +function addInstancePropertyFactory(instance: Model, key: PropertyKey, cachedSource: (instance: any) => ISource) { + getInstancePropertyProviders(instance)[key] = cachedSource.bind(null, instance) +} + function addPrototypePropertyFactory(prototype: object, key: PropertyKey, cachedSource: (instance: any) => ISource) { - getPrototypePropertyFactories(prototype)[key] = cachedSource + getPrototypePropertyProviders(prototype)[key] = cachedSource } function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISource; defaultName: string; }, errorFactory: () => Error): PropertyDescriptor { @@ -324,7 +321,8 @@ function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISour if (source.displayName == sourceType.defaultName) source.setDisplayName(String(key)) - getInstancePropertyFactories(this)[key] = constant(source) + registerModelProperty(this as Model, key, () => source) + getInstancePropertyProviders(this)[key] = constant(source) Object.defineProperty(this, key, { get: () => (source as Source).value, @@ -353,33 +351,45 @@ export function state(...args: if (argsAre(args, isObject, isString)) { // Legacy decorator let [prototype, key] = args - getPrototypePropertyFactories(prototype)[key] = instance => new StateProperty(instance, key) + getPrototypePropertyProviders(prototype)[key] = instance => registerModelProperty(instance, key, () => new StateProperty(instance, key)) } else if (argsAre(args, isUndefined, isClassFieldDecoratorContext)) { // Field decorator let [_, context] = args context.addInitializer(function (this: Model) { - getInstancePropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) + getInstancePropertyProviders(this)[context.name] = () => registerModelProperty(this as any, context.name, () => new StateProperty(this, context.name)) }) } else if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { // Auto-accessor property decorator let [_, context] = args context.addInitializer(function (this: Model) { - getInstancePropertyFactories(this)[context.name] = constant(new StateProperty(this, context.name)) + getInstancePropertyProviders(this)[context.name] = () => registerModelProperty(this as any, context.name, () => new StateProperty(this, context.name)) }) } else { // Class decorator, or legacy constructor parameter decorator return function decorate(ctor: new (...ctorArgs: any[]) => any) { for (let key of args) - getPrototypePropertyFactories(ctor.prototype)[key] = instance => new StateProperty(instance, key) + getPrototypePropertyProviders(ctor.prototype)[key] = instance => registerModelProperty(instance, key, () => new StateProperty(instance, key)) } } } +function registerModelProperty(model: Model, key: PropertyKey, createProperty: (model: Model) => Property) { + let property = createProperty(model) + updateChildren(model, null, property, `.${String(key)}`) + getOrAddWeakEntry(registeredInstanceProperties, model, () => []).push(property) + return property +} + export class StateProperty implements Disposable { constructor( private _instance: any, public readonly key: PropertyKey ) { + if (process.env.NODE_ENV !== 'production') { + if (isEvent(this.value) || isEventsClass(this.value)) + throw new EventsMarkedAsStateError(_instance, this.key) + } + updateChildren(_instance, null, this.value) } @@ -452,13 +462,13 @@ function isDecoratorContext(context: ClassMemberDecoratorContext | unknown): con } /** For new decorators which have access to the instance. */ -function getInstancePropertyFactories(instance: object): Record ModelProperty> { - return getOrAddWeakEntry(instancePropertyFactories, instance, () => ({})) +function getInstancePropertyProviders(instance: object): Record ModelProperty> { + return getOrAddWeakEntry(instancePropertyProviders, instance, () => ({})) } /** For class decorators and legacy decorators that don't have access to the instance. */ -function getPrototypePropertyFactories(prototype: object): Record ModelProperty> { - return getOrAddWeakEntry(prototypePropertyFactories, prototype, () => ({})) +function getPrototypePropertyProviders(prototype: object): Record ModelProperty> { + return getOrAddWeakEntry(prototypePropertyProviders, prototype, () => ({})) } /** For caching getters during model construction, so accessing them doesn't create extra sources. */ diff --git a/tests/State.test.ts b/tests/State.test.ts index 0783c3e..56fb064 100644 --- a/tests/State.test.ts +++ b/tests/State.test.ts @@ -7,8 +7,6 @@ describe("state", () => { let testModel = new TestModel('ctor'); let result = getState(testModel); expect(JSON.stringify(result)).toBe(JSON.stringify({ - subClassProperty: 'subClass', - constructorProperty: 'ctor', valueProperty: 1, valuePlusOne: 2, reducedModel: { value: 'child' }, @@ -17,6 +15,8 @@ describe("state", () => { { value: 'two' } ], mergedModel: { value: 'merged' }, + subClassProperty: 'subClass', + constructorProperty: 'ctor', })); }); }); From e450e96faf5af1088a979bafc0554c2c3676b4a9 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 5 Apr 2026 12:56:55 +1000 Subject: [PATCH 57/83] Throwing when model decorator is missing --- packages/event-reduce/src/models.ts | 46 +++++++++++++++++++++-------- tests-legacy/LegacyModel.test.ts | 33 ++++++++++++++------- tests-legacy/LegacyState.test.ts | 16 ++++++---- tests/Model.test.ts | 35 ++++++++++++++++++++-- 4 files changed, 99 insertions(+), 31 deletions(-) diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index b0af373..7d6cb96 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -86,7 +86,7 @@ export function model object>(target: Clas static [modelBrand] = true constructor(...args: any[]) { // Model decorator is ignored on base classes - let outerMostModelCtor = getPrototypeChain(new.target).find(ctor => modelBrand in ctor) + let outerMostModelCtor = getPrototypeChain(new.target).find(ctor => Object.hasOwn(ctor, modelBrand)) if (thisCtor != outerMostModelCtor) { super(...args) return @@ -126,9 +126,9 @@ export function model object>(target: Clas allProperties = registeredInstanceProperties.get(model) ?? [] // Includes overridden super-class properties if (process.env.NODE_ENV !== 'production' && !allProperties.length) console.warn(`Model ${className} has no @reduced, @derived, or @state properties. You can remove the @model decorator.`) - + sourceCreation.verifySourceManagement() - + // These are no longer needed registeredInstanceProperties.delete(model) instancePropertyProviders.delete(model) @@ -170,7 +170,7 @@ export function reduced(...args: target.get, factory => addInstancePropertyFactory(this, context.name, factory), instance => getUnderlyingReduction(instance, context.name, () => initial) - ).call(this) + ).call(assertIsModel(this, context.name)) }, get(this: Target) { return (getModelGetterCache(this).get(target.get) as Reduction)!.value }, } @@ -196,7 +196,7 @@ export function reduced(...args: key, getter, factory => addPrototypePropertyFactory(prototype, key, factory), - instance => getUnderlyingReduction(instance, key, getter)), + instance => getUnderlyingReduction(assertIsModel(instance, key), key, getter)), enumerable: true, configurable: true } @@ -263,7 +263,7 @@ export function derived(...args: key, getter, factory => addPrototypePropertyFactory(prototype, key, factory), - instance => derive(getter.bind(instance), valuesEqual ?? Object.is, String(key))), + instance => derive(getter.bind(assertIsModel(instance, key)), valuesEqual ?? Object.is, String(key))), enumerable: true, configurable: true } @@ -298,6 +298,7 @@ function cachedGetter | IReduction>( function addInstancePropertyFactoryInitializer(context: ClassGetterDecoratorContext | ClassAccessorDecoratorContext, cachedSource: (instance: any) => ISource) { context.addInitializer(function (this: Model) { + assertIsModel(this, context.name) addInstancePropertyFactory(this, context.name, cachedSource) }) } @@ -314,6 +315,8 @@ function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISour return { set } function set(this: object, value: unknown) { + assertIsModel(this as Model, key) + let source = getUnderlyingSource(value) if (!(source instanceof sourceType)) throw errorFactory() @@ -341,10 +344,10 @@ export function state(accessor: ClassAccessorDecoratorTarget(property: Key): (target: Function, ...rest: any) => void //..rest: any to support both constructor parameters and classes /** Mark a property as a state property. */ export function state(target: object, key: string): void -export function state(...args: +export function state(...args: | string[] - | [undefined, ClassFieldDecoratorContext] - | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] + | [undefined, ClassFieldDecoratorContext] + | [ClassAccessorDecoratorTarget, ClassAccessorDecoratorContext] | [string] | [object, string] ) { @@ -355,14 +358,16 @@ export function state(...args: } else if (argsAre(args, isUndefined, isClassFieldDecoratorContext)) { // Field decorator let [_, context] = args - context.addInitializer(function (this: Model) { - getInstancePropertyProviders(this)[context.name] = () => registerModelProperty(this as any, context.name, () => new StateProperty(this, context.name)) + context.addInitializer(function (this: Target) { + assertIsModel(this, context.name) + getInstancePropertyProviders(this)[context.name] = () => registerModelProperty(this, context.name, () => new StateProperty(this, context.name)) }) } else if (argsAre(args, isClassAccessorDecoratorTarget, isClassAccessorDecoratorContext)) { // Auto-accessor property decorator let [_, context] = args - context.addInitializer(function (this: Model) { - getInstancePropertyProviders(this)[context.name] = () => registerModelProperty(this as any, context.name, () => new StateProperty(this, context.name)) + context.addInitializer(function (this: Target) { + assertIsModel(this, context.name) + getInstancePropertyProviders(this)[context.name] = () => registerModelProperty(this, context.name, () => new StateProperty(this, context.name)) }) } else { // Class decorator, or legacy constructor parameter decorator @@ -373,6 +378,12 @@ export function state(...args: } } +function assertIsModel(model: Target, key: PropertyKey) { + if (process.env.NODE_ENV !== 'production' && !getPrototypeChain(model.constructor).some(p => Object.hasOwn(p, modelBrand))) + throw new MissingModelDecoratorError(model, key) + return model +} + function registerModelProperty(model: Model, key: PropertyKey, createProperty: (model: Model) => Property) { let property = createProperty(model) updateChildren(model, null, property, `.${String(key)}`) @@ -476,6 +487,15 @@ function getModelGetterCache(instance: object): Map { return getOrAddWeakEntry(getterCache, instance, () => new Map()) } +export class MissingModelDecoratorError extends Error { + constructor( + public model: object, + public property: PropertyKey + ) { + super(`${nameOfFunction(model.constructor)} class must be marked as a @model to use @reduced, @derived, or @state properties.`) + } +} + export class InvalidReducedPropertyError extends Error { constructor( public property: PropertyKey diff --git a/tests-legacy/LegacyModel.test.ts b/tests-legacy/LegacyModel.test.ts index 3a7f544..1636af7 100644 --- a/tests-legacy/LegacyModel.test.ts +++ b/tests-legacy/LegacyModel.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" -import { asyncEvent, derive, derived, event, events, extendReduce, model, reduce, reduced, state } from "event-reduce" +import { asyncEvent, derive, derived, disposeModel, event, events, extendReduce, model, reduce, reduced, state } from "event-reduce" import { EventsMarkedAsStateError } from "event-reduce/lib/models" describe("legacy models", () => { @@ -127,6 +127,7 @@ describe("legacy models", () => { }) test("when reducer creates a new model that observes the same event that created it, doesn't throw", () => { + @model class Parent { @reduced child = reduce(null as TestModel | null) @@ -140,10 +141,8 @@ describe("legacy models", () => { expect(parentModel.child).toBeInstanceOf(TestModel) }) - test("when initial value of a model's reduced property is derived from the same event that's creating the model, throws", () => { - reduce(null as ChildModel | null) - .on(increment, () => new ChildModel()) - + test("when reducer creates a nested model on an event, nested model reductions are set up without throwing", () => { + @model class ChildModel { @reduced property = reduce(testModel.property) @@ -151,7 +150,21 @@ describe("legacy models", () => { .value } - expect(() => increment(undefined)).toThrow() // NOCOMMIT probably wrong now + @model + class Registry { + @reduced + root = reduce(null as ChildModel | null) + .on(increment, () => new ChildModel()) + .value + } + + let holder = new Registry() + try { + expect(() => increment(undefined)).not.toThrow() + expect(holder.root).toBeInstanceOf(ChildModel) + } finally { + disposeModel(holder) + } }) test("when event is marked as state, throws when constructed", () => { @@ -207,10 +220,10 @@ describe("events decorator", () => { expect(sut.eventGetter.rejected.displayName).toBe('eventGetter.rejected') }) - test("sets event container", () => { - expect((sut.eventField as any).container).toBe(sut) - expect((sut.eventGetter as any).container).toBe(sut) - }) + test("sets event parents", () => { + expect(sut.eventField.path).toBe('TestEvents.eventField'); + expect(sut.eventGetter.path).toBe('TestEvents.eventGetter'); + }); test("snapshots getter event", () => { sut.eventGetter diff --git a/tests-legacy/LegacyState.test.ts b/tests-legacy/LegacyState.test.ts index 93e2835..b874044 100644 --- a/tests-legacy/LegacyState.test.ts +++ b/tests-legacy/LegacyState.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { derived, getState, reduce, reduced, state } from "event-reduce"; +import { derived, getState, model, reduce, reduced, state } from "event-reduce"; describe("legacy state", () => { + @model class BaseModel { constructor(@state('constructorProperty') public constructorProperty: string) { } @@ -27,11 +28,13 @@ describe("legacy state", () => { ignoredValue = 'ignored'; } + @model class TestModel extends BaseModel { @state subClassProperty = 'subClass'; } + @model class ChildModel { constructor(private _initialValue: string) { } @@ -39,14 +42,14 @@ describe("legacy state", () => { value = reduce(this._initialValue).value; } - let model: TestModel; + let sut: TestModel; beforeEach(() => { - model = new TestModel('ctor'); + sut = new TestModel('ctor'); }); test("getState copies the correct properties", () => { - let result = getState(model); + let result = getState(sut); expect(JSON.stringify(result)).toBe(JSON.stringify({ valueProperty: 1, reducedModel: { value: 'child' }, @@ -54,9 +57,10 @@ describe("legacy state", () => { { value: 'one' }, { value: 'two' } ], + subClassProperty: 'subClass', + valuePlusOne: 2, mergedModel: { value: 'merged' }, - constructorProperty: 'ctor', - subClassProperty: 'subClass' + constructorProperty: 'ctor' })); }); }); diff --git a/tests/Model.test.ts b/tests/Model.test.ts index b9db623..95e3a82 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { asyncEvent, derive, event, events, model, reduce, reduced, state } from "event-reduce"; +import { asyncEvent, derive, derived, event, events, model, reduce, reduced, state } from "event-reduce"; import { Derivation } from "event-reduce/lib/derivation"; -import { EventsMarkedAsStateError } from "event-reduce/lib/models"; +import { EventsMarkedAsStateError, MissingModelDecoratorError } from "event-reduce/lib/models"; import { decrement, ExtendedModel } from "./Model.test/ExtendedModel"; import { increment, TestModel } from "./Model.test/TestModel"; @@ -103,6 +103,37 @@ describe("models", () => { expect((e as EventsMarkedAsStateError).property).toBe('event'); } }); + + test("when class is not decorated with model, @reduced throws when constructed", () => { + class Bad { + @reduced + get x() { + return reduce(1).value; + } + } + + expect(() => new Bad()).toThrow(MissingModelDecoratorError); + }); + + test("when class is not decorated with model, @derived throws when constructed", () => { + class Bad { + @derived + get x() { + return 1; + } + } + + expect(() => new Bad()).toThrow(MissingModelDecoratorError); + }); + + test("when class is not decorated with model, @state throws when constructed", () => { + class Bad { + @state + x = 1; + } + + expect(() => new Bad()).toThrow(MissingModelDecoratorError); + }); }); }); From 87b0a0995cd0a64e33b71feb2acb033aa68df488 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 8 Apr 2026 19:29:21 +1000 Subject: [PATCH 58/83] model decorator doesn't override existing toJSON method --- packages/event-reduce/src/models.ts | 4 +++- tests/Model.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index 7d6cb96..ea93e4b 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -134,8 +134,10 @@ export function model object>(target: Clas instancePropertyProviders.delete(model) getterCache.delete(model) + if (typeof (model as any).toJSON != 'function') + Object.assign(this, { toJSON() { return properties } }) + Object.assign(this, { - toJSON() { return super.toJSON?.() ?? properties }, [dispose]() { for (let property of allProperties) updateChildren(model, property, null) diff --git a/tests/Model.test.ts b/tests/Model.test.ts index 95e3a82..b76a9cb 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -134,6 +134,25 @@ describe("models", () => { expect(() => new Bad()).toThrow(MissingModelDecoratorError); }); + + test("when base class defines toJSON, model decorator does not override it", () => { + class BaseModel { + toJSON() { + return { serializedValue: "base serializer" }; + } + } + + @model + class DerivedModel extends BaseModel { + @state + value = 1; + } + + let sut = new DerivedModel(); + + expect(sut.toJSON()).toEqual({ serializedValue: "base serializer" }); + expect(JSON.stringify(sut)).toBe(JSON.stringify({ serializedValue: "base serializer" })); + }); }); }); From 236c7c8ca778279b87a3c01c9e73334168c5c8c2 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 2 May 2026 11:21:01 +1000 Subject: [PATCH 59/83] filterContext method on async events for old filter behavior where helper properties are maintained --- packages/event-reduce/src/events.ts | 43 +++++++++---- packages/event-reduce/src/index.ts | 2 +- tests/Event.test.ts | 94 +++++++++++++++++++++++++++-- 3 files changed, 119 insertions(+), 20 deletions(-) diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index 3f2f829..befbd22 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -2,7 +2,7 @@ import { startLogging } from "./logging" import { type ISource, Source } from "./sources" import { beginTransition } from "./tracking" import { Scoped, SourceValue } from "./types" -import { dispose, matchesScope } from "./utils" +import { dispose, matchesScope, nameOfCallback } from "./utils" export interface IEvent extends IEventFunction { scope>(scope: Scope): IEvent, Out> @@ -34,10 +34,16 @@ export function event(name: string = "(anonymous event)"): IEvent { }) as IEvent } -export interface IAsyncEvent extends IEventFunction, [promise: PromiseLike, context: Context]> { +export interface IAsyncSource extends ISource> { started: ISource> resolved: ISource> rejected: ISource> + + filterContext(predicate: (context: Context) => context is FilteredContext, displayName?: string): IAsyncSource + filterContext(predicate: (context: Context) => boolean, displayName?: string): IAsyncSource +} + +export interface IAsyncEvent extends IEventFunction, [promise: PromiseLike, context: Context]>, IAsyncSource { scope>(scope: Scope): IAsyncEvent> } @@ -101,28 +107,39 @@ export function asyncEvent(name: string = "(anony return promise }, function asyncEventMembers(e: IEventFunction, [promise: PromiseLike, context: CurrentContext]>) { - let setBaseDisplayName = e.setDisplayName return { - started: e.filter(e => e.type == 'start', `${e.displayName}.started`), - resolved: e.filter(e => e.type == 'result', `${e.displayName}.resolved`), - rejected: e.filter(e => e.type == 'error', `${e.displayName}.rejected`), + ...asyncSourceMembers(e), scope>(scope: Scope): IAsyncEvent> { return bindEvent(e, `${e.displayName}.scoped(${JSON.stringify(scope)})`, (promise: PromiseLike, context: Scoped) => e(promise, { ...context, ...scope } as CurrentContext), v => matchesScope(scope, v.context), asyncEventMembers) as IAsyncEvent> - }, - setDisplayName(name: string) { - setBaseDisplayName.call(e, name) - this.started.setDisplayName(`${name}.started`) - this.resolved.setDisplayName(`${name}.resolved`) - this.rejected.setDisplayName(`${name}.rejected`) } } }) as IAsyncEvent } +function asyncSourceMembers(source: ISource>) { + let setBaseDisplayName = source.setDisplayName + + return { + started: source.filter(e => e.type == 'start', `${source.displayName}.started`), + resolved: source.filter(e => e.type == 'result', `${source.displayName}.resolved`), + rejected: source.filter(e => e.type == 'error', `${source.displayName}.rejected`), + filterContext(predicate: (context: Context) => boolean, displayName = `${source.displayName}.filterContext(${nameOfCallback(predicate)})`) { + let filtered = source.filter(e => predicate(e.context), displayName) + return Object.assign(filtered, asyncSourceMembers(filtered)) + }, + setDisplayName(this: IAsyncSource, name: string) { + setBaseDisplayName.call(source, name) + this.started.setDisplayName(`${name}.started`) + this.resolved.setDisplayName(`${name}.resolved`) + this.rejected.setDisplayName(`${name}.rejected`) + } + } +} + /** Creates a callable event object. */ export function makeEventFunction( name: string, @@ -181,4 +198,4 @@ class Event extends Source { export function isEvent(value: any): value is IEventFunction { return typeof value == 'function' && value instanceof Event -} \ No newline at end of file +} diff --git a/packages/event-reduce/src/index.ts b/packages/event-reduce/src/index.ts index 69500cd..69fe062 100644 --- a/packages/event-reduce/src/index.ts +++ b/packages/event-reduce/src/index.ts @@ -2,7 +2,7 @@ export { Atom } from "./atom" export { derive, type IDerivation } from "./derivation" export { enableDevTools } from "./devtools" export { Effect } from "./effect" -export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IEvent } from "./events" +export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IAsyncSource, type IEvent } from "./events" export { enableLogging, log, startLogging } from "./logging" export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" diff --git a/tests/Event.test.ts b/tests/Event.test.ts index a852695..fc7f7ea 100644 --- a/tests/Event.test.ts +++ b/tests/Event.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart } from 'event-reduce'; +import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type IAsyncSource } from 'event-reduce'; import { SideEffectDuringReactionError } from "event-reduce/lib/tracking"; import { MockObserver } from "./MockObserver"; @@ -99,7 +99,7 @@ describe("asyncEvent", () => { sut.rejected.subscribe(rejected); }); - describe("when called", () => { + describe("when called", () => { let promise: Promise; let resolve: (value: Result) => void; let reject: (error: any) => void; @@ -126,7 +126,89 @@ describe("asyncEvent", () => { reject(error); await promise.catch(() => { }); // Catch to prevent unhandled rejection - expect(rejected.sourceUpdated).toHaveBeenCalledWith(sut.rejected, expect.objectContaining({ error, context })); - }); - }); -}); + expect(rejected.sourceUpdated).toHaveBeenCalledWith(sut.rejected, expect.objectContaining({ error, context })); + }); + }); + + describe("filterContext", () => { + let filtered: IAsyncSource; + let filteredStarted: MockObserver>; + let filteredResolved: MockObserver>; + let filteredRejected: MockObserver>; + + beforeEach(() => { + filtered = sut.filterContext(context => context.bar == 'match'); + filteredStarted = new MockObserver(); + filteredResolved = new MockObserver(); + filteredRejected = new MockObserver(); + filtered.started.subscribe(filteredStarted); + filtered.resolved.subscribe(filteredResolved); + filtered.rejected.subscribe(filteredRejected); + }); + + test("when context matches, filters all async event phases through", async () => { + let resolved = Promise.resolve({ foo: 'result' }); + let rejected = Promise.reject({ message: 'error' }); + rejected.catch(() => { }); // Catch to prevent unhandled rejection + + sut(resolved, { bar: 'match' }); + sut(rejected, { bar: 'match' }); + await resolved; + await rejected.catch(() => { }); + + expect(filteredStarted.sourceUpdated).toHaveBeenCalledWith(filtered.started, expect.objectContaining({ context: { bar: 'match' } })); + expect(filteredResolved.sourceUpdated).toHaveBeenCalledWith(filtered.resolved, expect.objectContaining({ result: { foo: 'result' }, context: { bar: 'match' } })); + expect(filteredRejected.sourceUpdated).toHaveBeenCalledWith(filtered.rejected, expect.objectContaining({ error: { message: 'error' }, context: { bar: 'match' } })); + }); + + test("when context does not match, filters all async event phases out", async () => { + let resolved = Promise.resolve({ foo: 'result' }); + let rejected = Promise.reject({ message: 'error' }); + rejected.catch(() => { }); // Catch to prevent unhandled rejection + + sut(resolved, { bar: 'other' }); + sut(rejected, { bar: 'other' }); + await resolved; + await rejected.catch(() => { }); + + expect(filteredStarted.sourceUpdated).not.toHaveBeenCalled(); + expect(filteredResolved.sourceUpdated).not.toHaveBeenCalled(); + expect(filteredRejected.sourceUpdated).not.toHaveBeenCalled(); + }); + + test("can chain context filters", async () => { + let chained = sut + .filterContext(context => context.bar.length > 1) + .filterContext(context => context.bar == 'match'); + let observed = new MockObserver>(); + chained.resolved.subscribe(observed); + + let resolved = Promise.resolve({ foo: 'result' }); + sut(resolved, { bar: 'match' }); + await resolved; + + expect(observed.sourceUpdated).toHaveBeenCalledWith(chained.resolved, expect.objectContaining({ result: { foo: 'result' }, context: { bar: 'match' } })); + }); + + test("sets display names for filtered async sources and their phases", () => { + let filtered = sut.filterContext(context => context.bar == 'match', 'matching'); + + expect(filtered.displayName).toBe('matching'); + expect(filtered.started.displayName).toBe('matching.started'); + expect(filtered.resolved.displayName).toBe('matching.resolved'); + expect(filtered.rejected.displayName).toBe('matching.rejected'); + }); + + test("supports context type guards", () => { + type MixedContext = + | { kind: 'matching'; bar: string } + | { kind: 'other'; baz: string }; + + let mixed = asyncEvent(); + let matching = mixed.filterContext((context): context is Extract => context.kind == 'matching'); + let expected: IAsyncSource = matching; + + expected.resolved.subscribe(new MockObserver>()); + }); + }); +}); From 692a4103d641b8ed646b0ad7dd3821629df1d1f0 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 3 May 2026 08:44:10 +1000 Subject: [PATCH 60/83] Phased event helpers --- packages/event-reduce/src/events.ts | 476 ++++++++++++++++------------ tests/Event.test.ts | 365 +++++++++++++-------- 2 files changed, 509 insertions(+), 332 deletions(-) diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index befbd22..08c57a7 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -1,201 +1,275 @@ -import { startLogging } from "./logging" -import { type ISource, Source } from "./sources" -import { beginTransition } from "./tracking" -import { Scoped, SourceValue } from "./types" -import { dispose, matchesScope, nameOfCallback } from "./utils" - -export interface IEvent extends IEventFunction { - scope>(scope: Scope): IEvent, Out> -} - -export interface IEventFunction extends ISource { - (...args: Args): Return -} - -export function event(name: string = "(anonymous event)"): IEvent { - return makeEventFunction(name, function event(emit: (value: T) => void, value: T) { - let logging = startLogging('⚡ (event)', name, [value], () => ({ Path: this.path })) - try { - emit(value) - } finally { - logging?.[dispose]() - } - return value - }, function eventMembers(e: IEventFunction) { - return { - scope>(scope: Scope): IEvent, T> { - return bindEvent(e, - `${e.displayName}.scoped(${JSON.stringify(scope)})`, - (value: Scoped) => e({ ...value, ...scope } as In), - matchesScope(scope), - eventMembers) as IEvent, T> - } - } - }) as IEvent -} - -export interface IAsyncSource extends ISource> { - started: ISource> - resolved: ISource> - rejected: ISource> - - filterContext(predicate: (context: Context) => context is FilteredContext, displayName?: string): IAsyncSource - filterContext(predicate: (context: Context) => boolean, displayName?: string): IAsyncSource -} - -export interface IAsyncEvent extends IEventFunction, [promise: PromiseLike, context: Context]>, IAsyncSource { - scope>(scope: Scope): IAsyncEvent> -} - -export type AsyncValue = - | AsyncStart - | AsyncResult - | AsyncError - -export interface AsyncStart { - type: 'start' - context: Context - promise: PromiseLike -} - -export interface AsyncResult { - type: 'result' - context: Context - result: Result -} - -export interface AsyncError { - type: 'error' - context: Context - error: unknown -} - -export function asyncEvent(name: string = "(anonymous async event)"): IAsyncEvent { - return makeEventFunction(name, function asyncEvent

>( - this: IEventFunction, [promise: PromiseLike, context: Context]>, - emit: (value: AsyncValue) => void, - promise: P, - context: Context - ) { - let val = { type: 'start', promise, context } satisfies AsyncStart - let logging = startLogging('⚡⌚ (async event)', name, [val], () => ({ Path: this.path, Promise: promise })) - try { - emit(val) - } finally { - logging?.[dispose]() - } - - promise.then( - result => { - let val = { type: 'result', result, context } satisfies AsyncResult - let logging = startLogging('⚡✔ (async resolved)', name, [val], () => ({ Path: this.path, Promise: promise })) - try { - emit(val) - } finally { - logging?.[dispose]() - } - }, - error => { - let val = { type: 'error', error, context } satisfies AsyncError - let logging = startLogging('⚡✘ (async rejected)', name, [val], () => ({ Path: this.path, Promise: promise })) - try { - emit(val) - } finally { - logging?.[dispose]() - } - }) - - return promise - }, function asyncEventMembers(e: IEventFunction, [promise: PromiseLike, context: CurrentContext]>) { - return { - ...asyncSourceMembers(e), - scope>(scope: Scope): IAsyncEvent> { - return bindEvent(e, - `${e.displayName}.scoped(${JSON.stringify(scope)})`, - (promise: PromiseLike, context: Scoped) => e(promise, { ...context, ...scope } as CurrentContext), - v => matchesScope(scope, v.context), - asyncEventMembers) as IAsyncEvent> - } - } - }) as IAsyncEvent -} - -function asyncSourceMembers(source: ISource>) { - let setBaseDisplayName = source.setDisplayName - - return { - started: source.filter(e => e.type == 'start', `${source.displayName}.started`), - resolved: source.filter(e => e.type == 'result', `${source.displayName}.resolved`), - rejected: source.filter(e => e.type == 'error', `${source.displayName}.rejected`), - filterContext(predicate: (context: Context) => boolean, displayName = `${source.displayName}.filterContext(${nameOfCallback(predicate)})`) { - let filtered = source.filter(e => predicate(e.context), displayName) - return Object.assign(filtered, asyncSourceMembers(filtered)) - }, - setDisplayName(this: IAsyncSource, name: string) { - setBaseDisplayName.call(source, name) - this.started.setDisplayName(`${name}.started`) - this.resolved.setDisplayName(`${name}.resolved`) - this.rejected.setDisplayName(`${name}.rejected`) - } - } -} - -/** Creates a callable event object. */ -export function makeEventFunction( - name: string, - fire: (this: IEventFunction, emit: (value: T) => void, ...args: Args) => Return, - getMembers?: AdditionalMembersFactory, Members> -): IEventFunction & Members { - let eventSource = new Event(name) as Event & IEventFunction - return makeSourceFunction(eventSource, event, getMembers) - - function event(...args: Args) { return fire.call(event as IEventFunction, eventSource.emit.bind(event), ...args) } -} - -/** Wrap an event with input and output filter. Useful for scoping. */ -export function bindEvent, Args extends any[], Return, Members extends object>( - event: E, - displayName: string, - fire: (...args: Args) => Return, - predicate: (value: SourceValue) => boolean, - getMembers?: AdditionalMembersFactory, Members> -) { - return makeSourceFunction(event.filter(predicate, displayName), fire, getMembers) -} - -function makeSourceFunction, Args extends any[], Return, Members extends object>( - innerEvent: Source, - fire: (...args: Args) => Return, - getMembers?: AdditionalMembersFactory -): IEventFunction, Args, Return> & Members { - let eventFn = fire as Source & IEventFunction, Args, Return> - Object.setPrototypeOf(eventFn, innerEvent); - eventFn.apply = Function.prototype.apply; - eventFn.call = Function.prototype.call; - return getMembers - ? Object.assign(eventFn, getMembers(eventFn)) - : eventFn as Source & IEventFunction, Args, Return> & Members -} - -export type AdditionalMembersFactory, Members extends object> = - (source: Source & IEventFunction, any>) => Members - -class Event extends Source { - emit(value: T) { - let transition = beginTransition(this, true) - try { - this.setValue(value) - } finally { - this._value = undefined! // Events shouldn't hold on to their values - transition[dispose]() - } - } - override isValidAsState = false - override skipUpdate(nextValue: T) { return false } - protected override updateChildren(previousValue: T, nextValue: T) { } // Value won't be held onto -} - -export function isEvent(value: any): value is IEventFunction { - return typeof value == 'function' - && value instanceof Event -} +import { startLogging } from "./logging" +import { type ISource, Source } from "./sources" +import { beginTransition } from "./tracking" +import { Scoped, SourceValue } from "./types" +import { dispose, matchesScope, nameOfCallback } from "./utils" + +export function event(name: string = "(anonymous event)"): IEvent { + return makeEvent({ + name, + fire(emit: (value: T) => void, value: T) { + let logging = startLogging('⚡ (event)', name, [value], () => ({ Path: this.path })) + try { + emit(value) + } finally { + logging?.[dispose]() + } + return value + }, + additionalMembers: (e: IEventFunction) => ({ + scope>(scope: Scope): IEvent, T> { + return bindEvent(e, + `${e.displayName}.scoped(${JSON.stringify(scope)})`, + (value: Scoped) => e({ ...value, ...scope } as In), + matchesScope(scope)) as IEvent, T> + } + }) + }) as IEvent +} + +export function asyncEvent(name: string = "(anonymous async event)"): IAsyncEvent { + return makePhasedEvent({ + name, + phases: ['started', 'resolved', 'rejected'], + fire

>( + emit: (value: AsyncValue) => void, + promise: P, + context: Context + ) { + let val = { phase: 'started', promise, context } satisfies AsyncStart + let logging = startLogging('⚡⌚ (async event)', name, [val], () => ({ Path: this.path, Promise: promise })) + try { + emit(val) + } finally { + logging?.[dispose]() + } + + promise.then( + result => { + let val = { phase: 'resolved', result, context } satisfies AsyncResult + let logging = startLogging('⚡✔ (async resolved)', name, [val], () => ({ Path: this.path, Promise: promise })) + try { + emit(val) + } finally { + logging?.[dispose]() + } + }, + error => { + let val = { phase: 'rejected', error, context } satisfies AsyncError + let logging = startLogging('⚡✘ (async rejected)', name, [val], () => ({ Path: this.path, Promise: promise })) + try { + emit(val) + } finally { + logging?.[dispose]() + } + }) + + return promise + } + }) as IAsyncEvent +} + +/** + * Creates a callable event with named phase sources. + * + * Use this to build custom async-style events, such as observable or stream events. The `fire` + * callback receives an `emit` function; emit values must include a `phase` matching one of + * `phases` and a `context` matching the last argument passed to the event. The returned event + * exposes a source for each phase, plus `filterContext` and `scope`. + */ +export function makePhasedEvent< + const Phases extends readonly string[], + Args extends any[], + Return, + Value extends { phase: Phases[number], context: ContextArg } +>( + { + name, + phases, + fire + }: { + name: string, + phases: Phases, + fire: (emit: (value: Value) => void, ...args: Args) => Return + } & ThisType> +): IPhasedEvent { + return makeEvent({ + name, + fire, + additionalMembers: (e: IEventFunction, ScopedArgs, Return>) => ({ + ...phasedSourceMembers, CurrentContext>( + e as unknown as ISource>, + phases as readonly (NarrowContext['phase'] & string)[] + ), + scope : never>(scope: Scope): IPhasedEvent, ScopedArgs>, Return, CurrentContext & Scope> { + return bindEvent(e, + `${e.displayName}.scoped(${JSON.stringify(scope)})`, + (...args: ScopedArgs>) => { + let argsWithScope = args.slice() + argsWithScope[argsWithScope.length - 1] = { ...argsWithScope[argsWithScope.length - 1], ...scope } + return e(...argsWithScope as ScopedArgs) + }, + v => matchesScope(scope, v.context), + ) as unknown as IPhasedEvent, ScopedArgs>, Return, CurrentContext & Scope> + } + }) + }) as IPhasedEvent +} + +/** Creates a callable event object. */ +export function makeEvent( + { + name, + fire, + additionalMembers + }: { + name: string, + fire: (this: IEventFunction, emit: (value: T) => void, ...args: Args) => Return, + additionalMembers?: AdditionalMembersFactory, Members> + } & ThisType> +): IEventFunction & Members { + let eventSource = new Event(name) as Event & IEventFunction + return makeSourceFunction(eventSource, event, additionalMembers) + + function event(...args: Args) { return fire.call(event as IEventFunction, eventSource.emit.bind(event), ...args) } +} + +/** Wrap an event with input and output filter. Useful for scoping. */ +export function bindEvent, Args extends any[], Return>( + event: E, + displayName: string, + fire: (...args: Args) => Return, + predicate: (value: SourceValue) => boolean +) { + return makeSourceFunction(event.filter(predicate, displayName), fire, (event as EventWithAdditionalMembers)[membersFactory]) +} + +function phasedSourceMembers(source: ISource, phases: readonly (Value['phase'] & string)[]) { + let setBaseDisplayName = source.setDisplayName + let phaseMembers = Object.fromEntries(phases.map(phase => [ + phase, + source.filter(e => e.phase == phase, `${source.displayName}.${phase}`) + ])) + + return { + ...phaseMembers, + filterContext(predicate: (context: Context) => boolean, displayName = `${source.displayName}.filterContext(${nameOfCallback(predicate)})`) { + let filtered = source.filter(e => predicate(e.context), displayName) + return Object.assign(filtered, phasedSourceMembers(filtered, phases)) + }, + setDisplayName(this: IPhasedSource, name: string) { + setBaseDisplayName.call(source, name) + for (let phase of phases) + this[phase].setDisplayName(`${name}.${phase}`) + } + } +} + +function makeSourceFunction, Args extends any[], Return, Members extends object>( + innerEvent: Source, + fire: (...args: Args) => Return, + additionalMembers?: AdditionalMembersFactory +): IEventFunction, Args, Return> & Members { + let eventFn = fire as Source & IEventFunction, Args, Return> + Object.setPrototypeOf(eventFn, innerEvent); + eventFn.apply = Function.prototype.apply; + eventFn.call = Function.prototype.call; + return additionalMembers + ? Object.assign(eventFn, additionalMembers(eventFn), { [membersFactory]: additionalMembers as AdditionalMembersFactory, any> }) + : eventFn as Source & IEventFunction, Args, Return> & Members +} + +export function isEvent(value: any): value is IEventFunction { + return typeof value == 'function' + && value instanceof Event +} + +export interface IEvent extends IEventFunction { + scope>(scope: Scope): IEvent, Out> +} + +export interface IEventFunction extends ISource { + (...args: Args): Return +} + +export interface IAsyncSource extends ISource> { + started: ISource> + resolved: ISource> + rejected: ISource> + + filterContext(predicate: (context: Context) => context is FilteredContext, displayName?: string): IAsyncSource + filterContext(predicate: (context: Context) => boolean, displayName?: string): IAsyncSource +} + +export interface IAsyncEvent extends IEventFunction, [promise: PromiseLike, context: Context]>, IAsyncSource { + scope>(scope: Scope): IAsyncEvent> +} + +export type AsyncValue = + | AsyncStart + | AsyncResult + | AsyncError + +export interface AsyncStart { + phase: 'started' + context: Context + promise: PromiseLike +} + +export interface AsyncResult { + phase: 'resolved' + context: Context + result: Result +} + +export interface AsyncError { + phase: 'rejected' + context: Context + error: unknown +} + +export type IPhasedSource = + ISource + & { [Phase in Value['phase'] & string]: ISource> } + & { + filterContext(predicate: (context: Context) => context is FilteredContext, displayName?: string): IPhasedSource, FilteredContext> + filterContext(predicate: (context: Context) => boolean, displayName?: string): IPhasedSource + } + +export type IPhasedEvent> = + IEventFunction + & IPhasedSource + & { + scope : never>( + scope: Scope + ): IPhasedEvent, ScopedArgs>, Return, Context & Scope> + } + +export type AdditionalMembersFactory, Members extends object> = + (source: Source & IEventFunction, any>) => Members + +class Event extends Source { + emit(value: T) { + let transition = beginTransition(this, true) + try { + this.setValue(value) + } finally { + this._value = undefined! // Events shouldn't hold on to their values + transition[dispose]() + } + } + override isValidAsState = false + override skipUpdate(nextValue: T) { return false } + protected override updateChildren(previousValue: T, nextValue: T) { } // Value won't be held onto +} + +const membersFactory = Symbol('event members factory') + +type ContextArg = Args extends [...any[], infer Ctx] ? Ctx : never +type NarrowContext = Value & { context: Context } +type ScopedArgs = + Args extends [...infer Rest, any] + ? [...Rest, Context] + : never + +type EventWithAdditionalMembers = { [membersFactory]?: AdditionalMembersFactory, any> } diff --git a/tests/Event.test.ts b/tests/Event.test.ts index fc7f7ea..5e614b8 100644 --- a/tests/Event.test.ts +++ b/tests/Event.test.ts @@ -1,132 +1,133 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, test } from "bun:test"; import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type IAsyncSource } from 'event-reduce'; -import { SideEffectDuringReactionError } from "event-reduce/lib/tracking"; -import { MockObserver } from "./MockObserver"; - -describe("event", () => { - type TestType = { foo: string, bar: number }; - let sut: ReturnType>; - - beforeEach(() => { - sut = event('sut'); - }); - - describe("when subscribed to", () => { - let subscriber: MockObserver; - - beforeEach(() => { - subscriber = new MockObserver() - sut.subscribe(subscriber); - }); - - test("when called, passes value to subscribers", () => { - let value = { foo: 'foo', bar: 1 }; - - sut(value); - - expect(subscriber.sourceUpdated).toHaveBeenCalledWith(sut, value); - }); - }); - - test("when handler calls another event, throws", () => { - let otherEvent = event('otherEvent'); - let observer = new MockObserver() - observer.sourceUpdated = () => otherEvent(undefined) - sut.subscribe(observer); - - try { - sut({ foo: 'foo', bar: 1 }); - expect.unreachable("Should have thrown"); - } catch (e) { - expect(e).toBeInstanceOf(SideEffectDuringReactionError); - expect((e as SideEffectDuringReactionError).reactingTo).toBe(sut); - expect((e as SideEffectDuringReactionError).sideEffect).toBe(otherEvent); - } - }); - - describe("scope", () => { - let scoped: any; - let rootObserver: MockObserver; - let scopedObserver: MockObserver; - - beforeEach(() => { - scoped = sut.scope({ foo: 'foo' }); - rootObserver = new MockObserver() - scopedObserver = new MockObserver() - sut.subscribe(rootObserver); - scoped.subscribe(scopedObserver); - }); - - test("when non-matching value passed to parent event, scoped event does not pass on value", () => { - sut({ foo: 'bar', bar: 1 }); - - expect(scopedObserver.sourceUpdated).not.toHaveBeenCalled(); - }); - - test("when matching value passed to parent event, scoped event passed on value", () => { - let value = { foo: 'foo', bar: 1 }; - - sut(value); - - expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, value); - }); - - test("when called with partial value, scoped event fills in scope values", () => { - scoped({ bar: 2 }); - - expect(rootObserver.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ foo: 'foo', bar: 2 })); - expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, expect.objectContaining({ foo: 'foo', bar: 2 })); - }); - }); -}); - -describe("asyncEvent", () => { - type Result = { foo: string; }; - type Context = { bar: string; }; - let sut: ReturnType>; - - let started: MockObserver>; - let resolved: MockObserver>; - let rejected: MockObserver>; - - beforeEach(() => { - sut = asyncEvent(); - started = new MockObserver() - resolved = new MockObserver() - rejected = new MockObserver() - sut.started.subscribe(started); - sut.resolved.subscribe(resolved); - sut.rejected.subscribe(rejected); - }); - +import { makePhasedEvent, type IPhasedSource } from "event-reduce/lib/events"; +import { SideEffectDuringReactionError } from "event-reduce/lib/tracking"; +import { MockObserver } from "./MockObserver"; + +describe("event", () => { + type TestType = { foo: string, bar: number }; + let sut: ReturnType>; + + beforeEach(() => { + sut = event('sut'); + }); + + describe("when subscribed to", () => { + let subscriber: MockObserver; + + beforeEach(() => { + subscriber = new MockObserver() + sut.subscribe(subscriber); + }); + + test("when called, passes value to subscribers", () => { + let value = { foo: 'foo', bar: 1 }; + + sut(value); + + expect(subscriber.sourceUpdated).toHaveBeenCalledWith(sut, value); + }); + }); + + test("when handler calls another event, throws", () => { + let otherEvent = event('otherEvent'); + let observer = new MockObserver() + observer.sourceUpdated = () => otherEvent(undefined) + sut.subscribe(observer); + + try { + sut({ foo: 'foo', bar: 1 }); + expect.unreachable("Should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(SideEffectDuringReactionError); + expect((e as SideEffectDuringReactionError).reactingTo).toBe(sut); + expect((e as SideEffectDuringReactionError).sideEffect).toBe(otherEvent); + } + }); + + describe("scope", () => { + let scoped: any; + let rootObserver: MockObserver; + let scopedObserver: MockObserver; + + beforeEach(() => { + scoped = sut.scope({ foo: 'foo' }); + rootObserver = new MockObserver() + scopedObserver = new MockObserver() + sut.subscribe(rootObserver); + scoped.subscribe(scopedObserver); + }); + + test("when non-matching value passed to parent event, scoped event does not pass on value", () => { + sut({ foo: 'bar', bar: 1 }); + + expect(scopedObserver.sourceUpdated).not.toHaveBeenCalled(); + }); + + test("when matching value passed to parent event, scoped event passed on value", () => { + let value = { foo: 'foo', bar: 1 }; + + sut(value); + + expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, value); + }); + + test("when called with partial value, scoped event fills in scope values", () => { + scoped({ bar: 2 }); + + expect(rootObserver.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ foo: 'foo', bar: 2 })); + expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, expect.objectContaining({ foo: 'foo', bar: 2 })); + }); + }); +}); + +describe("asyncEvent", () => { + type Result = { foo: string; }; + type Context = { bar: string; }; + let sut: ReturnType>; + + let started: MockObserver>; + let resolved: MockObserver>; + let rejected: MockObserver>; + + beforeEach(() => { + sut = asyncEvent(); + started = new MockObserver() + resolved = new MockObserver() + rejected = new MockObserver() + sut.started.subscribe(started); + sut.resolved.subscribe(resolved); + sut.rejected.subscribe(rejected); + }); + describe("when called", () => { - let promise: Promise; - let resolve: (value: Result) => void; - let reject: (error: any) => void; - let context: Context; - - beforeEach(() => { - ({ promise, resolve, reject } = Promise.withResolvers()); - context = { bar: 'context' }; - sut(promise, context); - }); - - test("started event fired", () => expect(started.sourceUpdated).toHaveBeenCalledWith(sut.started, expect.objectContaining({ promise, context }))); - - test("when promise resolved, resolved event fired", async () => { - let result = { foo: 'result' }; - resolve(result); - await promise; - - expect(resolved.sourceUpdated).toHaveBeenCalledWith(sut.resolved, expect.objectContaining({ result, context })); - }); - - test("when promise rejected, rejected event fired", async () => { - let error = { message: 'error' }; - reject(error); - await promise.catch(() => { }); // Catch to prevent unhandled rejection - - expect(rejected.sourceUpdated).toHaveBeenCalledWith(sut.rejected, expect.objectContaining({ error, context })); + let promise: Promise; + let resolve: (value: Result) => void; + let reject: (error: any) => void; + let context: Context; + + beforeEach(() => { + ({ promise, resolve, reject } = Promise.withResolvers()); + context = { bar: 'context' }; + sut(promise, context); + }); + + test("started event fired", () => expect(started.sourceUpdated).toHaveBeenCalledWith(sut.started, expect.objectContaining({ phase: 'started', promise, context }))); + + test("when promise resolved, resolved event fired", async () => { + let result = { foo: 'result' }; + resolve(result); + await promise; + + expect(resolved.sourceUpdated).toHaveBeenCalledWith(sut.resolved, expect.objectContaining({ phase: 'resolved', result, context })); + }); + + test("when promise rejected, rejected event fired", async () => { + let error = { message: 'error' }; + reject(error); + await promise.catch(() => { }); // Catch to prevent unhandled rejection + + expect(rejected.sourceUpdated).toHaveBeenCalledWith(sut.rejected, expect.objectContaining({ phase: 'rejected', error, context })); }); }); @@ -156,9 +157,9 @@ describe("asyncEvent", () => { await resolved; await rejected.catch(() => { }); - expect(filteredStarted.sourceUpdated).toHaveBeenCalledWith(filtered.started, expect.objectContaining({ context: { bar: 'match' } })); - expect(filteredResolved.sourceUpdated).toHaveBeenCalledWith(filtered.resolved, expect.objectContaining({ result: { foo: 'result' }, context: { bar: 'match' } })); - expect(filteredRejected.sourceUpdated).toHaveBeenCalledWith(filtered.rejected, expect.objectContaining({ error: { message: 'error' }, context: { bar: 'match' } })); + expect(filteredStarted.sourceUpdated).toHaveBeenCalledWith(filtered.started, expect.objectContaining({ phase: 'started', context: { bar: 'match' } })); + expect(filteredResolved.sourceUpdated).toHaveBeenCalledWith(filtered.resolved, expect.objectContaining({ phase: 'resolved', result: { foo: 'result' }, context: { bar: 'match' } })); + expect(filteredRejected.sourceUpdated).toHaveBeenCalledWith(filtered.rejected, expect.objectContaining({ phase: 'rejected', error: { message: 'error' }, context: { bar: 'match' } })); }); test("when context does not match, filters all async event phases out", async () => { @@ -187,7 +188,7 @@ describe("asyncEvent", () => { sut(resolved, { bar: 'match' }); await resolved; - expect(observed.sourceUpdated).toHaveBeenCalledWith(chained.resolved, expect.objectContaining({ result: { foo: 'result' }, context: { bar: 'match' } })); + expect(observed.sourceUpdated).toHaveBeenCalledWith(chained.resolved, expect.objectContaining({ phase: 'resolved', result: { foo: 'result' }, context: { bar: 'match' } })); }); test("sets display names for filtered async sources and their phases", () => { @@ -212,3 +213,105 @@ describe("asyncEvent", () => { }); }); }); + +describe("makePhasedEvent", () => { + type Context = { id: string, group: string }; + type Value = + | { phase: 'started', input: string, context: Context } + | { phase: 'next', value: string, context: Context } + | { phase: 'completed', context: Context }; + + function testPhasedEvent(name = 'test phased') { + return makePhasedEvent({ + name, + phases: ['started', 'next', 'completed'], + fire(emit: (value: Value) => void, input: string, context: Context) { + emit({ phase: 'started', input, context }); + return () => { + emit({ phase: 'next', value: input.toUpperCase(), context }); + emit({ phase: 'completed', context }); + }; + } + }); + } + + test("adds phase discriminator and exposes phase sources", () => { + let sut = testPhasedEvent(); + let all = new MockObserver(); + let started = new MockObserver(); + let next = new MockObserver(); + sut.subscribe(all); + sut.started.subscribe(started); + sut.next.subscribe(next); + + let flush: () => void = sut('value', { id: '1', group: 'a' }); + flush(); + + expect(started.sourceUpdated).toHaveBeenCalledWith(sut.started, expect.objectContaining({ phase: 'started', input: 'value' })); + expect(next.sourceUpdated).toHaveBeenCalledWith(sut.next, expect.objectContaining({ phase: 'next', value: 'VALUE' })); + expect(all.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ phase: 'completed', context: { id: '1', group: 'a' } })); + }); + + test("filters all phases by context", () => { + let sut = testPhasedEvent(); + let filtered = sut.filterContext(context => context.group == 'a'); + let next = new MockObserver(); + filtered.next.subscribe(next); + + sut('skip', { id: '1', group: 'b' })(); + sut('keep', { id: '2', group: 'a' })(); + + expect(next.sourceUpdated).toHaveBeenCalledTimes(1); + expect(next.sourceUpdated).toHaveBeenCalledWith(filtered.next, expect.objectContaining({ phase: 'next', value: 'KEEP', context: { id: '2', group: 'a' } })); + }); + + test("scopes context by merging into last argument", () => { + let sut = testPhasedEvent(); + let scoped = sut.scope({ id: 'scoped' }); + let expectedScopedSource: IPhasedSource = scoped; + let root = new MockObserver(); + let scopedNext = new MockObserver(); + sut.subscribe(root); + expectedScopedSource.next.subscribe(scopedNext); + + scoped('value', { group: 'a' }); + + expect(root.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ phase: 'started', context: { id: 'scoped', group: 'a' } })); + expect(scopedNext.sourceUpdated).not.toHaveBeenCalled(); + + sut('other', { id: 'scoped', group: 'a' })(); + + expect(scopedNext.sourceUpdated).toHaveBeenCalledWith(scoped.next, expect.objectContaining({ phase: 'next', context: { id: 'scoped', group: 'a' } })); + }); + + test("sets display names for phases", () => { + let sut = testPhasedEvent('custom'); + + expect(sut.started.displayName).toBe('custom.started'); + expect(sut.next.displayName).toBe('custom.next'); + expect(sut.completed.displayName).toBe('custom.completed'); + + sut.setDisplayName('renamed'); + + expect(sut.started.displayName).toBe('renamed.started'); + expect(sut.next.displayName).toBe('renamed.next'); + expect(sut.completed.displayName).toBe('renamed.completed'); + }); + + test("supports context type guards", () => { + type MixedContext = + | { kind: 'matching'; id: string } + | { kind: 'other'; id: string }; + type MixedValue = { phase: 'next', value: string, context: MixedContext }; + + let sut = makePhasedEvent({ + name: 'mixed', + phases: ['next'], + fire: (emit: (value: MixedValue) => void, value: string, context: MixedContext) => emit({ phase: 'next', value, context }) + }); + let matching = sut.filterContext((context): context is Extract => context.kind == 'matching'); + let expected: IPhasedSource<{ phase: 'next', value: string, context: { kind: 'matching', id: string } }, { kind: 'matching', id: string }> = matching; + + expected.next.subscribe(new MockObserver()); + }); +}); From 6900dce5ba62763a5c94f80ac8402ed342b399e6 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Mon, 4 May 2026 19:18:47 +1000 Subject: [PATCH 61/83] Splitting tracking into tracking and ownership --- packages/event-reduce-react/src/hooks.ts | 4 +- packages/event-reduce/src/atom.ts | 11 +- packages/event-reduce/src/derivation.ts | 5 +- packages/event-reduce/src/models.ts | 5 +- packages/event-reduce/src/ownership.ts | 136 ++++++++++++++++++++++ packages/event-reduce/src/reduction.ts | 5 +- packages/event-reduce/src/sources.ts | 9 +- packages/event-reduce/src/tracking.ts | 141 +---------------------- 8 files changed, 162 insertions(+), 154 deletions(-) create mode 100644 packages/event-reduce/src/ownership.ts diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index 775ec7b..9a8a395 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -1,7 +1,7 @@ import { asyncEvent, Atom, derive, event, IDerivation, IReduction, reduce } from "event-reduce" -import { isModel, Model } from "event-reduce/lib/models" +import { isModel } from "event-reduce/lib/models" +import { trackManagedSourceCreation } from "event-reduce/lib/ownership" import { Reduction } from "event-reduce/lib/reduction" -import { trackManagedSourceCreation } from "event-reduce/lib/tracking" import { dispose, emptyArray } from "event-reduce/lib/utils" import { DependencyList, useEffect, useMemo, useRef, useState } from "react" diff --git a/packages/event-reduce/src/atom.ts b/packages/event-reduce/src/atom.ts index c299b55..7a7f156 100644 --- a/packages/event-reduce/src/atom.ts +++ b/packages/event-reduce/src/atom.ts @@ -1,7 +1,8 @@ -import { Derivation } from "./derivation"; -import { ISource, Source } from "./sources"; -import { getState, State } from "./state"; -import { beginTransition, updateChildren } from "./tracking"; +import { Derivation } from "./derivation"; +import { updateChildren } from "./ownership"; +import { ISource, Source } from "./sources"; +import { getState, State } from "./state"; +import { beginTransition } from "./tracking"; import { dispose } from "./utils"; export class Atom extends Source implements ISource { @@ -33,4 +34,4 @@ export class Atom extends Source implements ISource { } override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue); } -} \ No newline at end of file +} diff --git a/packages/event-reduce/src/derivation.ts b/packages/event-reduce/src/derivation.ts index 68eaa76..6af01e8 100644 --- a/packages/event-reduce/src/derivation.ts +++ b/packages/event-reduce/src/derivation.ts @@ -1,8 +1,9 @@ import { Effect } from "./effect" import { startLogging } from "./logging" +import { trackManagedSourceCreation } from "./ownership" import { IObserver, ISource, Source } from "./sources" import { getState, State } from "./state" -import { beginTransition, iterationSource, trackManagedSourceCreation, trackSourceAccessed } from "./tracking" +import { beginTransition, iterationSource, trackSourceAccessed } from "./tracking" import { dispose, emptyArray, nameOfFunction, nullDisposable } from "./utils" export interface IDerivation extends ISource, IObserver { @@ -121,4 +122,4 @@ export class Derivation extends Source implements IDerivation { this._json?.[dispose]() super[dispose]() } -} \ No newline at end of file +} diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index ea93e4b..f39fbab 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -1,9 +1,10 @@ import { Derivation, derive, IDerivation } from "./derivation" import { isEvent } from "./events" +import { getChildNodes, trackManagedSourceCreation, updateChildren } from "./ownership" import { IReduction, Reduction } from "./reduction" import { ISource, isSource, Source } from "./sources" import { getState } from "./state" -import { collectAccessedSources, getChildNodes, getUnderlyingSource, trackManagedSourceCreation, updateChildren } from "./tracking" +import { collectAccessedSources, getUnderlyingSource } from "./tracking" import { SourceValue } from "./types" import { argsAre, constant, dispose, emptyArray, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "./utils" @@ -521,4 +522,4 @@ export class EventsMarkedAsStateError extends Error { ) { super(`Model property ${String(property)} returns an event or events class, and cannot be marked as @state.`) } -} \ No newline at end of file +} diff --git a/packages/event-reduce/src/ownership.ts b/packages/event-reduce/src/ownership.ts new file mode 100644 index 0000000..a046c28 --- /dev/null +++ b/packages/event-reduce/src/ownership.ts @@ -0,0 +1,136 @@ +import { log } from "./logging" +import { type EventsClass, isEventsClass, isModel, type Model, StateProperty } from "./models" +import { type ISource, isSource, Source } from "./sources" +import { dispose, emptyArray, emptyMap, isDisposable, isObject, nameOfFunction } from "./utils" + +type GraphNode = ISource | Model | EventsClass + +/** Child -> parent -> path. */ +let parentRegistry = new WeakMap>() +/** Value -> node -> path. */ +let childSnapshots = new WeakMap>() +let garbage = new Set() + +let currentCreationScope: Set> | null = null + +export function updateChildren(parent: GraphNode, previousValue: unknown, nextValue: unknown, parentPath = '') { + if (Object.is(previousValue, nextValue)) + return + + let previousChildren: ReadonlyMap = emptyMap + if (isObject(previousValue) || previousValue instanceof Source) { + previousChildren = childSnapshots.get(previousValue) ?? getChildNodes(previousValue) + childSnapshots.delete(previousValue) + } + + let newChildren: ReadonlyMap = emptyMap + if (isObject(nextValue) || nextValue instanceof Source) { + newChildren = getChildNodes(nextValue) + childSnapshots.set(nextValue, newChildren) + } + + for (let [removed, _] of previousChildren) + if (!newChildren.has(removed)) + removeParent(removed, parent) + + for (let [added, path] of newChildren) + if (!previousChildren.has(added)) + addParent(added, parent, parentPath + path) +} + +export function getChildNodes(value: object): ReadonlyMap { + if (isGraphNode(value)) + return new Map([[value, '']]) + + if (value instanceof StateProperty) + return isObject(value.value) + ? new Map(getChildNodes(value.value) + .entries() + .map(([node, path]) => [node, `.${String(value.key)}${path}`])) + : emptyMap + + if (Array.isArray(value)) + return new Map(value + .filter(isGraphNode) + .map((obj, i) => [obj, `[${i}]`])) + + return new Map(Object.entries(value) + .filter(([_, val]) => isGraphNode(val)) + .map(([key, val]) => [val, `.${key}`])) +} + +function isGraphNode(value: unknown): value is GraphNode { + return isSource(value) + || isModel(value) + || isEventsClass(value) +} + +function addParent(object: GraphNode, owner: GraphNode, path: string) { + let parents = parentRegistry.get(object) + + if (!parents) + parentRegistry.set(object, parents = new Map()) + + parents.set(owner, path) +} + +function removeParent(node: GraphNode, parent: GraphNode) { + let parents = parentRegistry.get(node) + if (parents) { + parents.delete(parent) + + if (!parents.size && isDisposable(node)) { + parentRegistry.delete(node) + garbage.add(node) + } + } +} + +export function disposeGarbage() { + for (let node of garbage) { + if (!parentRegistry.has(node)) { + log('🗑️ (dispose)', getPath(node), emptyArray, undefined) + node[dispose]() + } + } + garbage.clear() +} + +export interface IManagedSourceTracker extends Disposable { + verifySourceManagement(): void +} + +export function trackManagedSourceCreation(): IManagedSourceTracker { + let previousScope = currentCreationScope + let currentScope = currentCreationScope = new Set() + return { + verifySourceManagement() { + for (let object of currentScope) + if (!parentRegistry.has(object)) + throw new UntrackedSourceError(object) + }, + [dispose]() { currentCreationScope = previousScope } + } +} + +export function managedSourceCreated(source: GraphNode & ISource) { + if (!currentCreationScope) + throw new UntrackedSourceError(source) + currentCreationScope.add(source) +} + +export function getPath(node: GraphNode): string { + let primaryOwner = parentRegistry.get(node)?.entries().next().value + return primaryOwner + ? getPath(primaryOwner[0]) + primaryOwner[1] + : isSource(node) ? node.displayName + : isObject(node) ? nameOfFunction(node.constructor) || '$' + : '$' +} + +export class UntrackedSourceError extends Error { + constructor(source: ISource) { + super(`Leak detected: ${nameOfFunction(source.constructor)} '${source.displayName}' created without an owner. +Make sure reductions are defined on a model, and that the model is either a class decorated with @model, or created using createModel().`) + } +} diff --git a/packages/event-reduce/src/reduction.ts b/packages/event-reduce/src/reduction.ts index ec203fb..d878f68 100644 --- a/packages/event-reduce/src/reduction.ts +++ b/packages/event-reduce/src/reduction.ts @@ -1,8 +1,9 @@ import { Derivation } from "./derivation" import { startLogging } from "./logging" +import { managedSourceCreated, updateChildren } from "./ownership" import { allSources, IObserver, ISource, isSource, NotASourceValueError, Source } from "./sources" import { getState, State } from "./state" -import { collectAccessedSources, currentIteration, getUnderlyingSource, managedSourceCreated, trackSourceAccessed, updateChildren } from "./tracking" +import { collectAccessedSources, currentIteration, getUnderlyingSource, trackSourceAccessed } from "./tracking" import { dispose, emptyArray } from "./utils" export interface IReduction extends ISource { @@ -148,4 +149,4 @@ export class CircularSubscriptionError extends Error { ) { super(`Cannot subscribe to '${source.displayName}', as it depends on this reduction, '${reduction.displayName}'.`); } -} \ No newline at end of file +} diff --git a/packages/event-reduce/src/sources.ts b/packages/event-reduce/src/sources.ts index eae6783..e496568 100644 --- a/packages/event-reduce/src/sources.ts +++ b/packages/event-reduce/src/sources.ts @@ -1,6 +1,7 @@ -import type { IEventFunction } from "./events" -import { isEventsClass } from "./models" -import { currentIteration, getPath, iterationSource, trackSourceAccessed, updateChildren } from "./tracking" +import type { IEventFunction } from "./events" +import { isEventsClass } from "./models" +import { getPath, updateChildren } from "./ownership" +import { currentIteration, iterationSource, trackSourceAccessed } from "./tracking" import { MergedSources } from "./types" import { dispose, emptyArray, identity, nameOfCallback, nameOfFunction } from "./utils" @@ -176,4 +177,4 @@ export class StateChangeOutsideTransitionError extends Error { super(`Tried to change state of ${source.displayName} outside of a transition. ` + 'This usually means a bug in event-reduce, or in a custom source implementation.'); } -} \ No newline at end of file +} diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts index c5571a4..73327c8 100644 --- a/packages/event-reduce/src/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -1,25 +1,14 @@ import { Effect } from "./effect" -import { log } from "./logging" -import { type EventsClass, isEventsClass, isModel, type Model, StateProperty } from "./models" -import { type IObserver, type ISource, isSource, Source } from "./sources" -import { dispose, emptyArray, emptyMap, isDisposable, isObject, nameOfFunction, nullDisposable } from "./utils" +import { disposeGarbage } from "./ownership" +import { type IObserver, type ISource, Source } from "./sources" +import { dispose, nullDisposable } from "./utils" -type GraphNode = ISource | Model | EventsClass - -export let currentIteration = 0 +export let currentIteration = 0 export let iterationSource: ISource | Effect | null = null let currentAccessTracker: AccessTracker | null = null let lastSourceAccessed: ISource | null = null -/** Child -> parent -> path. */ -let parentRegistry = new WeakMap>() -/** Value -> node -> path. */ -let childSnapshots = new WeakMap>() -let garbage = new Set() - -let currentCreationScope: Set> | null = null - export function beginTransition(source: ISource | Effect, isTriggerEvent: boolean): Disposable { if (isTriggerEvent) { if (iterationSource) @@ -56,112 +45,6 @@ export function getUnderlyingSource(value: T): ISource | null { : null } -export function updateChildren(parent: GraphNode, previousValue: unknown, nextValue: unknown, parentPath = '') { - if (Object.is(previousValue, nextValue)) - return - - let previousChildren: ReadonlyMap = emptyMap - if (isObject(previousValue) || previousValue instanceof Source) { - previousChildren = childSnapshots.get(previousValue) ?? getChildNodes(previousValue) - childSnapshots.delete(previousValue) - } - - let newChildren: ReadonlyMap = emptyMap - if (isObject(nextValue) || nextValue instanceof Source) { - newChildren = getChildNodes(nextValue) - childSnapshots.set(nextValue, newChildren) - } - - for (let [removed, _] of previousChildren) - if (!newChildren.has(removed)) - removeParent(removed, parent) - - for (let [added, path] of newChildren) - if (!previousChildren.has(added)) - addParent(added, parent, parentPath + path) -} - -export function getChildNodes(value: object): ReadonlyMap { - if (isGraphNode(value)) - return new Map([[value, '']]) - - if (value instanceof StateProperty) - return isObject(value.value) - ? new Map(getChildNodes(value.value) - .entries() - .map(([node, path]) => [node, `.${String(value.key)}${path}`])) - : emptyMap - - if (Array.isArray(value)) - return new Map(value - .filter(isGraphNode) - .map((obj, i) => [obj, `[${i}]`])) - - return new Map(Object.entries(value) - .filter(([_, val]) => isGraphNode(val)) - .map(([key, val]) => [val, `.${key}`])) -} - -function isGraphNode(value: unknown): value is GraphNode { - return isSource(value) - || isModel(value) - || isEventsClass(value) -} - -function addParent(object: GraphNode, owner: GraphNode, path: string) { - let parents = parentRegistry.get(object) - - if (!parents) - parentRegistry.set(object, parents = new Map()) - - parents.set(owner, path) -} - -function removeParent(node: GraphNode, parent: GraphNode) { - let parents = parentRegistry.get(node) - if (parents) { - parents.delete(parent) - - if (!parents.size && isDisposable(node)) { - parentRegistry.delete(node) - garbage.add(node) - } - } -} - -function disposeGarbage() { - for (let node of garbage) { - if (!parentRegistry.has(node)) { // In case a new parent was added - log('🗑️ (dispose)', getPath(node), emptyArray, undefined) - node[dispose]() - } - } - garbage.clear() -} - -export interface IManagedSourceTracker extends Disposable { - verifySourceManagement(): void -} - -export function trackManagedSourceCreation(): IManagedSourceTracker { - let previousScope = currentCreationScope - let currentScope = currentCreationScope = new Set() - return { - verifySourceManagement() { - for (let object of currentScope) - if (!parentRegistry.has(object)) - throw new UntrackedSourceError(object) - }, - [dispose]() { currentCreationScope = previousScope } - } -} - -export function managedSourceCreated(source: GraphNode & ISource) { - if (!currentCreationScope) - throw new UntrackedSourceError(source) - currentCreationScope.add(source) -} - export interface IAccessTracker extends Disposable { readonly accessed: ReadonlySet> } @@ -199,15 +82,6 @@ class AccessTracker implements IAccessTracker { } } -export function getPath(node: GraphNode): string { - let primaryOwner = parentRegistry.get(node)?.entries().next().value - return primaryOwner - ? getPath(primaryOwner[0]) + primaryOwner[1] - : isSource(node) ? node.displayName - : isObject(node) ? nameOfFunction(node.constructor) || '$' - : '$' -} - export class CircularDependencyError extends Error { constructor(path: ISource[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}.`) } } @@ -218,10 +92,3 @@ export class SideEffectDuringReactionError extends Error { public sideEffect: ISource | Effect ) { super(`${sideEffect.displayName} side effect during reaction to ${reactingTo.displayName}.`) } } - -export class UntrackedSourceError extends Error { - constructor(source: ISource) { - super(`Leak detected: ${nameOfFunction(source.constructor)} '${source.displayName}' created without an owner. -Make sure reductions are defined on a model, and that the model is either a class decorated with @model, or created using createModel().`) - } -} \ No newline at end of file From 689a57e28eaf3bf7cd6df2a9ab1c46f1f108997d Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 6 May 2026 19:44:32 +1000 Subject: [PATCH 62/83] Renaming Source to Observable and Atom to ObservableValue --- CONTEXT.md | 96 +++++++++++++++ packages/event-reduce-react/src/hooks.ts | 16 +-- packages/event-reduce-react/src/rendering.ts | 12 +- packages/event-reduce/src/derivation.ts | 20 ++-- packages/event-reduce/src/effect.ts | 30 ++--- packages/event-reduce/src/events.ts | 88 +++++++------- packages/event-reduce/src/index.ts | 7 +- packages/event-reduce/src/models.ts | 70 +++++------ .../src/{sources.ts => observable.ts} | 89 +++++++------- .../src/{atom.ts => observableValue.ts} | 4 +- packages/event-reduce/src/ownership.ts | 70 +++++------ packages/event-reduce/src/reduction.ts | 112 +++++++++--------- packages/event-reduce/src/testing.ts | 18 +-- packages/event-reduce/src/tracking.ts | 32 ++--- packages/event-reduce/src/types.ts | 10 +- tests-legacy/LegacyModel.test.ts | 2 +- tests/Cleanup.test.ts | 42 +++---- tests/Derivation.test.ts | 34 +++--- tests/Event.test.ts | 16 +-- tests/Hooks.test.ts | 8 +- tests/MockObserver.ts | 6 +- tests/Model.test.ts | 5 +- tests/{Source.test.ts => Observable.test.ts} | 12 +- .../{Atom.test.ts => ObservableValue.test.ts} | 14 +-- tests/Reduction.test.ts | 54 ++++----- tests/Rendering.test.ts | 10 +- 26 files changed, 486 insertions(+), 391 deletions(-) create mode 100644 CONTEXT.md rename packages/event-reduce/src/{sources.ts => observable.ts} (56%) rename packages/event-reduce/src/{atom.ts => observableValue.ts} (86%) rename tests/{Source.test.ts => Observable.test.ts} (82%) rename tests/{Atom.test.ts => ObservableValue.test.ts} (71%) diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..4959bcb --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,96 @@ +# Event Reduce + +Event Reduce models state as values computed from observable event streams and observable state. + +## Language + +**Observable**: +A value-producing node that can be observed and composed. +_Avoid_: Source when naming the node itself. + +**Source**: +The relationship role of an **Observable** used to compute another **Observable**. +_Avoid_: Observable source as a type name. + +**ObservableValue**: +An **Observable** that stores a mutable value. +_Avoid_: Atom. + +**State**: +A non-observable value that is included in a model's state. +_Avoid_: ObservableValue. + +## Relationships + +- An **Observable** may have zero or more **Sources**. +- A **Source** is always an **Observable**. +- An **ObservableValue** is an **Observable**. +- **State** is not an **ObservableValue**. +- APIs that describe dependency relationships should use "source" language. +- Observer callbacks should use "source" language when the changed **Observable** is one of the observer's dependencies. +- Effects should use "source" language for observables they read and for the observable that invalidates them. +- Modules that define observable node types should use "observable" language, not "source" language. +- `Source` and `ISource` should not remain as aliases after the rename; they preserve branch-local drift, not compatibility. +- Specialized observable classes should use "Observable" in the class name. +- Type aliases and interfaces should use "Observable" when they name an observable node concept. +- Async event interfaces should use "Observable" when naming the event stream itself or its phase streams. +- Event functions keep event-function naming even though they are observable. +- Async events keep async-event naming; do not rename `IAsyncEvent`. +- Variables should use "source" only when the value is acting as a dependency of another **Observable**. +- Transition internals such as `iterationSource` may keep existing names when they are not part of the public naming cleanup. +- Error class names should use "Observable" when they name the observable node concept. +- Error properties should use `source` only when the property identifies the source or trigger of the error. +- Helpers that return the observable node behind a value should use "Observable" language. +- Code comments and tests should use "observable" for user-facing streams and "source" only for dependency roles; the README is out of scope for this branch cleanup. +- Public helper parameters should use "observable" language when callers pass observable nodes directly. +- Dependency graph access uses `getSources()` returning an iterable; restoring master's `sources` property is not required. +- Observable implementations should expose dependency graph access through `getSources()`, not public mutable `sources` sets. +- Observer/effect implementations should not expose public mutable source sets. +- Subscription uses branch-style observer objects; restoring master's callback subscription is not required. +- `IObserver` remains the counterpart to `IObservable`; source-specific behavior belongs in method names. +- `IObserver` should remain minimal and should not require source introspection. +- ObservableValue uses branch-style mutation mechanics; restoring master's old helpers is not required. + +## Example dialogue + +> **Dev:** "Should this type be called Source?" +> **Domain expert:** "No. It is an Observable. It is only a source when another Observable depends on it." + +## Flagged ambiguities + +- "source" was used on this branch both as the observable node type and as a dependency role; resolved: restore the master-compatible public type name **Observable**, and reserve **Source** for the dependency role. +- `getSources` and `sourceUpdated` are relationship names, not type names. +- `Effect.sources` and `Effect.invalidatingSource` are dependency-role names. +- `Effect` should keep source terminology internally but not expose a public mutable `sources` set. +- `Reduction` should keep source terminology internally but expose dependencies through `getSources()`. +- The module defining observable node concepts should keep the master-compatible `observable` name. +- The bound transform implementation is **BoundObservable**, not BoundSource. +- Names like `ObservableValue`, `MergedObservables`, and `IAsyncObservable` name concepts; names like `sources` and `getSources` name dependency relationships. +- `ObservedValue` names the value type produced by an **Observable**; **ObservableValue** names the mutable value class. +- Generic type parameters may use concept names directly, e.g. `ObservedValue`. +- `allSources(observable)` returns the root **Observable** plus all transitive **Sources**. +- `IAsyncObservable` names an async event observable with phase observables. +- `IEventFunction` extends `IObservable`; it does not need "Observable" in its own name. +- `IAsyncEvent` remains the async event concept. +- Use `observable` for a node being inspected generally; use `source` for a node reached through `getSources` or passed to `sourceUpdated`. +- Rename error classes away from Source when they name concepts, but keep `source` properties when they identify causal sources. +- `NoUnderlyingObservableError` pairs with `getUnderlyingObservable`; avoid `NotAnObservableValueError` because **ObservableValue** is a specific class. +- `InvalidStateError` keeps state language; its invalid state holder property is `observable`, not `source`. +- `StateChangeOutsideTransitionError.source` is valid because the observable is both the state container and the source of the error; only its implementation wording should say "custom observable implementation". +- `getUnderlyingObservable` returns an **Observable** node, not a source relationship. +- Test descriptions can say "observable"; local variables can say `source` when the observable is used as a dependency. +- Event factory docs should say "observable for each phase", not "source for each phase". +- `merge` takes an array parameter named `observables`; it may keep a display-name overload, but should not expose a `sources` parameter. +- The merge return type is **MergedObservables**, not MergedSources. +- Compatibility goal is to restore observable naming, not every master API shape. +- Public export order is not a compatibility requirement. +- Generated or built `lib` output is out of scope for this naming cleanup. +- Public export surface should not change beyond the agreed renames. +- `Observable` naming does not imply master's old `subscribe(callback)` API. +- `IObserver.sourceUpdated` carries the dependency-graph semantics without renaming the observer type. +- Classes that track sources may expose their own introspection, but that is not part of `IObserver`. +- The mutable value primitive should use master's **ObservableValue** name, not Atom. +- The mutable value primitive module should use master's `observableValue` name. +- Tests should use public concept names; ObservableValue tests should not keep Atom naming. +- `ObservableValue.setValue` is acceptable even though the class name is restored for compatibility. +- Model **State** names non-observable values included in model state; do not rename state concepts to ObservableValue. diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index 9a8a395..c00321c 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -1,6 +1,6 @@ -import { asyncEvent, Atom, derive, event, IDerivation, IReduction, reduce } from "event-reduce" +import { asyncEvent, derive, event, IDerivation, IReduction, ObservableValue, reduce } from "event-reduce" import { isModel } from "event-reduce/lib/models" -import { trackManagedSourceCreation } from "event-reduce/lib/ownership" +import { trackManagedObservableCreation } from "event-reduce/lib/ownership" import { Reduction } from "event-reduce/lib/reduction" import { dispose, emptyArray } from "event-reduce/lib/utils" import { DependencyList, useEffect, useMemo, useRef, useState } from "react" @@ -45,7 +45,7 @@ export function useDerived(getValue: () => T, nameOrUnobservableDependencies? } export function useReduced(initial: T, name?: string): IReduction { - let sourceCreation = trackManagedSourceCreation() // It's fine for this reduction to be loose, because it will be disposed on unmount + let sourceCreation = trackManagedObservableCreation() // It's fine for this reduction to be loose, because it will be disposed on unmount let reduction = useState(() => reduce(initial, name))[0] sourceCreation[dispose]() useEffect(() => () => (reduction as Reduction)[dispose]?.(), []) @@ -53,13 +53,13 @@ export function useReduced(initial: T, name?: string): IReduction { } export function useObservedProps(values: T, name: string = '(anonymous observed values)') { - let observableValues = useState(() => ({} as { [K in keyof T]?: Atom }))[0] + let observableValues = useState(() => ({} as { [K in keyof T]?: ObservableValue }))[0] let nameBase = (name || '') + '.' // Update any values that are already being observed useEffect(() => { - for (let [key, atom] of Object.entries(observableValues) as [keyof T, Atom][]) - atom.setValue(values[key]) + for (let [key, observableValue] of Object.entries(observableValues) as [keyof T, ObservableValue][]) + observableValue.setValue(values[key]) }) let latestValues = useRef(values) @@ -69,7 +69,7 @@ export function useObservedProps(values: T, name: string = '(a return new Proxy({} as T, { get(_, key) { return (observableValues[key as keyof T] - ??= new Atom(nameBase + String(key), + ??= new ObservableValue(nameBase + String(key), (latestValues.current)[key as keyof T])) .value } @@ -77,7 +77,7 @@ export function useObservedProps(values: T, name: string = '(a } export function useAtom(value: T, name: string = '(anonymous observed value)') { - let observableValue = useState(() => new Atom(name, value))[0] + let observableValue = useState(() => new ObservableValue(name, value))[0] observableValue.setValue(value) return observableValue } diff --git a/packages/event-reduce-react/src/rendering.ts b/packages/event-reduce-react/src/rendering.ts index 00c99f9..74c8b05 100644 --- a/packages/event-reduce-react/src/rendering.ts +++ b/packages/event-reduce-react/src/rendering.ts @@ -1,6 +1,6 @@ import { Effect, log, startLogging } from "event-reduce" import { LogValue } from "event-reduce/lib/logging" -import { ISource } from "event-reduce/lib/sources" +import { IObservable } from "event-reduce/lib/observable" import { beginTransition } from "event-reduce/lib/tracking" import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/lib/utils" import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useState } from "react" @@ -33,15 +33,15 @@ export function useReactive(nameOrDeriveValue: string | (() => T), maybeDeriv class RenderEffect extends Effect { private _observers = new Set<() => void>() - private _trigger: ISource | null = null + private _trigger: IObservable | null = null private _triggerState = { triggeredBy: "(nothing)" } private _previousValue?: T constructor(displayName: string) { - super(displayName, constant(undefined!), (source) => { - log('⚛️🚩 (render invalidated)', this.displayName, [source]) - this._trigger = source - this._triggerState = { triggeredBy: source.path } + super(displayName, constant(undefined!), (observable) => { + log('⚛️🚩 (render invalidated)', this.displayName, [observable]) + this._trigger = observable + this._triggerState = { triggeredBy: observable.path } for (let subscriber of this._observers) subscriber() }) diff --git a/packages/event-reduce/src/derivation.ts b/packages/event-reduce/src/derivation.ts index 6af01e8..415e27a 100644 --- a/packages/event-reduce/src/derivation.ts +++ b/packages/event-reduce/src/derivation.ts @@ -1,18 +1,18 @@ import { Effect } from "./effect" import { startLogging } from "./logging" -import { trackManagedSourceCreation } from "./ownership" -import { IObserver, ISource, Source } from "./sources" +import { IObservable, IObserver, Observable } from "./observable" +import { trackManagedObservableCreation } from "./ownership" import { getState, State } from "./state" import { beginTransition, iterationSource, trackSourceAccessed } from "./tracking" import { dispose, emptyArray, nameOfFunction, nullDisposable } from "./utils" -export interface IDerivation extends ISource, IObserver { +export interface IDerivation extends IObservable, IObserver { readonly value: T /** * Forces the value to be re-calculated. * @param deriveValue Optional function to use to derive the value. If not provided, the original derivation function will be used. - * @param reason Optional reason for the update. If not invalidated by a source change, this will be used as the reason for the update. + * @param reason Optional reason for the update. If not invalidated by an observable change, this will be used as the reason for the update. **/ update(deriveValue?: () => T, reason?: string): void } @@ -26,7 +26,7 @@ export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, return new Derivation((displayName ?? nameOfFunction(derive)) || Derivation.defaultName, derive, skipUpdate) } -export class Derivation extends Source implements IDerivation { +export class Derivation extends Observable implements IDerivation { private _derive: Effect private _json?: Derivation> static defaultName = "(anonymous derivation)" @@ -53,8 +53,6 @@ export class Derivation extends Source implements IDerivation { return this._json.value } - get sources() { return this._derive.sources } - override subscribe(observer: IObserver): void { if (!this._observers.size) this._derive.attach() @@ -76,7 +74,7 @@ export class Derivation extends Source implements IDerivation { override getSources() { return this._derive.getSources() } - sourceUpdated(source: ISource, value: S) { this._derive.sourceUpdated(source, value) } + sourceUpdated(source: IObservable, value: S) { this._derive.sourceUpdated(source, value) } override get value() { // Notifying tracker before checking for updates so circular reference is detected early @@ -100,7 +98,7 @@ export class Derivation extends Source implements IDerivation { let trigger = this._derive.invalidatingSource let transition = beginTransition(this, !iterationSource) // Derivations are always safe to access, and only initiate a transition if necessary - let sourceCreation = trackManagedSourceCreation() + let observableCreation = trackManagedObservableCreation() let logging = this.suppressLogging ? nullDisposable : startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ Path: this.path, Trigger: trigger?.path ?? '(unknown)', @@ -109,10 +107,10 @@ export class Derivation extends Source implements IDerivation { })) try { this.setValue(nextValue = this._derive.run(deriveValue)) - sourceCreation.verifySourceManagement() + observableCreation.verifyObservableManagement() } finally { logging[dispose]() - sourceCreation[dispose]() + observableCreation[dispose]() transition[dispose]() } } diff --git a/packages/event-reduce/src/effect.ts b/packages/event-reduce/src/effect.ts index bcd3394..cfda3f2 100644 --- a/packages/event-reduce/src/effect.ts +++ b/packages/event-reduce/src/effect.ts @@ -1,25 +1,25 @@ -import type { IObserver, ISource } from "./sources" +import type { IObservable, IObserver } from "./observable" import { collectAccessedSources, currentIteration } from "./tracking" import { dispose } from "./utils" export class Effect implements IObserver { private _attached = false - readonly sources = new Set>() - invalidatingSource?: ISource + private readonly _sources = new Set>() + invalidatingSource?: IObservable lastRun = -2 // Stale from the start constructor( public readonly displayName: string, private _effect: () => T, - private _onSourceUpdatedSinceLastRun: (source: ISource) => void + private _onSourceUpdatedSinceLastRun: (source: IObservable) => void ) { } - getSources() { return this.sources } + getSources() { return this._sources } attach() { if (!this._attached) { this._attached = true - for (let source of this.sources) + for (let source of this._sources) source.subscribe(this) } } @@ -27,12 +27,12 @@ export class Effect implements IObserver { detach() { if (this._attached) { this._attached = false - for (let source of this.sources) + for (let source of this._sources) source.unsubscribe(this) } } - sourceUpdated(source: ISource, value: S): void { + sourceUpdated(source: IObservable, value: S): void { if (source.lastUpdated > this.lastRun) { this.invalidatingSource = source this._onSourceUpdatedSinceLastRun(source) @@ -41,7 +41,7 @@ export class Effect implements IObserver { get isStale() { return this.sourceLastUpdated > this.lastRun } - get sourceLastUpdated() { return Math.max(-1, ...[...this.sources].map(s => s.lastUpdated)) } + get sourceLastUpdated() { return Math.max(-1, ...[...this._sources].map(s => s.lastUpdated)) } /** * Forces the effect to run. @@ -60,13 +60,13 @@ export class Effect implements IObserver { tracker[dispose]() } - for (let oldSource of this.sources.difference(tracker.accessed)) { - this.sources.delete(oldSource) + for (let oldSource of this._sources.difference(tracker.accessed)) { + this._sources.delete(oldSource) oldSource.unsubscribe(this) } - for (let newSource of tracker.accessed.difference(this.sources)) { - this.sources.add(newSource) + for (let newSource of tracker.accessed.difference(this._sources)) { + this._sources.add(newSource) if (this._attached) newSource.subscribe(this) } @@ -76,6 +76,6 @@ export class Effect implements IObserver { [dispose]() { this.detach() - this.sources.clear() + this._sources.clear() } -} \ No newline at end of file +} diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index 08c57a7..869a938 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -1,7 +1,7 @@ import { startLogging } from "./logging" -import { type ISource, Source } from "./sources" +import { type IObservable, Observable } from "./observable" import { beginTransition } from "./tracking" -import { Scoped, SourceValue } from "./types" +import { ObservedValue, Scoped } from "./types" import { dispose, matchesScope, nameOfCallback } from "./utils" export function event(name: string = "(anonymous event)"): IEvent { @@ -70,12 +70,12 @@ export function asyncEvent(name: string = "(anony } /** - * Creates a callable event with named phase sources. + * Creates a callable event with named phase observables. * * Use this to build custom async-style events, such as observable or stream events. The `fire` * callback receives an `emit` function; emit values must include a `phase` matching one of * `phases` and a `context` matching the last argument passed to the event. The returned event - * exposes a source for each phase, plus `filterContext` and `scope`. + * exposes an observable for each phase, plus `filterContext` and `scope`. */ export function makePhasedEvent< const Phases extends readonly string[], @@ -91,14 +91,14 @@ export function makePhasedEvent< name: string, phases: Phases, fire: (emit: (value: Value) => void, ...args: Args) => Return - } & ThisType> + } & ThisType> ): IPhasedEvent { return makeEvent({ name, fire, additionalMembers: (e: IEventFunction, ScopedArgs, Return>) => ({ - ...phasedSourceMembers, CurrentContext>( - e as unknown as ISource>, + ...phasedObservableMembers, CurrentContext>( + e as unknown as IObservable>, phases as readonly (NarrowContext['phase'] & string)[] ), scope : never>(scope: Scope): IPhasedEvent, ScopedArgs>, Return, CurrentContext & Scope> { @@ -125,11 +125,11 @@ export function makeEvent, emit: (value: T) => void, ...args: Args) => Return, - additionalMembers?: AdditionalMembersFactory, Members> + additionalMembers?: AdditionalMembersFactory, Members> } & ThisType> ): IEventFunction & Members { let eventSource = new Event(name) as Event & IEventFunction - return makeSourceFunction(eventSource, event, additionalMembers) + return makeObservableFunction(eventSource, event, additionalMembers) function event(...args: Args) { return fire.call(event as IEventFunction, eventSource.emit.bind(event), ...args) } } @@ -139,44 +139,44 @@ export function bindEvent, Args extends any[] event: E, displayName: string, fire: (...args: Args) => Return, - predicate: (value: SourceValue) => boolean + predicate: (value: ObservedValue) => boolean ) { - return makeSourceFunction(event.filter(predicate, displayName), fire, (event as EventWithAdditionalMembers)[membersFactory]) + return makeObservableFunction(event.filter(predicate, displayName), fire, (event as EventWithAdditionalMembers)[membersFactory]) } -function phasedSourceMembers(source: ISource, phases: readonly (Value['phase'] & string)[]) { - let setBaseDisplayName = source.setDisplayName +function phasedObservableMembers(observable: IObservable, phases: readonly (Value['phase'] & string)[]) { + let setBaseDisplayName = observable.setDisplayName let phaseMembers = Object.fromEntries(phases.map(phase => [ phase, - source.filter(e => e.phase == phase, `${source.displayName}.${phase}`) + observable.filter(e => e.phase == phase, `${observable.displayName}.${phase}`) ])) return { ...phaseMembers, - filterContext(predicate: (context: Context) => boolean, displayName = `${source.displayName}.filterContext(${nameOfCallback(predicate)})`) { - let filtered = source.filter(e => predicate(e.context), displayName) - return Object.assign(filtered, phasedSourceMembers(filtered, phases)) + filterContext(predicate: (context: Context) => boolean, displayName = `${observable.displayName}.filterContext(${nameOfCallback(predicate)})`) { + let filtered = observable.filter(e => predicate(e.context), displayName) + return Object.assign(filtered, phasedObservableMembers(filtered, phases)) }, - setDisplayName(this: IPhasedSource, name: string) { - setBaseDisplayName.call(source, name) + setDisplayName(this: IPhasedObservable, name: string) { + setBaseDisplayName.call(observable, name) for (let phase of phases) this[phase].setDisplayName(`${name}.${phase}`) } } } -function makeSourceFunction, Args extends any[], Return, Members extends object>( - innerEvent: Source, +function makeObservableFunction, Args extends any[], Return, Members extends object>( + innerEvent: Observable, fire: (...args: Args) => Return, - additionalMembers?: AdditionalMembersFactory -): IEventFunction, Args, Return> & Members { - let eventFn = fire as Source & IEventFunction, Args, Return> + additionalMembers?: AdditionalMembersFactory +): IEventFunction, Args, Return> & Members { + let eventFn = fire as Observable & IEventFunction, Args, Return> Object.setPrototypeOf(eventFn, innerEvent); eventFn.apply = Function.prototype.apply; eventFn.call = Function.prototype.call; return additionalMembers - ? Object.assign(eventFn, additionalMembers(eventFn), { [membersFactory]: additionalMembers as AdditionalMembersFactory, any> }) - : eventFn as Source & IEventFunction, Args, Return> & Members + ? Object.assign(eventFn, additionalMembers(eventFn), { [membersFactory]: additionalMembers as AdditionalMembersFactory, any> }) + : eventFn as Observable & IEventFunction, Args, Return> & Members } export function isEvent(value: any): value is IEventFunction { @@ -188,20 +188,20 @@ export interface IEvent extends IEventFunction { scope>(scope: Scope): IEvent, Out> } -export interface IEventFunction extends ISource { +export interface IEventFunction extends IObservable { (...args: Args): Return } -export interface IAsyncSource extends ISource> { - started: ISource> - resolved: ISource> - rejected: ISource> +export interface IAsyncObservable extends IObservable> { + started: IObservable> + resolved: IObservable> + rejected: IObservable> - filterContext(predicate: (context: Context) => context is FilteredContext, displayName?: string): IAsyncSource - filterContext(predicate: (context: Context) => boolean, displayName?: string): IAsyncSource + filterContext(predicate: (context: Context) => context is FilteredContext, displayName?: string): IAsyncObservable + filterContext(predicate: (context: Context) => boolean, displayName?: string): IAsyncObservable } -export interface IAsyncEvent extends IEventFunction, [promise: PromiseLike, context: Context]>, IAsyncSource { +export interface IAsyncEvent extends IEventFunction, [promise: PromiseLike, context: Context]>, IAsyncObservable { scope>(scope: Scope): IAsyncEvent> } @@ -228,27 +228,27 @@ export interface AsyncError { error: unknown } -export type IPhasedSource = - ISource - & { [Phase in Value['phase'] & string]: ISource> } +export type IPhasedObservable = + IObservable + & { [Phase in Value['phase'] & string]: IObservable> } & { - filterContext(predicate: (context: Context) => context is FilteredContext, displayName?: string): IPhasedSource, FilteredContext> - filterContext(predicate: (context: Context) => boolean, displayName?: string): IPhasedSource + filterContext(predicate: (context: Context) => context is FilteredContext, displayName?: string): IPhasedObservable, FilteredContext> + filterContext(predicate: (context: Context) => boolean, displayName?: string): IPhasedObservable } export type IPhasedEvent> = IEventFunction - & IPhasedSource + & IPhasedObservable & { scope : never>( scope: Scope ): IPhasedEvent, ScopedArgs>, Return, Context & Scope> } -export type AdditionalMembersFactory, Members extends object> = - (source: Source & IEventFunction, any>) => Members +export type AdditionalMembersFactory, Members extends object> = + (observable: Observable & IEventFunction, any>) => Members -class Event extends Source { +class Event extends Observable { emit(value: T) { let transition = beginTransition(this, true) try { @@ -272,4 +272,4 @@ type ScopedArgs = ? [...Rest, Context] : never -type EventWithAdditionalMembers = { [membersFactory]?: AdditionalMembersFactory, any> } +type EventWithAdditionalMembers = { [membersFactory]?: AdditionalMembersFactory, any> } diff --git a/packages/event-reduce/src/index.ts b/packages/event-reduce/src/index.ts index 69fe062..27a15e2 100644 --- a/packages/event-reduce/src/index.ts +++ b/packages/event-reduce/src/index.ts @@ -1,10 +1,11 @@ -export { Atom } from "./atom" export { derive, type IDerivation } from "./derivation" export { enableDevTools } from "./devtools" export { Effect } from "./effect" -export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IAsyncSource, type IEvent } from "./events" +export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IAsyncObservable, type IEvent } from "./events" export { enableLogging, log, startLogging } from "./logging" export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" +export { merge, type IObservable, type IObserver } from "./observable" +export { ObservableValue } from "./observableValue" export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" -export { merge, type IObserver, type ISource } from "./sources" export { getState, type State } from "./state" + diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index f39fbab..2346c24 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -1,11 +1,11 @@ import { Derivation, derive, IDerivation } from "./derivation" import { isEvent } from "./events" -import { getChildNodes, trackManagedSourceCreation, updateChildren } from "./ownership" +import { IObservable, isObservable, Observable } from "./observable" +import { getChildNodes, trackManagedObservableCreation, updateChildren } from "./ownership" import { IReduction, Reduction } from "./reduction" -import { ISource, isSource, Source } from "./sources" import { getState } from "./state" -import { collectAccessedSources, getUnderlyingSource } from "./tracking" -import { SourceValue } from "./types" +import { collectAccessedSources, getUnderlyingObservable } from "./tracking" +import { ObservedValue } from "./types" import { argsAre, constant, dispose, emptyArray, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "./utils" const modelBrand = Symbol('model class') @@ -15,7 +15,7 @@ export interface Model extends Disposable { [modelProperties]: Record } -export type ModelProperty = ISource | StateProperty +export type ModelProperty = IObservable | StateProperty export function isModel(value: unknown): value is Model { return isObject(value) @@ -27,16 +27,16 @@ let instancePropertyProviders = new WeakMap Mo /** prototype -> key -> provider */ let prototypePropertyProviders = new WeakMap ModelProperty>>() /** - * instance -> getter -> property source - * Required for getters to cache their source without being overridden in sub-classes + * instance -> getter -> property observable + * Required for getters to cache their Observable without being overridden in sub-classes */ -let getterCache = new WeakMap>>() +let getterCache = new WeakMap>>() let registeredInstanceProperties = new WeakMap() /** Creates a loose model object. Not required if your model is already decorated with \@{@link model}. */ export function createModel(create: () => T): T & Model { - let sourceCreation = trackManagedSourceCreation() + let sourceCreation = trackManagedObservableCreation() let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this try { let instance = create() @@ -47,7 +47,7 @@ export function createModel(create: () => T): T & Model { value: Object.fromEntries( getChildNodes(instance) .entries() - .flatMap(([node, path]) => isSource(node) ? [[path, node]] : [])) + .flatMap(([node, path]) => isObservable(node) ? [[path, node]] : [])) }, [dispose]: { value() { @@ -60,7 +60,7 @@ export function createModel(create: () => T): T & Model { } }) updateChildren(model, null, instance) - sourceCreation.verifySourceManagement() + sourceCreation.verifyObservableManagement() return model } finally { accesses[dispose]() @@ -93,7 +93,7 @@ export function model object>(target: Clas return } - let sourceCreation = trackManagedSourceCreation() + let sourceCreation = trackManagedObservableCreation() let accesses = collectAccessedSources(null) // Don't want anything accessed during construction to become a source for whatever is creating this let allProperties: readonly ModelProperty[] = emptyArray try { @@ -111,7 +111,7 @@ export function model object>(target: Clas properties[key] ??= getProperty(model) // Instance and sub-class properties take precedence over super-class properties for (let [key, property] of Object.entries(properties)) { - if (isSource(property)) { + if (isObservable(property)) { Object.defineProperty(model, key, { // Bind directly to the property for cleaner call stack get: getPrototypeChain(property) @@ -128,7 +128,7 @@ export function model object>(target: Clas if (process.env.NODE_ENV !== 'production' && !allProperties.length) console.warn(`Model ${className} has no @reduced, @derived, or @state properties. You can remove the @model decorator.`) - sourceCreation.verifySourceManagement() + sourceCreation.verifyObservableManagement() // These are no longer needed registeredInstanceProperties.delete(model) @@ -209,7 +209,7 @@ export function reduced(...args: } function getUnderlyingReduction(instance: any, key: PropertyKey, getter: Function) { - let reduction = getUnderlyingSource(getter.call(instance)) + let reduction = getUnderlyingObservable(getter.call(instance)) if (!(reduction instanceof Reduction)) throw new InvalidReducedPropertyError(key) @@ -277,61 +277,61 @@ export function derived(...args: } } -function cachedGetter | IReduction>( +function cachedGetter | IReduction>( key: PropertyKey, - getter: () => SourceValue, - addPropertyFactory: (cachedSource: (instance: any) => Source) => void, - createSource: (instance: Model) => Source + getter: () => ObservedValue, + addPropertyFactory: (cachedSource: (instance: any) => Observable) => void, + createSource: (instance: Model) => Observable ) { addPropertyFactory(cachedSource) - /** Ensures source is only created once during model construction. */ + /** Ensures observable is only created once during model construction. */ return function cachedGetter(this: Model) { return cachedSource(this).value } function cachedSource(instance: Model) { let cache = getModelGetterCache(instance) - let source = cache.get(getter) as Source - if (!source) - cache.set(getter, source = registerModelProperty(instance, key, createSource)) - return source + let observable = cache.get(getter) as Observable + if (!observable) + cache.set(getter, observable = registerModelProperty(instance, key, createSource)) + return observable } } -function addInstancePropertyFactoryInitializer(context: ClassGetterDecoratorContext | ClassAccessorDecoratorContext, cachedSource: (instance: any) => ISource) { +function addInstancePropertyFactoryInitializer(context: ClassGetterDecoratorContext | ClassAccessorDecoratorContext, cachedSource: (instance: any) => IObservable) { context.addInitializer(function (this: Model) { assertIsModel(this, context.name) addInstancePropertyFactory(this, context.name, cachedSource) }) } -function addInstancePropertyFactory(instance: Model, key: PropertyKey, cachedSource: (instance: any) => ISource) { +function addInstancePropertyFactory(instance: Model, key: PropertyKey, cachedSource: (instance: any) => IObservable) { getInstancePropertyProviders(instance)[key] = cachedSource.bind(null, instance) } -function addPrototypePropertyFactory(prototype: object, key: PropertyKey, cachedSource: (instance: any) => ISource) { +function addPrototypePropertyFactory(prototype: object, key: PropertyKey, cachedSource: (instance: any) => IObservable) { getPrototypePropertyProviders(prototype)[key] = cachedSource } -function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): ISource; defaultName: string; }, errorFactory: () => Error): PropertyDescriptor { +function sourceSetter(key: PropertyKey, sourceType: { new(...args: any[]): IObservable; defaultName: string; }, errorFactory: () => Error): PropertyDescriptor { return { set } function set(this: object, value: unknown) { assertIsModel(this as Model, key) - let source = getUnderlyingSource(value) - if (!(source instanceof sourceType)) + let observable = getUnderlyingObservable(value) + if (!(observable instanceof sourceType)) throw errorFactory() - if (source.displayName == sourceType.defaultName) - source.setDisplayName(String(key)) + if (observable.displayName == sourceType.defaultName) + observable.setDisplayName(String(key)) - registerModelProperty(this as Model, key, () => source) - getInstancePropertyProviders(this)[key] = constant(source) + registerModelProperty(this as Model, key, () => observable) + getInstancePropertyProviders(this)[key] = constant(observable) Object.defineProperty(this, key, { - get: () => (source as Source).value, + get: () => (observable as Observable).value, set, enumerable: true, configurable: true diff --git a/packages/event-reduce/src/sources.ts b/packages/event-reduce/src/observable.ts similarity index 56% rename from packages/event-reduce/src/sources.ts rename to packages/event-reduce/src/observable.ts index e496568..5040212 100644 --- a/packages/event-reduce/src/sources.ts +++ b/packages/event-reduce/src/observable.ts @@ -1,36 +1,35 @@ -import type { IEventFunction } from "./events" -import { isEventsClass } from "./models" -import { getPath, updateChildren } from "./ownership" -import { currentIteration, iterationSource, trackSourceAccessed } from "./tracking" -import { MergedSources } from "./types" +import type { IEventFunction } from "./events" +import { isEventsClass } from "./models" +import { getPath, updateChildren } from "./ownership" +import { currentIteration, iterationSource, trackSourceAccessed } from "./tracking" +import { MergedObservables } from "./types" import { dispose, emptyArray, identity, nameOfCallback, nameOfFunction } from "./utils" -export interface ISource extends Disposable { +export interface IObservable extends Disposable { readonly displayName: string setDisplayName(name: string): void readonly path: string - getSources(): Iterable> + getSources(): Iterable> readonly lastUpdated: number subscribe(observer: IObserver): void unsubscribe(observer: IObserver): void - map(transform: (value: T) => Out, displayName?: string): ISource - filter(predicate: (value: T) => value is Out, displayName?: string): ISource - filter(predicate: (value: T) => boolean, displayName?: string): ISource - filterMap(transform: (value: T) => Out | undefined, displayName?: string): ISource + map(transform: (value: T) => Out, displayName?: string): IObservable + filter(predicate: (value: T) => value is Out, displayName?: string): IObservable + filter(predicate: (value: T) => boolean, displayName?: string): IObservable + filterMap(transform: (value: T) => Out | undefined, displayName?: string): IObservable } -// NOCOMMIT move elsewhere? export interface IObserver { readonly displayName: string - sourceUpdated(source: ISource, value: S): void + sourceUpdated(source: IObservable, value: S): void } -export function isSource(value: any): value is ISource { - return value instanceof Source +export function isObservable(value: any): value is IObservable { + return value instanceof Observable } -export abstract class Source implements ISource { +export abstract class Observable implements IObservable { protected readonly _observers = new Set>() protected _value!: T lastUpdated = -1 @@ -41,7 +40,7 @@ export abstract class Source implements ISource { get path(): string { return getPath(this) } - getSources() { return emptyArray as Iterable> } + getSources() { return emptyArray as Iterable> } get value() { trackSourceAccessed(this)[dispose]() @@ -50,7 +49,7 @@ export abstract class Source implements ISource { setValue(newValue: T) { if (process.env.NODE_ENV !== 'production') { - if (isEventsClass(newValue) || newValue instanceof Source && !newValue.isValidAsState) + if (isEventsClass(newValue) || newValue instanceof Observable && !newValue.isValidAsState) throw new InvalidStateError(this, newValue) if (!iterationSource) @@ -73,15 +72,15 @@ export abstract class Source implements ISource { protected isValidAsState = true - map, Out>(this: S, transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`): ISource { + map, Out>(this: S, transform: (value: T) => Out, displayName = `${this.displayName}.map(${nameOfCallback(transform)})`): IObservable { return bind(displayName, this, transform) } - filter>(this: S, predicate: (value: T) => boolean, displayName = `${this.displayName}.filter(${nameOfCallback(predicate)})`): ISource { + filter>(this: S, predicate: (value: T) => boolean, displayName = `${this.displayName}.filter(${nameOfCallback(predicate)})`): IObservable { return bind(displayName, this, identity, predicate) } - filterMap, Out>(this: S, transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`): ISource { + filterMap, Out>(this: S, transform: (value: T) => Out | undefined, displayName = `${this.displayName}.filterMap(${nameOfCallback(transform)})`): IObservable { return bind(displayName, this, v => transform(v)!, value => value != undefined) } @@ -95,27 +94,29 @@ export abstract class Source implements ISource { } } -export function bind(displayName: string, source: ISource, transform: (value: In) => Out, predicate?: (value: Out) => boolean) { - return new BoundSource(displayName, [source], transform, predicate) +export function bind(displayName: string, observable: IObservable, transform: (value: In) => Out, predicate?: (value: Out) => boolean) { + return new BoundObservable(displayName, [observable], transform, predicate) } -export function merge[]>(...sources: Sources): MergedSources -export function merge[]>(displayName: string, ...sources: Sources): MergedSources -export function merge[]>(...sources: [string, ...ISource[]] | ISource[]): MergedSources { +export function merge[]>(observables: Observables): MergedObservables +export function merge[]>(displayName: string, observables: Observables): MergedObservables +export function merge[]>(displayNameOrObservables: string | Observables, maybeObservables?: Observables): MergedObservables { let displayName: string; - if (typeof sources[0] == 'string') { - displayName = sources[0] as string - sources = sources.slice(1) as ISource[] + let observables: Observables + if (typeof displayNameOrObservables == 'string') { + displayName = displayNameOrObservables + observables = maybeObservables! } else { - displayName = `merge(${(sources as ISource[]).map(s => s.displayName).join(', ')})` + observables = displayNameOrObservables + displayName = `merge(${observables.map(s => s.displayName).join(', ')})` } - return new BoundSource(displayName, sources as ISource[], identity) + return new BoundObservable(displayName, observables, identity) } -export class BoundSource extends Source { +export class BoundObservable extends Observable { constructor( displayName: string, - protected _sources: ISource[], + protected _sources: IObservable[], protected _transform: (value: In) => Out, protected _predicate?: (value: Out) => boolean ) { super(displayName) } @@ -136,7 +137,7 @@ export class BoundSource extends Source { this._sources.forEach(source => source.unsubscribe(this)) } - sourceUpdated(source: ISource, value: In): void { + sourceUpdated(source: IObservable, value: In): void { try { this.setValue(this._transform(value)) } finally { @@ -149,32 +150,32 @@ export class BoundSource extends Source { protected override skipUpdate(value: Out) { return this._predicate?.(value) == false } } -export function* allSources(source: ISource): Generator> { - yield source - for (let dependency of source.getSources()) +export function* allSources(observable: IObservable): Generator> { + yield observable + for (let dependency of observable.getSources()) yield* allSources(dependency) } -export class NotASourceValueError extends Error { +export class NoUnderlyingObservableError extends Error { constructor( public value: unknown ) { - super("Couldn't detect source for value. Make sure you pass in an observable value directly."); + super("Couldn't detect observable for value. Make sure you pass in an observable value directly."); } } export class InvalidStateError extends Error { constructor( - public source: ISource, + public observable: IObservable, public value: IEventFunction | object - ) { super(`Tried to store ${isSource(value) ? value.displayName : value} in ${source.displayName}. ${nameOfFunction(value.constructor)}s cannot be state.`); } + ) { super(`Tried to store ${isObservable(value) ? value.displayName : value} in ${observable.displayName}. ${nameOfFunction(value.constructor)}s cannot be state.`); } } export class StateChangeOutsideTransitionError extends Error { constructor( - public source: ISource + public source: IObservable ) { super(`Tried to change state of ${source.displayName} outside of a transition. ` - + 'This usually means a bug in event-reduce, or in a custom source implementation.'); + + 'This usually means a bug in event-reduce, or in a custom observable implementation.'); } -} +} diff --git a/packages/event-reduce/src/atom.ts b/packages/event-reduce/src/observableValue.ts similarity index 86% rename from packages/event-reduce/src/atom.ts rename to packages/event-reduce/src/observableValue.ts index 7a7f156..ea3c992 100644 --- a/packages/event-reduce/src/atom.ts +++ b/packages/event-reduce/src/observableValue.ts @@ -1,11 +1,11 @@ import { Derivation } from "./derivation"; import { updateChildren } from "./ownership"; -import { ISource, Source } from "./sources"; +import { IObservable, Observable } from "./observable"; import { getState, State } from "./state"; import { beginTransition } from "./tracking"; import { dispose } from "./utils"; -export class Atom extends Source implements ISource { +export class ObservableValue extends Observable implements IObservable { private _json: Derivation> constructor( diff --git a/packages/event-reduce/src/ownership.ts b/packages/event-reduce/src/ownership.ts index a046c28..7d68a9e 100644 --- a/packages/event-reduce/src/ownership.ts +++ b/packages/event-reduce/src/ownership.ts @@ -1,9 +1,9 @@ import { log } from "./logging" import { type EventsClass, isEventsClass, isModel, type Model, StateProperty } from "./models" -import { type ISource, isSource, Source } from "./sources" +import { type IObservable, isObservable, Observable } from "./observable" import { dispose, emptyArray, emptyMap, isDisposable, isObject, nameOfFunction } from "./utils" -type GraphNode = ISource | Model | EventsClass +type GraphNode = IObservable | Model | EventsClass /** Child -> parent -> path. */ let parentRegistry = new WeakMap>() @@ -11,20 +11,20 @@ let parentRegistry = new WeakMap>() let childSnapshots = new WeakMap>() let garbage = new Set() -let currentCreationScope: Set> | null = null +let currentCreationScope: Set> | null = null export function updateChildren(parent: GraphNode, previousValue: unknown, nextValue: unknown, parentPath = '') { if (Object.is(previousValue, nextValue)) return let previousChildren: ReadonlyMap = emptyMap - if (isObject(previousValue) || previousValue instanceof Source) { + if (isObject(previousValue) || previousValue instanceof Observable) { previousChildren = childSnapshots.get(previousValue) ?? getChildNodes(previousValue) childSnapshots.delete(previousValue) } let newChildren: ReadonlyMap = emptyMap - if (isObject(nextValue) || nextValue instanceof Source) { + if (isObject(nextValue) || nextValue instanceof Observable) { newChildren = getChildNodes(nextValue) childSnapshots.set(nextValue, newChildren) } @@ -60,7 +60,7 @@ export function getChildNodes(value: object): ReadonlyMap { } function isGraphNode(value: unknown): value is GraphNode { - return isSource(value) + return isObservable(value) || isModel(value) || isEventsClass(value) } @@ -96,41 +96,41 @@ export function disposeGarbage() { garbage.clear() } -export interface IManagedSourceTracker extends Disposable { - verifySourceManagement(): void -} - -export function trackManagedSourceCreation(): IManagedSourceTracker { - let previousScope = currentCreationScope - let currentScope = currentCreationScope = new Set() - return { - verifySourceManagement() { - for (let object of currentScope) - if (!parentRegistry.has(object)) - throw new UntrackedSourceError(object) - }, - [dispose]() { currentCreationScope = previousScope } - } -} - -export function managedSourceCreated(source: GraphNode & ISource) { - if (!currentCreationScope) - throw new UntrackedSourceError(source) - currentCreationScope.add(source) -} +export interface IManagedObservableTracker extends Disposable { + verifyObservableManagement(): void +} + +export function trackManagedObservableCreation(): IManagedObservableTracker { + let previousScope = currentCreationScope + let currentScope = currentCreationScope = new Set() + return { + verifyObservableManagement() { + for (let object of currentScope) + if (!parentRegistry.has(object)) + throw new UntrackedObservableError(object) + }, + [dispose]() { currentCreationScope = previousScope } + } +} + +export function managedObservableCreated(observable: GraphNode & IObservable) { + if (!currentCreationScope) + throw new UntrackedObservableError(observable) + currentCreationScope.add(observable) +} export function getPath(node: GraphNode): string { let primaryOwner = parentRegistry.get(node)?.entries().next().value return primaryOwner ? getPath(primaryOwner[0]) + primaryOwner[1] - : isSource(node) ? node.displayName + : isObservable(node) ? node.displayName : isObject(node) ? nameOfFunction(node.constructor) || '$' : '$' } -export class UntrackedSourceError extends Error { - constructor(source: ISource) { - super(`Leak detected: ${nameOfFunction(source.constructor)} '${source.displayName}' created without an owner. -Make sure reductions are defined on a model, and that the model is either a class decorated with @model, or created using createModel().`) - } -} +export class UntrackedObservableError extends Error { + constructor(observable: IObservable) { + super(`Leak detected: ${nameOfFunction(observable.constructor)} '${observable.displayName}' created without an owner. +Make sure reductions are defined on a model, and that the model is either a class decorated with @model, or created using createModel().`) + } +} diff --git a/packages/event-reduce/src/reduction.ts b/packages/event-reduce/src/reduction.ts index d878f68..b65aaa3 100644 --- a/packages/event-reduce/src/reduction.ts +++ b/packages/event-reduce/src/reduction.ts @@ -1,16 +1,16 @@ import { Derivation } from "./derivation" import { startLogging } from "./logging" -import { managedSourceCreated, updateChildren } from "./ownership" -import { allSources, IObserver, ISource, isSource, NotASourceValueError, Source } from "./sources" +import { managedObservableCreated, updateChildren } from "./ownership" +import { allSources, IObserver, IObservable, isObservable, NoUnderlyingObservableError, Observable } from "./observable" import { getState, State } from "./state" -import { collectAccessedSources, currentIteration, getUnderlyingSource, trackSourceAccessed } from "./tracking" +import { collectAccessedSources, currentIteration, getUnderlyingObservable, trackSourceAccessed } from "./tracking" import { dispose, emptyArray } from "./utils" -export interface IReduction extends ISource { - readonly value: T - on(getSource: (events: Events) => ISource, reduce: Reducer): this - on(source: ISource, reduce: Reducer): this -} +export interface IReduction extends IObservable { + readonly value: T + on(getSource: (events: Events) => IObservable, reduce: Reducer): this + on(source: IObservable, reduce: Reducer): this +} export function reduce(initialValue: T, displayName?: string): IReduction export function reduce(initialValue: T, events: Events, displayName?: string): IReduction @@ -22,19 +22,19 @@ export function reduce(initialValue: T, eventsOrDisplayName?: Events return new Reduction(displayName ?? Reduction.defaultName, initialValue, Object.is, events) } -/** Extends an existing source with reduction capabilities. */ -export function extendReduce(observableValue: T, displayName?: string): IReduction { - let source = getUnderlyingSource(observableValue) - if (source) - return reduce(observableValue, displayName ?? source.displayName).on(source, (_, val) => val) - throw new NotASourceValueError(observableValue) -} - -export type Reducer = (current: Result, next: Source) => Result - -export class Reduction extends Source implements IReduction, IObserver { - sources = new Set>() - private _reducers = new Map, (current: T, next: any) => T>() +/** Extends an existing Observable with reduction capabilities. */ +export function extendReduce(observableValue: T, displayName?: string): IReduction { + let observable = getUnderlyingObservable(observableValue) + if (observable) + return reduce(observableValue, displayName ?? observable.displayName).on(observable, (_, val) => val) + throw new NoUnderlyingObservableError(observableValue) +} + +export type Reducer = (current: Result, next: Next) => Result + +export class Reduction extends Observable implements IReduction, IObserver { + private readonly _sources = new Set>() + private _reducers = new Map, (current: T, next: any) => T>() private _sourcesLastChecked = -1 private _json: Derivation> static defaultName = "(anonymous reduction)" @@ -50,7 +50,7 @@ export class Reduction extends Source implements IReduction this._json = new Derivation(`toJSON(${this.displayName})`, () => getState(this.value)) this._json.suppressLogging = true - managedSourceCreated(this) + managedObservableCreated(this) this._value = initialValue updateChildren(this, null, initialValue) @@ -58,36 +58,36 @@ export class Reduction extends Source implements IReduction toJSON() { return this._json.value; } - on(source: ISource, reduce: Reducer): this - on(getSource: (events: Events) => ISource, reduce: Reducer): this - on(source: ISource | ((events: Events) => ISource), reduce: Reducer) { - if (!isSource(source)) - source = source(this._events!) - - if (process.env.NODE_ENV !== 'production') { - if (allSources(source).find(s => s == this)) - throw new CircularSubscriptionError(this, source); - } - - source.subscribe(this) - this.sources.add(source) - this._reducers.set(source, reduce) - this._sourcesLastChecked = Math.max(this._sourcesLastChecked, source.lastUpdated) // Avoid firing reduction because of new source - - return this - } - - override getSources() { return this.sources } - - sourceUpdated(source: ISource, value: S) { - if (source.lastUpdated > this.lastUpdated) { - let reduce = this._reducers.get(source) + on(source: IObservable, reduce: Reducer): this + on(getSource: (events: Events) => IObservable, reduce: Reducer): this + on(source: IObservable | ((events: Events) => IObservable), reduce: Reducer) { + if (!isObservable(source)) + source = source(this._events!) + + if (process.env.NODE_ENV !== 'production') { + if (allSources(source).find(s => s == this)) + throw new CircularSubscriptionError(this, source); + } + + source.subscribe(this) + this._sources.add(source) + this._reducers.set(source, reduce) + this._sourcesLastChecked = Math.max(this._sourcesLastChecked, source.lastUpdated) // Avoid firing reduction because of new source + + return this + } + + override getSources() { return this._sources } + + sourceUpdated(source: IObservable, value: S) { + if (source.lastUpdated > this.lastUpdated) { + let reduce = this._reducers.get(source) if (reduce) { let previousValue = this._value let nextValue!: T let logging = startLogging('🧪 (reduction)', this.displayName, emptyArray, () => ({ Path: this.path, - Trigger: source.path, + Trigger: source.path, Previous: previousValue, Current: nextValue })) @@ -113,9 +113,9 @@ export class Reduction extends Source implements IReduction try { if (this._sourcesLastChecked < currentIteration) { - let newerSource = Array.from(this.sources).find(s => s.lastUpdated > this._sourcesLastChecked) + let newerSource = Array.from(this._sources).find(s => s.lastUpdated > this._sourcesLastChecked) if (newerSource) - this.sourceUpdated(newerSource, (newerSource as Source).value) + this.sourceUpdated(newerSource, (newerSource as Observable).value) } } finally { access[dispose]() @@ -132,8 +132,8 @@ export class Reduction extends Source implements IReduction override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue) } detach() { - for (let source of this.sources) - source.unsubscribe(this) + for (let source of this._sources) + source.unsubscribe(this) } override[dispose]() { @@ -145,8 +145,8 @@ export class Reduction extends Source implements IReduction export class CircularSubscriptionError extends Error { constructor( public reduction: IReduction, - public source: ISource - ) { - super(`Cannot subscribe to '${source.displayName}', as it depends on this reduction, '${reduction.displayName}'.`); - } -} + public source: IObservable + ) { + super(`Cannot subscribe to '${source.displayName}', as it depends on this reduction, '${reduction.displayName}'.`); + } +} diff --git a/packages/event-reduce/src/testing.ts b/packages/event-reduce/src/testing.ts index 773e8ea..2544c50 100644 --- a/packages/event-reduce/src/testing.ts +++ b/packages/event-reduce/src/testing.ts @@ -1,9 +1,9 @@ -import { Atom } from "./atom" +import { ObservableValue } from "./observableValue" import { Derivation } from "./derivation" import { event } from "./events" import { getModelProperties, isModel } from "./models" import { Reduction } from "./reduction" -import { isSource, Source } from "./sources" +import { isObservable, Observable } from "./observable" import { beginTransition } from "./tracking" import { dispose, isObject } from "./utils" @@ -15,14 +15,14 @@ export function mutable(model: T): Mutable { for (let key in properties) { let property = properties[key] - if (isSource(property)) { + if (isObservable(property)) { Object.defineProperty(model, key, { - get() { return (property as Source).value }, + get() { return (property as Observable).value }, set(value) { if (property instanceof Reduction || property instanceof Derivation) property.detach() - if (property instanceof Source) { + if (property instanceof Observable) { let transition = beginTransition(property, true) try { property.setValue(value) @@ -51,13 +51,13 @@ export function modelProxy(model: T = {} as T) { if (!isObject(model) || Array.isArray(model)) return model - let properties = {} as Record> + let properties = {} as Record> // Pre-populate observable values with plain value fields Object.entries(Object.getOwnPropertyDescriptors(model)) .forEach(([key, prop]) => { if ('value' in prop) - properties[key] = new Atom(String(key), prop.value) + properties[key] = new ObservableValue(String(key), prop.value) }) let proxy: T = new Proxy(model, { @@ -97,8 +97,8 @@ export function modelProxy(model: T = {} as T) { return proxy - function getOrAddObservableValue(key: PropertyKey): Atom { - return properties[String(key)] ??= new Atom(String(key), model[key as keyof T]) + function getOrAddObservableValue(key: PropertyKey): ObservableValue { + return properties[String(key)] ??= new ObservableValue(String(key), model[key as keyof T]) } } diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts index 73327c8..d3380a7 100644 --- a/packages/event-reduce/src/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -1,15 +1,15 @@ import { Effect } from "./effect" +import { type IObservable, type IObserver, Observable } from "./observable" import { disposeGarbage } from "./ownership" -import { type IObserver, type ISource, Source } from "./sources" import { dispose, nullDisposable } from "./utils" -export let currentIteration = 0 -export let iterationSource: ISource | Effect | null = null +export let currentIteration = 0 +export let iterationSource: IObservable | Effect | null = null let currentAccessTracker: AccessTracker | null = null -let lastSourceAccessed: ISource | null = null +let lastSourceAccessed: IObservable | null = null -export function beginTransition(source: ISource | Effect, isTriggerEvent: boolean): Disposable { +export function beginTransition(source: IObservable | Effect, isTriggerEvent: boolean): Disposable { if (isTriggerEvent) { if (iterationSource) throw new SideEffectDuringReactionError(iterationSource, source) @@ -32,33 +32,33 @@ export function collectAccessedSources(observer: IObserver | null) { return new AccessTracker(observer) } -export function trackSourceAccessed(source: ISource): Disposable { +export function trackSourceAccessed(source: IObservable): Disposable { currentAccessTracker?.addAccessed(source) return { [dispose]() { lastSourceAccessed = source } } } -export function getUnderlyingSource(value: T): ISource | null { +export function getUnderlyingObservable(value: T): IObservable | null { return lastSourceAccessed - && lastSourceAccessed instanceof Source + && lastSourceAccessed instanceof Observable && Object.is(lastSourceAccessed.value, value) ? lastSourceAccessed : null } export interface IAccessTracker extends Disposable { - readonly accessed: ReadonlySet> + readonly accessed: ReadonlySet> } class AccessTracker implements IAccessTracker { private readonly _previous: AccessTracker | null - readonly accessed = new Set>() + readonly accessed = new Set>() constructor(private readonly _observer: IObserver | null) { this._previous = currentAccessTracker currentAccessTracker = this } - addAccessed(source: ISource) { + addAccessed(source: IObservable) { if (this._observer) { let circularDependency = this.findCircularDependency(source) if (circularDependency) @@ -68,12 +68,12 @@ class AccessTracker implements IAccessTracker { this.accessed.add(source) } - private findCircularDependency(source: ISource): ISource[] | null { + private findCircularDependency(source: IObservable): IObservable[] | null { if (this._observer == source as any as IObserver) return [source] let circularDependency = this._previous?.findCircularDependency(source) return circularDependency - ? [...circularDependency, this._observer as any as ISource] + ? [...circularDependency, this._observer as any as IObservable] : null } @@ -83,12 +83,12 @@ class AccessTracker implements IAccessTracker { } export class CircularDependencyError extends Error { - constructor(path: ISource[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}.`) } + constructor(path: IObservable[]) { super(`Circular dependency detected: ${path.map(s => s.displayName).join(' -> ')}.`) } } export class SideEffectDuringReactionError extends Error { constructor( - public reactingTo: ISource | Effect, - public sideEffect: ISource | Effect + public reactingTo: IObservable | Effect, + public sideEffect: IObservable | Effect ) { super(`${sideEffect.displayName} side effect during reaction to ${reactingTo.displayName}.`) } } diff --git a/packages/event-reduce/src/types.ts b/packages/event-reduce/src/types.ts index fae44fb..9199867 100644 --- a/packages/event-reduce/src/types.ts +++ b/packages/event-reduce/src/types.ts @@ -1,14 +1,14 @@ -import type { ISource } from "./sources"; +import type { IObservable } from "./observable"; export type Scoped> = & Omit & { [K in keyof Scope]?: K extends keyof T ? T[K] : never } -export type MergedSources[]> = ISource> +export type MergedObservables[]> = IObservable> -type MergedValues[]> = - Sources extends [ISource, ...infer Rest extends ISource[]] +type MergedValues[]> = + Sources extends [IObservable, ...infer Rest extends IObservable[]] ? First | MergedValues : never -export type SourceValue> = Source extends ISource ? T : never +export type ObservedValue> = Observable extends IObservable ? T : never diff --git a/tests-legacy/LegacyModel.test.ts b/tests-legacy/LegacyModel.test.ts index 1636af7..7f45ec7 100644 --- a/tests-legacy/LegacyModel.test.ts +++ b/tests-legacy/LegacyModel.test.ts @@ -115,7 +115,7 @@ describe("legacy models", () => { test("extended legacy field value updated", () => expect(extendedModel.legacyField).toBe(0)) }) - test("when derivation creates a new model that accesses an observable value in its constructor, accessed observable value is not counted as a source for the derivation", () => { + test("when derivation creates a new model that accesses an observable value in its constructor, accessed observable value is not counted as an observable for the derivation", () => { @model class DerivedModel { property = testModel.property diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index 464aa4b..83c5fda 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test" -import { Atom, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce" +import { ObservableValue, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce" import { getModelProperties, isModel } from "event-reduce/lib/models" import { dispose } from "event-reduce/lib/utils" @@ -38,45 +38,45 @@ describe("subscription cleanup", () => { }) test("when derivation returns a new model, disposes old model", () => { - let source = new Atom('test source', 0) - let derivation = derive(() => new Model(source.value)) + let observable = new ObservableValue('test Observable', 0) + let derivation = derive(() => new Model(observable.value)) let modelInstance = derivation.value let unsubscribe = spyOnDispose(modelInstance, 'reducedProp') - source.setValue(1) + observable.setValue(1) derivation.value expect(unsubscribe).toHaveBeenCalled() }) test("when derivation returns the same model, doesn't dispose model", () => { - let source = new Atom('test source', 0) + let observable = new ObservableValue('test Observable', 0) let modelInstance = new Model(0) let derivation = derive(() => { - source.value + observable.value return modelInstance }) derivation.value let unsubscribe = spyOnDispose(modelInstance, 'reducedProp') - source.setValue(1) + observable.setValue(1) derivation.value expect(unsubscribe).not.toHaveBeenCalled() }) describe("when derivation returns a model from another observable value", () => { - let model1: Atom - let model2: Atom - let useModel2: Atom + let model1: ObservableValue + let model2: ObservableValue + let useModel2: ObservableValue let derivation: ReturnType> let modelInstance: Model let disposeModelSpy: Mock<() => void> beforeEach(() => { - model1 = new Atom('model 1', new Model(1)) - model2 = new Atom('model 2', new Model(2)) - useModel2 = new Atom('switch', false) + model1 = new ObservableValue('model 1', new Model(1)) + model2 = new ObservableValue('model 2', new Model(2)) + useModel2 = new ObservableValue('switch', false) derivation = derive(() => useModel2.value ? model2.value : model1.value) modelInstance = derivation.value disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') @@ -144,7 +144,7 @@ describe("subscription cleanup", () => { reduce([model1, model2]) .on(removeModel, ms => ms.slice(0, -1))) - new Atom('other value', model2) + new ObservableValue('other value', model2) removeModel() @@ -212,7 +212,7 @@ describe("subscription cleanup", () => { return remaining })) - new Atom('other value', model2) + new ObservableValue('other value', model2) removeModel() @@ -252,8 +252,8 @@ describe("subscription cleanup", () => { /* describe("when array items are also in another observable value", () => { test("when array value is disposed, doesn't dispose items", () => { - let arrayValue = new Atom('array value', [new Model(0)]) - new Atom('other value', arrayValue.value[0]) + let arrayValue = new ObservableValue('array value', [new Model(0)]) + new ObservableValue('other value', arrayValue.value[0]) let disposeModelSpy = spyOnDispose(arrayValue.value[0], 'reducedProp') arrayValue[dispose]() @@ -264,8 +264,8 @@ describe("subscription cleanup", () => { describe("when object values are also in another observable value", () => { test("when object value is disposed, doesn't dispose values", () => { - let objectValue = new Atom('object value', { model: new Model(0) }) - new Atom('other value', objectValue.value.model) + let objectValue = new ObservableValue('object value', { model: new Model(0) }) + new ObservableValue('other value', objectValue.value.model) let disposeModelSpy = spyOnDispose(objectValue.value.model, 'reducedProp') objectValue[dispose]() @@ -291,7 +291,7 @@ describe("subscription cleanup", () => { @derived get externallyOwned() { return this._ownedModel; } } - let externalOwner = new Atom('external owner', new Model(3)) + let externalOwner = new ObservableValue('external owner', new Model(3)) let outerModel = new OuterModel(externalOwner.value) as OuterModel & Disposable let disposeReducedPropValue = spyOnDispose(outerModel.reducedProp, 'reducedProp') let disposeDerivedPropValue = spyOnDispose(outerModel.derivedProp, 'reducedProp') @@ -301,7 +301,7 @@ describe("subscription cleanup", () => { // let disposeDerivedProp = spyOnDispose(outerModel, 'derivedProp') // let disposeExternallyOwnedProp = spyOnDispose(outerModel, 'externallyOwned') - let outerOwner = new Atom('outer owner', outerModel) + let outerOwner = new ObservableValue('outer owner', outerModel) outerOwner.setValue(null!) expect(disposeReducedProp).toHaveBeenCalled() diff --git a/tests/Derivation.test.ts b/tests/Derivation.test.ts index 644ad88..46bfdca 100644 --- a/tests/Derivation.test.ts +++ b/tests/Derivation.test.ts @@ -1,18 +1,18 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { Atom, derive, event, events, IDerivation } from "event-reduce"; -import { InvalidStateError } from "event-reduce/lib/sources"; -import { getUnderlyingSource, SideEffectDuringReactionError } from "event-reduce/lib/tracking"; +import { derive, event, events, IDerivation, ObservableValue } from "event-reduce"; +import { InvalidStateError } from "event-reduce/lib/observable"; +import { getUnderlyingObservable, SideEffectDuringReactionError } from "event-reduce/lib/tracking"; import { MockObserver } from "./MockObserver"; describe("derive", () => { - let sourceA: Atom; - let sourceB: Atom; + let sourceA: ObservableValue; + let sourceB: ObservableValue; let calculation: Mock<() => string>; let sut: IDerivation; beforeEach(() => { - sourceA = new Atom('a', 'a'); - sourceB = new Atom('b', 'b'); + sourceA = new ObservableValue('a', 'a'); + sourceB = new ObservableValue('b', 'b'); calculation = mock(() => sourceA.value + sourceB.value); sut = derive(calculation, 'sut'); }); @@ -28,7 +28,7 @@ describe("derive", () => { test("returns result of calculation", () => expect(result).toBe('ab')); - test("derivation is last accessed value", () => expect(getUnderlyingSource(result)!).toBe(sut)); + test("derivation is last accessed value", () => expect(getUnderlyingObservable(result)!).toBe(sut)); test("when accessed again, returns same value and doesn't re-compute the value", () => { let result2 = sut.value; @@ -37,7 +37,7 @@ describe("derive", () => { expect(calculation).toHaveBeenCalledTimes(1); }); - test("when a source value changed then accessed again, returns updated value", () => { + test("when an observable value changed then accessed again, returns updated value", () => { sourceB.setValue('B'); let result2 = sut.value; @@ -45,7 +45,7 @@ describe("derive", () => { expect(result2).toBe('aB'); }); - test("when multiple source values changed then accessed again, returns updated value and re-computes only once", () => { + test("when multiple observable values changed then accessed again, returns updated value and re-computes only once", () => { calculation.mockClear(); sourceA.setValue('A'); sourceB.setValue('B'); @@ -66,7 +66,7 @@ describe("derive", () => { test("doesn't notify immediately", () => expect(observer.sourceUpdated).not.toHaveBeenCalled()); - test("when a source value changed, notifies observer", () => { + test("when an observable value changed, notifies observer", () => { sourceA.setValue('A'); expect(observer.sourceUpdated).toHaveBeenCalled(); @@ -97,7 +97,7 @@ describe("derive", () => { expect.unreachable("Should have thrown"); } catch (e) { expect(e).toBeInstanceOf(InvalidStateError); - expect((e as InvalidStateError).source).toBe(derivation); + expect((e as InvalidStateError).observable).toBe(derivation); expect((e as InvalidStateError).value).toBe(eventValue); } }); @@ -113,16 +113,16 @@ describe("derive", () => { expect.unreachable("Should have thrown"); } catch (e) { expect(e).toBeInstanceOf(InvalidStateError); - expect((e as InvalidStateError).source).toBe(derivation); + expect((e as InvalidStateError).observable).toBe(derivation); expect((e as InvalidStateError).value).toBe(eventsValue); } }); }); test("derivation that depends on other derivations only updates once", () => { - let source = new Atom("source", "a"); - let inner1 = derive(() => source.value + "_inner1"); - let inner2 = derive(() => source.value + "_inner2"); + let observable = new ObservableValue("Observable", "a"); + let inner1 = derive(() => observable.value + "_inner1"); + let inner2 = derive(() => observable.value + "_inner2"); let outerCalc = mock(() => inner1.value + inner2.value); let outer = derive(outerCalc); outer.subscribe(new MockObserver()); // Outer must be observed for sources to trigger an update @@ -132,7 +132,7 @@ test("derivation that depends on other derivations only updates once", () => { expect(outerCalc).toHaveBeenCalledTimes(1); // This should trigger updates to inner1 and inner2, but outer should only re-calculate once - source.setValue("b"); + observable.setValue("b"); outer.value; expect(outerCalc).toHaveBeenCalledTimes(2); }); diff --git a/tests/Event.test.ts b/tests/Event.test.ts index 5e614b8..2192c60 100644 --- a/tests/Event.test.ts +++ b/tests/Event.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type IAsyncSource } from 'event-reduce'; -import { makePhasedEvent, type IPhasedSource } from "event-reduce/lib/events"; +import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type IAsyncObservable } from 'event-reduce'; +import { makePhasedEvent, type IPhasedObservable } from "event-reduce/lib/events"; import { SideEffectDuringReactionError } from "event-reduce/lib/tracking"; import { MockObserver } from "./MockObserver"; @@ -132,7 +132,7 @@ describe("asyncEvent", () => { }); describe("filterContext", () => { - let filtered: IAsyncSource; + let filtered: IAsyncObservable; let filteredStarted: MockObserver>; let filteredResolved: MockObserver>; let filteredRejected: MockObserver>; @@ -191,7 +191,7 @@ describe("asyncEvent", () => { expect(observed.sourceUpdated).toHaveBeenCalledWith(chained.resolved, expect.objectContaining({ phase: 'resolved', result: { foo: 'result' }, context: { bar: 'match' } })); }); - test("sets display names for filtered async sources and their phases", () => { + test("sets display names for filtered async observables and their phases", () => { let filtered = sut.filterContext(context => context.bar == 'match', 'matching'); expect(filtered.displayName).toBe('matching'); @@ -207,7 +207,7 @@ describe("asyncEvent", () => { let mixed = asyncEvent(); let matching = mixed.filterContext((context): context is Extract => context.kind == 'matching'); - let expected: IAsyncSource = matching; + let expected: IAsyncObservable = matching; expected.resolved.subscribe(new MockObserver>()); }); @@ -235,7 +235,7 @@ describe("makePhasedEvent", () => { }); } - test("adds phase discriminator and exposes phase sources", () => { + test("adds phase discriminator and exposes phase observables", () => { let sut = testPhasedEvent(); let all = new MockObserver(); let started = new MockObserver(); @@ -268,7 +268,7 @@ describe("makePhasedEvent", () => { test("scopes context by merging into last argument", () => { let sut = testPhasedEvent(); let scoped = sut.scope({ id: 'scoped' }); - let expectedScopedSource: IPhasedSource = scoped; + let expectedScopedSource: IPhasedObservable = scoped; let root = new MockObserver(); let scopedNext = new MockObserver(); sut.subscribe(root); @@ -310,7 +310,7 @@ describe("makePhasedEvent", () => { fire: (emit: (value: MixedValue) => void, value: string, context: MixedContext) => emit({ phase: 'next', value, context }) }); let matching = sut.filterContext((context): context is Extract => context.kind == 'matching'); - let expected: IPhasedSource<{ phase: 'next', value: string, context: { kind: 'matching', id: string } }, { kind: 'matching', id: string }> = matching; + let expected: IPhasedObservable<{ phase: 'next', value: string, context: { kind: 'matching', id: string } }, { kind: 'matching', id: string }> = matching; expected.next.subscribe(new MockObserver()); }); diff --git a/tests/Hooks.test.ts b/tests/Hooks.test.ts index fbf177e..7ce8bf5 100644 --- a/tests/Hooks.test.ts +++ b/tests/Hooks.test.ts @@ -60,20 +60,20 @@ describe("useAsyncEvent", () => { describe("useDerived", () => { @model class SourceModel { @reduced get value() { return reduce(1).value; } } - let source: ReturnType>; + let observable: ReturnType>; let sut: RenderHookResult, unknown> let initialResult: IDerivation beforeEach(() => { - source = mutable(new SourceModel()); - sut = renderHook(() => useDerived(() => source.value * 2)); + observable = mutable(new SourceModel()); + sut = renderHook(() => useDerived(() => observable.value * 2)); initialResult = sut.result.current; }); test("returns a derivation", () => { expect(initialResult.value).toBe(2); - source.value = 3; + observable.value = 3; expect(initialResult.value).toBe(6); }); diff --git a/tests/MockObserver.ts b/tests/MockObserver.ts index 43aedb1..27d5754 100644 --- a/tests/MockObserver.ts +++ b/tests/MockObserver.ts @@ -1,8 +1,8 @@ import { mock } from "bun:test"; -import { IObserver, ISource } from "event-reduce"; +import { IObservable, IObserver } from "event-reduce"; export class MockObserver implements IObserver { displayName = 'mock observer' - sources = new Set>() - sourceUpdated: (source: ISource, value: T) => void = mock<(source: ISource, value: T) => void>() + sources = new Set>() + sourceUpdated: (observable: IObservable, value: T) => void = mock<(observable: IObservable, value: T) => void>() } diff --git a/tests/Model.test.ts b/tests/Model.test.ts index b76a9cb..4640098 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; import { asyncEvent, derive, derived, event, events, model, reduce, reduced, state } from "event-reduce"; -import { Derivation } from "event-reduce/lib/derivation"; import { EventsMarkedAsStateError, MissingModelDecoratorError } from "event-reduce/lib/models"; import { decrement, ExtendedModel } from "./Model.test/ExtendedModel"; import { increment, TestModel } from "./Model.test/TestModel"; @@ -57,7 +56,7 @@ describe("models", () => { test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(0)); }); - test("when derivation creates a new model that accesses an observable value in its constructor, accessed observable value is not counted as a source for the derivation", () => { + test("when derivation creates a new model that accesses an observable value in its constructor, accessed observable value is not counted as an observable for the derivation", () => { @model class DerivedModel { constructor() { @@ -67,7 +66,7 @@ describe("models", () => { let derivation = derive(() => new DerivedModel()); derivation.update(); - expect((derivation as Derivation).sources.size).toBe(0); + expect([...derivation.getSources()]).toHaveLength(0); }); test("when reducer creates a new model that observes the same event that created it, doesn't throw", () => { diff --git a/tests/Source.test.ts b/tests/Observable.test.ts similarity index 82% rename from tests/Source.test.ts rename to tests/Observable.test.ts index b415517..ec540d4 100644 --- a/tests/Source.test.ts +++ b/tests/Observable.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { Atom, type ISource } from "event-reduce"; +import { ObservableValue, type IObservable } from "event-reduce"; import { MockObserver } from "./MockObserver"; -describe("Source", () => { - let sut: Atom; +describe("Observable", () => { + let sut: ObservableValue; let observer: MockObserver; beforeEach(() => { - sut = new Atom('sut', 'initial'); + sut = new ObservableValue('sut', 'initial'); observer = new MockObserver(); sut.subscribe(observer); }); @@ -58,9 +58,9 @@ describe("Source", () => { expect(observed).toEqual([2, 4]); }); - function values(source: ISource) { + function values(observable: IObservable) { let observed: T[] = []; - source.subscribe({ + observable.subscribe({ displayName: 'value collector', sourceUpdated: (_, value) => observed.push(value) }); diff --git a/tests/Atom.test.ts b/tests/ObservableValue.test.ts similarity index 71% rename from tests/Atom.test.ts rename to tests/ObservableValue.test.ts index 428c66d..d9a49f8 100644 --- a/tests/Atom.test.ts +++ b/tests/ObservableValue.test.ts @@ -1,14 +1,14 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { Atom } from "event-reduce"; -import { getUnderlyingSource } from "event-reduce/lib/tracking"; +import { ObservableValue } from "event-reduce"; +import { getUnderlyingObservable } from "event-reduce/lib/tracking"; import { MockObserver } from "./MockObserver"; -describe("Atom", () => { - let sut: Atom; +describe("ObservableValue", () => { + let sut: ObservableValue; let observer: MockObserver; beforeEach(() => { - sut = new Atom('sut', 'initial'); + sut = new ObservableValue('sut', 'initial'); observer = new MockObserver(); sut.subscribe(observer); }); @@ -22,7 +22,7 @@ describe("Atom", () => { test("returns provided value", () => expect(result).toBe('initial')); - test("tracks the accessed source", () => expect(getUnderlyingSource(result)).toBe(sut)); + test("tracks the accessed observable", () => expect(getUnderlyingObservable(result)).toBe(sut)); }); describe("when value changed", () => { @@ -42,7 +42,7 @@ describe("Atom", () => { }); test("when custom equality treats values as equal, observers are not notified", () => { - let custom = new Atom('custom', 'Alpha', (left, right) => left?.toLowerCase() === right?.toLowerCase()); + let custom = new ObservableValue('custom', 'Alpha', (left, right) => left?.toLowerCase() === right?.toLowerCase()); let customObserver = new MockObserver(); custom.subscribe(customObserver); diff --git a/tests/Reduction.test.ts b/tests/Reduction.test.ts index 1c705aa..c2d5d81 100644 --- a/tests/Reduction.test.ts +++ b/tests/Reduction.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { Atom, createModel, event, events, IReduction, reduce } from 'event-reduce'; +import { ObservableValue, createModel, event, events, IReduction, reduce } from 'event-reduce'; import { CircularSubscriptionError } from "event-reduce/lib/reduction"; -import { InvalidStateError } from "event-reduce/lib/sources"; +import { InvalidStateError } from "event-reduce/lib/observable"; import { CircularDependencyError, collectAccessedSources } from "event-reduce/lib/tracking"; import { dispose } from "event-reduce/lib/utils"; import { MockObserver } from "./MockObserver"; @@ -24,19 +24,19 @@ describe("reduce", () => { test("starts with initial value", () => expect(sut.value).toBe(1)); describe("when subscribed to an observable", () => { - let atom: Atom; + let observableValue: ObservableValue; let reducer: Mock<(previous: number, event: string) => number>; beforeEach(() => { - atom = new Atom('test', 'test'); + observableValue = new ObservableValue('test', 'test'); reducer = mock(); - sut.on(atom, reducer); + sut.on(observableValue, reducer); }); describe("when observable emits a value", () => { beforeEach(() => { reducer.mockReturnValue(3); - atom.setValue('foo'); + observableValue.setValue('foo'); }); test("reducer called with previous value and observable value", () => { @@ -48,7 +48,7 @@ describe("reduce", () => { describe("when another value is emitted", () => { beforeEach(() => { reducer.mockReturnValue(4); - atom.setValue('bar'); + observableValue.setValue('bar'); }); test("reducer called with previous value and observable value", () => { @@ -62,12 +62,12 @@ describe("reduce", () => { describe("when a reducer accesses a reduced value", () => { let other: ReturnType>; - let source: Atom; + let observable: ObservableValue; beforeEach(() => { other = createModel(() => reduce(0)); - source = new Atom('test', 0); - sut.on(source, () => other.value); + observable = new ObservableValue('test', 0); + sut.on(observable, () => other.value); }); afterEach(() => { @@ -75,19 +75,19 @@ describe("reduce", () => { }) test("when other value based on same source, uses latest value", () => { - other.on(source, (_, value) => value); + other.on(observable, (_, value) => value); - source.setValue(3); + observable.setValue(3); expect(other.value).toBe(3); expect(sut.value).toBe(3); }); test("when other value also accesses current reduction, throws", () => { - other.on(source, () => sut.value); + other.on(observable, () => sut.value); try { - source.setValue(1); + observable.setValue(1); expect.unreachable("Should have thrown"); } catch (e) { expect(e).toBeInstanceOf(CircularDependencyError); @@ -95,18 +95,18 @@ describe("reduce", () => { }); test("when other value not based on same event, doesn't throw", () => { - source.setValue(0); + observable.setValue(0); }); }); test("when subscribing to an observable based on itself, throws", () => { - let source = sut.filter(n => n > 3); + let observable = sut.filter(n => n > 3); try { - sut.on(source, (_, n) => n); + sut.on(observable, (_, n) => n); expect.unreachable("Should have thrown"); } catch (e) { expect(e).toBeInstanceOf(CircularSubscriptionError); - expect((e as CircularSubscriptionError).source).toBe(source); + expect((e as CircularSubscriptionError).source).toBe(observable); expect((e as CircularSubscriptionError).reduction).toBe(sut); } }); @@ -126,13 +126,13 @@ describe("reduce", () => { }) describe("when subscribing to an observable", () => { - let atom: Atom; - let getEvent: Mock<(events: typeof eventsObj) => Atom>; + let observableValue: ObservableValue; + let getEvent: Mock<(events: typeof eventsObj) => ObservableValue>; let reducer: Mock<(previous: number, event: string) => number>; beforeEach(() => { - atom = new Atom('test', 'test'); - getEvent = mock(() => atom); + observableValue = new ObservableValue('test', 'test'); + getEvent = mock(() => observableValue); reducer = mock(); sut.on(getEvent, reducer); }); @@ -142,7 +142,7 @@ describe("reduce", () => { }); test("reduction subscribed to result of event getter", () => { - atom.setValue('foo'); + observableValue.setValue('foo'); expect(reducer).toHaveBeenCalledWith(1, 'foo'); }); }); @@ -164,7 +164,7 @@ describe("reduce", () => { test("when reducer returns an event, throws", () => { let eventValue = event('test event'); - let subject = new Atom('test', 'test'); + let subject = new ObservableValue('test', 'test'); using sut = createModel(() => ({ reduction: reduce(null as any, 'sut') .on(subject, () => eventValue) @@ -175,7 +175,7 @@ describe("reduce", () => { expect.unreachable("Should have thrown"); } catch (e) { expect(e).toBeInstanceOf(InvalidStateError); - expect((e as InvalidStateError).source).toBe(sut.reduction); + expect((e as InvalidStateError).observable).toBe(sut.reduction); expect((e as InvalidStateError).value).toBe(eventValue); } }); @@ -184,7 +184,7 @@ describe("reduce", () => { @events class Events { } let eventsValue = new Events(); - let subject = new Atom('test', 'test'); + let subject = new ObservableValue('test', 'test'); using sut = createModel(() => ({ reduction: reduce(null as any, 'sut') .on(subject, () => eventsValue) @@ -195,7 +195,7 @@ describe("reduce", () => { expect.unreachable("Should have thrown"); } catch (e) { expect(e).toBeInstanceOf(InvalidStateError); - expect((e as InvalidStateError).source).toBe(sut.reduction); + expect((e as InvalidStateError).observable).toBe(sut.reduction); expect((e as InvalidStateError).value).toBe(eventsValue); } }); diff --git a/tests/Rendering.test.ts b/tests/Rendering.test.ts index e753bc7..4881736 100644 --- a/tests/Rendering.test.ts +++ b/tests/Rendering.test.ts @@ -1,20 +1,20 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; +import { ObservableValue } from "event-reduce"; import { useReactive } from "event-reduce-react"; -import { Atom } from "event-reduce"; describe('useReactive', () => { - let source: Atom; + let observable: ObservableValue; let innerBehaviour: () => void; let render: Mock<() => string>; let sut: ReturnType>; beforeEach(() => { - source = new Atom('test value', 'initial value'); + observable = new ObservableValue('test value', 'initial value'); innerBehaviour = () => { }; render = mock(() => { innerBehaviour(); - return source.value; + return observable.value; }); sut = renderHook(() => useReactive(render)); }); @@ -31,7 +31,7 @@ describe('useReactive', () => { describe("when accessed observable value changed", () => { beforeEach(() => { - act(() => source.setValue('new value')); + act(() => observable.setValue('new value')); }); test("render function called again", () => expect(render).toHaveBeenCalledTimes(2)); From f430a0411bc087bafc325cf66c7aee4b217a5cbb Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Thu, 7 May 2026 20:10:57 +1000 Subject: [PATCH 63/83] Extracting event and model symbols out to separate module to break cyclic dependencies --- packages/event-reduce/src/events.ts | 7 +-- packages/event-reduce/src/index.ts | 21 +++++---- packages/event-reduce/src/models.ts | 25 ++--------- packages/event-reduce/src/observable.ts | 2 +- packages/event-reduce/src/ownership.ts | 60 ++++++++++++------------- packages/event-reduce/src/symbols.ts | 51 +++++++++++++++++++++ packages/event-reduce/src/testing.ts | 7 +-- 7 files changed, 102 insertions(+), 71 deletions(-) create mode 100644 packages/event-reduce/src/symbols.ts diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index 869a938..3fd6daf 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -1,5 +1,6 @@ import { startLogging } from "./logging" import { type IObservable, Observable } from "./observable" +import { markEvent } from "./symbols" import { beginTransition } from "./tracking" import { ObservedValue, Scoped } from "./types" import { dispose, matchesScope, nameOfCallback } from "./utils" @@ -171,6 +172,7 @@ function makeObservableFunction, Args extend additionalMembers?: AdditionalMembersFactory ): IEventFunction, Args, Return> & Members { let eventFn = fire as Observable & IEventFunction, Args, Return> + markEvent(eventFn) Object.setPrototypeOf(eventFn, innerEvent); eventFn.apply = Function.prototype.apply; eventFn.call = Function.prototype.call; @@ -179,11 +181,6 @@ function makeObservableFunction, Args extend : eventFn as Observable & IEventFunction, Args, Return> & Members } -export function isEvent(value: any): value is IEventFunction { - return typeof value == 'function' - && value instanceof Event -} - export interface IEvent extends IEventFunction { scope>(scope: Scope): IEvent, Out> } diff --git a/packages/event-reduce/src/index.ts b/packages/event-reduce/src/index.ts index 27a15e2..5f7e68e 100644 --- a/packages/event-reduce/src/index.ts +++ b/packages/event-reduce/src/index.ts @@ -1,11 +1,10 @@ -export { derive, type IDerivation } from "./derivation" -export { enableDevTools } from "./devtools" -export { Effect } from "./effect" -export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IAsyncObservable, type IEvent } from "./events" -export { enableLogging, log, startLogging } from "./logging" -export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" -export { merge, type IObservable, type IObserver } from "./observable" -export { ObservableValue } from "./observableValue" -export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" -export { getState, type State } from "./state" - +export { derive, type IDerivation } from "./derivation" +export { enableDevTools } from "./devtools" +export { Effect } from "./effect" +export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IAsyncObservable, type IEvent } from "./events" +export { enableLogging, log, startLogging } from "./logging" +export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" +export { merge, type IObservable, type IObserver } from "./observable" +export { ObservableValue } from "./observableValue" +export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" +export { getState, type State } from "./state" diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index 2346c24..1c70ca1 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -1,27 +1,19 @@ import { Derivation, derive, IDerivation } from "./derivation" -import { isEvent } from "./events" import { IObservable, isObservable, Observable } from "./observable" import { getChildNodes, trackManagedObservableCreation, updateChildren } from "./ownership" import { IReduction, Reduction } from "./reduction" import { getState } from "./state" +import { EventsClass, isEvent, isEventsClass, isModel, markEventsClass, modelBrand, modelProperties, statePropertyBrand } from "./symbols" import { collectAccessedSources, getUnderlyingObservable } from "./tracking" import { ObservedValue } from "./types" import { argsAre, constant, dispose, emptyArray, getAllPropertyDescriptors, getOrAddWeakEntry, getPrototypeChain, isDisposable, isFunction, isObject, isString, isUndefined, nameOfFunction } from "./utils" -const modelBrand = Symbol('model class') -const modelProperties = Symbol('model properties') - export interface Model extends Disposable { [modelProperties]: Record } export type ModelProperty = IObservable | StateProperty -export function isModel(value: unknown): value is Model { - return isObject(value) - && modelProperties in value -} - /** instance -> key -> provider */ let instancePropertyProviders = new WeakMap ModelProperty>>() /** prototype -> key -> provider */ @@ -395,6 +387,8 @@ function registerModelProperty(model: Model, key } export class StateProperty implements Disposable { + readonly [statePropertyBrand] = true + constructor( private _instance: any, public readonly key: PropertyKey @@ -438,23 +432,12 @@ export let events = (target: T): T => { }) } } - (this as any)[eventsBrand] = true + markEventsClass(this) } } }[className] } -const eventsBrand = Symbol('events class') - -export interface EventsClass { - [eventsBrand]: true -} - -export function isEventsClass(value: unknown): value is EventsClass { - return value instanceof Object - && eventsBrand in value -} - export function getModelProperties(model: Model): Record { return model[modelProperties] } diff --git a/packages/event-reduce/src/observable.ts b/packages/event-reduce/src/observable.ts index 5040212..d9f46f7 100644 --- a/packages/event-reduce/src/observable.ts +++ b/packages/event-reduce/src/observable.ts @@ -1,6 +1,6 @@ import type { IEventFunction } from "./events" -import { isEventsClass } from "./models" import { getPath, updateChildren } from "./ownership" +import { isEventsClass } from "./symbols" import { currentIteration, iterationSource, trackSourceAccessed } from "./tracking" import { MergedObservables } from "./types" import { dispose, emptyArray, identity, nameOfCallback, nameOfFunction } from "./utils" diff --git a/packages/event-reduce/src/ownership.ts b/packages/event-reduce/src/ownership.ts index 7d68a9e..f5fde43 100644 --- a/packages/event-reduce/src/ownership.ts +++ b/packages/event-reduce/src/ownership.ts @@ -1,6 +1,6 @@ import { log } from "./logging" -import { type EventsClass, isEventsClass, isModel, type Model, StateProperty } from "./models" import { type IObservable, isObservable, Observable } from "./observable" +import { type EventsClass, isEventsClass, isModel, isStateProperty, type Model } from "./symbols" import { dispose, emptyArray, emptyMap, isDisposable, isObject, nameOfFunction } from "./utils" type GraphNode = IObservable | Model | EventsClass @@ -42,7 +42,7 @@ export function getChildNodes(value: object): ReadonlyMap { if (isGraphNode(value)) return new Map([[value, '']]) - if (value instanceof StateProperty) + if (isStateProperty(value)) return isObject(value.value) ? new Map(getChildNodes(value.value) .entries() @@ -96,28 +96,28 @@ export function disposeGarbage() { garbage.clear() } -export interface IManagedObservableTracker extends Disposable { - verifyObservableManagement(): void -} - -export function trackManagedObservableCreation(): IManagedObservableTracker { - let previousScope = currentCreationScope - let currentScope = currentCreationScope = new Set() - return { - verifyObservableManagement() { - for (let object of currentScope) - if (!parentRegistry.has(object)) - throw new UntrackedObservableError(object) - }, - [dispose]() { currentCreationScope = previousScope } - } -} - -export function managedObservableCreated(observable: GraphNode & IObservable) { - if (!currentCreationScope) - throw new UntrackedObservableError(observable) - currentCreationScope.add(observable) -} +export interface IManagedObservableTracker extends Disposable { + verifyObservableManagement(): void +} + +export function trackManagedObservableCreation(): IManagedObservableTracker { + let previousScope = currentCreationScope + let currentScope = currentCreationScope = new Set() + return { + verifyObservableManagement() { + for (let object of currentScope) + if (!parentRegistry.has(object)) + throw new UntrackedObservableError(object) + }, + [dispose]() { currentCreationScope = previousScope } + } +} + +export function managedObservableCreated(observable: GraphNode & IObservable) { + if (!currentCreationScope) + throw new UntrackedObservableError(observable) + currentCreationScope.add(observable) +} export function getPath(node: GraphNode): string { let primaryOwner = parentRegistry.get(node)?.entries().next().value @@ -128,9 +128,9 @@ export function getPath(node: GraphNode): string { : '$' } -export class UntrackedObservableError extends Error { - constructor(observable: IObservable) { - super(`Leak detected: ${nameOfFunction(observable.constructor)} '${observable.displayName}' created without an owner. -Make sure reductions are defined on a model, and that the model is either a class decorated with @model, or created using createModel().`) - } -} +export class UntrackedObservableError extends Error { + constructor(observable: IObservable) { + super(`Leak detected: ${nameOfFunction(observable.constructor)} '${observable.displayName}' created without an owner. +Make sure reductions are defined on a model, and that the model is either a class decorated with @model, or created using createModel().`) + } +} diff --git a/packages/event-reduce/src/symbols.ts b/packages/event-reduce/src/symbols.ts new file mode 100644 index 0000000..bf8ba20 --- /dev/null +++ b/packages/event-reduce/src/symbols.ts @@ -0,0 +1,51 @@ +import { isObject } from "./utils" + +const eventBrand = Symbol('event') +const eventsBrand = Symbol('events class') + +export const modelBrand = Symbol('model class') +export const modelProperties = Symbol('model properties') + +export const statePropertyBrand = Symbol('state property') + +export interface Model extends Disposable { + [modelProperties]: Record +} + +export interface EventsClass { + [eventsBrand]: true +} + +export interface StatePropertyBrand { + [statePropertyBrand]: true + key: PropertyKey + value: unknown +} + +export function markEvent(value: Function) { + (value as any)[eventBrand] = true +} + +export function isEvent(value: any): value is Function { + return typeof value == 'function' + && eventBrand in value +} + +export function markEventsClass(value: object) { + (value as any)[eventsBrand] = true +} + +export function isEventsClass(value: unknown): value is EventsClass { + return value instanceof Object + && eventsBrand in value +} + +export function isModel(value: unknown): value is Model { + return isObject(value) + && modelProperties in value +} + +export function isStateProperty(value: unknown): value is StatePropertyBrand { + return isObject(value) + && statePropertyBrand in value +} diff --git a/packages/event-reduce/src/testing.ts b/packages/event-reduce/src/testing.ts index 2544c50..b5f808a 100644 --- a/packages/event-reduce/src/testing.ts +++ b/packages/event-reduce/src/testing.ts @@ -1,9 +1,10 @@ -import { ObservableValue } from "./observableValue" +import { isModel } from "./symbols" import { Derivation } from "./derivation" import { event } from "./events" -import { getModelProperties, isModel } from "./models" -import { Reduction } from "./reduction" +import { getModelProperties } from "./models" import { isObservable, Observable } from "./observable" +import { ObservableValue } from "./observableValue" +import { Reduction } from "./reduction" import { beginTransition } from "./tracking" import { dispose, isObject } from "./utils" From aa017232b7f16e5cfc177d2f7e0be2fb13ae8255 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 13 May 2026 22:12:45 +1000 Subject: [PATCH 64/83] Updating isModel imports in event-reduce-react --- packages/event-reduce-react/src/hooks.ts | 2 +- tests/Cleanup.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index c00321c..5cb5fde 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -1,7 +1,7 @@ import { asyncEvent, derive, event, IDerivation, IReduction, ObservableValue, reduce } from "event-reduce" -import { isModel } from "event-reduce/lib/models" import { trackManagedObservableCreation } from "event-reduce/lib/ownership" import { Reduction } from "event-reduce/lib/reduction" +import { isModel } from "event-reduce/lib/symbols" import { dispose, emptyArray } from "event-reduce/lib/utils" import { DependencyList, useEffect, useMemo, useRef, useState } from "react" diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index 83c5fda..ab4d1c4 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test" import { ObservableValue, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce" -import { getModelProperties, isModel } from "event-reduce/lib/models" +import { getModelProperties } from "event-reduce/lib/models" +import { isModel } from "event-reduce/lib/symbols" import { dispose } from "event-reduce/lib/utils" describe("subscription cleanup", () => { From 5ad527aa8e2ef0f8b3db2ae2f98fb185677adb52 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 13 May 2026 22:14:41 +1000 Subject: [PATCH 65/83] Splitting trackSourceAccessed into start and stop functions to avoid disposable allocation --- packages/event-reduce/src/derivation.ts | 8 +- packages/event-reduce/src/observable.ts | 5 +- packages/event-reduce/src/reduction.ts | 106 ++++++++++++------------ packages/event-reduce/src/tracking.ts | 7 +- 4 files changed, 65 insertions(+), 61 deletions(-) diff --git a/packages/event-reduce/src/derivation.ts b/packages/event-reduce/src/derivation.ts index 415e27a..4d65caa 100644 --- a/packages/event-reduce/src/derivation.ts +++ b/packages/event-reduce/src/derivation.ts @@ -3,7 +3,7 @@ import { startLogging } from "./logging" import { IObservable, IObserver, Observable } from "./observable" import { trackManagedObservableCreation } from "./ownership" import { getState, State } from "./state" -import { beginTransition, iterationSource, trackSourceAccessed } from "./tracking" +import { beginTransition, endSourceAccessed, iterationSource, startSourceAccessed } from "./tracking" import { dispose, emptyArray, nameOfFunction, nullDisposable } from "./utils" export interface IDerivation extends IObservable, IObserver { @@ -46,7 +46,7 @@ export class Derivation extends Observable implements IDerivation { toJSON() { // Lazy init to avoid infinite loop in Derivation constructor - if (!this._json){ + if (!this._json) { this._json = new Derivation(`toJSON(${this.displayName})`, () => getState(this.value)) this._json.suppressLogging = true } @@ -78,13 +78,13 @@ export class Derivation extends Observable implements IDerivation { override get value() { // Notifying tracker before checking for updates so circular reference is detected early - let access = trackSourceAccessed(this) + startSourceAccessed(this) try { if (this._derive.isStale) this.update() } finally { - access[dispose]() + endSourceAccessed(this) } return this._value // Not using super.value to avoid duplicate tracker notification diff --git a/packages/event-reduce/src/observable.ts b/packages/event-reduce/src/observable.ts index d9f46f7..4d59490 100644 --- a/packages/event-reduce/src/observable.ts +++ b/packages/event-reduce/src/observable.ts @@ -1,7 +1,7 @@ import type { IEventFunction } from "./events" import { getPath, updateChildren } from "./ownership" import { isEventsClass } from "./symbols" -import { currentIteration, iterationSource, trackSourceAccessed } from "./tracking" +import { currentIteration, endSourceAccessed, iterationSource, startSourceAccessed } from "./tracking" import { MergedObservables } from "./types" import { dispose, emptyArray, identity, nameOfCallback, nameOfFunction } from "./utils" @@ -43,7 +43,8 @@ export abstract class Observable implements IObservable { getSources() { return emptyArray as Iterable> } get value() { - trackSourceAccessed(this)[dispose]() + startSourceAccessed(this) + endSourceAccessed(this) return this._value } diff --git a/packages/event-reduce/src/reduction.ts b/packages/event-reduce/src/reduction.ts index b65aaa3..27f3d1e 100644 --- a/packages/event-reduce/src/reduction.ts +++ b/packages/event-reduce/src/reduction.ts @@ -1,16 +1,16 @@ import { Derivation } from "./derivation" import { startLogging } from "./logging" +import { allSources, IObservable, IObserver, isObservable, NoUnderlyingObservableError, Observable } from "./observable" import { managedObservableCreated, updateChildren } from "./ownership" -import { allSources, IObserver, IObservable, isObservable, NoUnderlyingObservableError, Observable } from "./observable" import { getState, State } from "./state" -import { collectAccessedSources, currentIteration, getUnderlyingObservable, trackSourceAccessed } from "./tracking" +import { collectAccessedSources, currentIteration, endSourceAccessed, getUnderlyingObservable, startSourceAccessed } from "./tracking" import { dispose, emptyArray } from "./utils" -export interface IReduction extends IObservable { - readonly value: T - on(getSource: (events: Events) => IObservable, reduce: Reducer): this - on(source: IObservable, reduce: Reducer): this -} +export interface IReduction extends IObservable { + readonly value: T + on(getSource: (events: Events) => IObservable, reduce: Reducer): this + on(source: IObservable, reduce: Reducer): this +} export function reduce(initialValue: T, displayName?: string): IReduction export function reduce(initialValue: T, events: Events, displayName?: string): IReduction @@ -23,17 +23,17 @@ export function reduce(initialValue: T, eventsOrDisplayName?: Events } /** Extends an existing Observable with reduction capabilities. */ -export function extendReduce(observableValue: T, displayName?: string): IReduction { - let observable = getUnderlyingObservable(observableValue) - if (observable) - return reduce(observableValue, displayName ?? observable.displayName).on(observable, (_, val) => val) - throw new NoUnderlyingObservableError(observableValue) -} - -export type Reducer = (current: Result, next: Next) => Result - -export class Reduction extends Observable implements IReduction, IObserver { - private readonly _sources = new Set>() +export function extendReduce(observableValue: T, displayName?: string): IReduction { + let observable = getUnderlyingObservable(observableValue) + if (observable) + return reduce(observableValue, displayName ?? observable.displayName).on(observable, (_, val) => val) + throw new NoUnderlyingObservableError(observableValue) +} + +export type Reducer = (current: Result, next: Next) => Result + +export class Reduction extends Observable implements IReduction, IObserver { + private readonly _sources = new Set>() private _reducers = new Map, (current: T, next: any) => T>() private _sourcesLastChecked = -1 private _json: Derivation> @@ -58,36 +58,36 @@ export class Reduction extends Observable implements IReduc toJSON() { return this._json.value; } - on(source: IObservable, reduce: Reducer): this - on(getSource: (events: Events) => IObservable, reduce: Reducer): this - on(source: IObservable | ((events: Events) => IObservable), reduce: Reducer) { - if (!isObservable(source)) - source = source(this._events!) - - if (process.env.NODE_ENV !== 'production') { - if (allSources(source).find(s => s == this)) - throw new CircularSubscriptionError(this, source); - } - - source.subscribe(this) - this._sources.add(source) - this._reducers.set(source, reduce) - this._sourcesLastChecked = Math.max(this._sourcesLastChecked, source.lastUpdated) // Avoid firing reduction because of new source - - return this - } - - override getSources() { return this._sources } - - sourceUpdated(source: IObservable, value: S) { - if (source.lastUpdated > this.lastUpdated) { - let reduce = this._reducers.get(source) + on(source: IObservable, reduce: Reducer): this + on(getSource: (events: Events) => IObservable, reduce: Reducer): this + on(source: IObservable | ((events: Events) => IObservable), reduce: Reducer) { + if (!isObservable(source)) + source = source(this._events!) + + if (process.env.NODE_ENV !== 'production') { + if (allSources(source).find(s => s == this)) + throw new CircularSubscriptionError(this, source); + } + + source.subscribe(this) + this._sources.add(source) + this._reducers.set(source, reduce) + this._sourcesLastChecked = Math.max(this._sourcesLastChecked, source.lastUpdated) // Avoid firing reduction because of new source + + return this + } + + override getSources() { return this._sources } + + sourceUpdated(source: IObservable, value: S) { + if (source.lastUpdated > this.lastUpdated) { + let reduce = this._reducers.get(source) if (reduce) { let previousValue = this._value let nextValue!: T let logging = startLogging('🧪 (reduction)', this.displayName, emptyArray, () => ({ Path: this.path, - Trigger: source.path, + Trigger: source.path, Previous: previousValue, Current: nextValue })) @@ -109,16 +109,16 @@ export class Reduction extends Observable implements IReduc } override get value() { - let access = trackSourceAccessed(this) // Notifying tracker before checking for updates so circular reference is detected early + startSourceAccessed(this) // Notifying tracker before checking for updates so circular reference is detected early try { if (this._sourcesLastChecked < currentIteration) { - let newerSource = Array.from(this._sources).find(s => s.lastUpdated > this._sourcesLastChecked) + let newerSource = Array.from(this._sources).find(s => s.lastUpdated > this._sourcesLastChecked) if (newerSource) this.sourceUpdated(newerSource, (newerSource as Observable).value) } } finally { - access[dispose]() + endSourceAccessed(this) } return this._value // Not using super.value to avoid duplicate tracker notification @@ -132,8 +132,8 @@ export class Reduction extends Observable implements IReduc override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue) } detach() { - for (let source of this._sources) - source.unsubscribe(this) + for (let source of this._sources) + source.unsubscribe(this) } override[dispose]() { @@ -145,8 +145,8 @@ export class Reduction extends Observable implements IReduc export class CircularSubscriptionError extends Error { constructor( public reduction: IReduction, - public source: IObservable - ) { - super(`Cannot subscribe to '${source.displayName}', as it depends on this reduction, '${reduction.displayName}'.`); - } -} + public source: IObservable + ) { + super(`Cannot subscribe to '${source.displayName}', as it depends on this reduction, '${reduction.displayName}'.`); + } +} diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts index d3380a7..ba9a25a 100644 --- a/packages/event-reduce/src/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -32,9 +32,12 @@ export function collectAccessedSources(observer: IObserver | null) { return new AccessTracker(observer) } -export function trackSourceAccessed(source: IObservable): Disposable { +export function startSourceAccessed(source: IObservable) { currentAccessTracker?.addAccessed(source) - return { [dispose]() { lastSourceAccessed = source } } +} + +export function endSourceAccessed(source: IObservable) { + lastSourceAccessed = source } export function getUnderlyingObservable(value: T): IObservable | null { From ce076a1a1fd173ee47a285079d4913d4a1affdb7 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 13 May 2026 22:15:14 +1000 Subject: [PATCH 66/83] Optimizing circular dependency detection in AccessTracker --- packages/event-reduce/src/tracking.ts | 31 +++++++++++++++++---------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts index ba9a25a..40018fe 100644 --- a/packages/event-reduce/src/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -62,22 +62,31 @@ class AccessTracker implements IAccessTracker { } addAccessed(source: IObservable) { - if (this._observer) { - let circularDependency = this.findCircularDependency(source) - if (circularDependency) - throw new CircularDependencyError([...circularDependency, source]) + if (this._observer + && !this.accessed.has(source) + && this.hasCircularDependency(source) + ) { + throw new CircularDependencyError(this.circularPath(source)) } this.accessed.add(source) } - private findCircularDependency(source: IObservable): IObservable[] | null { - if (this._observer == source as any as IObserver) - return [source] - let circularDependency = this._previous?.findCircularDependency(source) - return circularDependency - ? [...circularDependency, this._observer as any as IObservable] - : null + private hasCircularDependency(source: IObservable) { + let foundCircularDependency = false + for (let tracker = this as AccessTracker | null; !foundCircularDependency && tracker; tracker = tracker._previous) + foundCircularDependency = tracker._observer == source as any as IObserver + return foundCircularDependency + } + + private circularPath(source: IObservable) { + let path = [source] + let tracker: AccessTracker | null = this + do { + path.unshift(tracker._observer as any as IObservable) + tracker = tracker._previous + } while (tracker && tracker._observer != source as any as IObserver) + return path } [dispose]() { From 018ecacb2dd2af47673eba199c7986ce15227951 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 13 May 2026 22:15:57 +1000 Subject: [PATCH 67/83] RenderEffect uses simple version as trigger state in production builds --- packages/event-reduce-react/src/rendering.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/event-reduce-react/src/rendering.ts b/packages/event-reduce-react/src/rendering.ts index 74c8b05..178ffba 100644 --- a/packages/event-reduce-react/src/rendering.ts +++ b/packages/event-reduce-react/src/rendering.ts @@ -34,14 +34,17 @@ export function useReactive(nameOrDeriveValue: string | (() => T), maybeDeriv class RenderEffect extends Effect { private _observers = new Set<() => void>() private _trigger: IObservable | null = null - private _triggerState = { triggeredBy: "(nothing)" } + private _version = 0 + private _triggerState: unknown = { triggeredBy: "(nothing)" } private _previousValue?: T constructor(displayName: string) { super(displayName, constant(undefined!), (observable) => { log('⚛️🚩 (render invalidated)', this.displayName, [observable]) this._trigger = observable - this._triggerState = { triggeredBy: observable.path } + this._triggerState = process.env.NODE_ENV !== 'production' + ? { triggeredBy: observable.path } + : { version: ++this._version } for (let subscriber of this._observers) subscriber() }) From 6cb3fc53b85bb41a62774994bdf2a3d65951f194 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 13 May 2026 22:16:35 +1000 Subject: [PATCH 68/83] disposeGarbage doesn't resolve entire node path unless logging is enabled --- packages/event-reduce/src/ownership.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/event-reduce/src/ownership.ts b/packages/event-reduce/src/ownership.ts index f5fde43..470f39b 100644 --- a/packages/event-reduce/src/ownership.ts +++ b/packages/event-reduce/src/ownership.ts @@ -89,7 +89,7 @@ function removeParent(node: GraphNode, parent: GraphNode) { export function disposeGarbage() { for (let node of garbage) { if (!parentRegistry.has(node)) { - log('🗑️ (dispose)', getPath(node), emptyArray, undefined) + log('🗑️ (dispose)', getName(node), emptyArray, () => ({ Path: getPath(node) })) node[dispose]() } } @@ -123,9 +123,13 @@ export function getPath(node: GraphNode): string { let primaryOwner = parentRegistry.get(node)?.entries().next().value return primaryOwner ? getPath(primaryOwner[0]) + primaryOwner[1] - : isObservable(node) ? node.displayName - : isObject(node) ? nameOfFunction(node.constructor) || '$' - : '$' + : getName(node) +} + +function getName(node: GraphNode) { + return isObservable(node) ? node.displayName + : isObject(node) ? nameOfFunction(node.constructor) || '$' + : '$' } export class UntrackedObservableError extends Error { @@ -134,3 +138,4 @@ export class UntrackedObservableError extends Error { Make sure reductions are defined on a model, and that the model is either a class decorated with @model, or created using createModel().`) } } + From 784dcc1ca430b38c5757fb0ce185f0c1842ab76a Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Mon, 18 May 2026 17:13:31 +1000 Subject: [PATCH 69/83] Improved merge typing --- packages/event-reduce/src/observable.ts | 10 +++++----- packages/event-reduce/src/types.ts | 7 ------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/event-reduce/src/observable.ts b/packages/event-reduce/src/observable.ts index 4d59490..cf931a7 100644 --- a/packages/event-reduce/src/observable.ts +++ b/packages/event-reduce/src/observable.ts @@ -2,7 +2,7 @@ import type { IEventFunction } from "./events" import { getPath, updateChildren } from "./ownership" import { isEventsClass } from "./symbols" import { currentIteration, endSourceAccessed, iterationSource, startSourceAccessed } from "./tracking" -import { MergedObservables } from "./types" +import type { ObservedValue } from "./types" import { dispose, emptyArray, identity, nameOfCallback, nameOfFunction } from "./utils" export interface IObservable extends Disposable { @@ -99,9 +99,9 @@ export function bind(displayName: string, observable: IObservable, return new BoundObservable(displayName, [observable], transform, predicate) } -export function merge[]>(observables: Observables): MergedObservables -export function merge[]>(displayName: string, observables: Observables): MergedObservables -export function merge[]>(displayNameOrObservables: string | Observables, maybeObservables?: Observables): MergedObservables { +export function merge[]>(observables: Observables): IObservable> +export function merge[]>(displayName: string, observables: Observables): IObservable> +export function merge[]>(displayNameOrObservables: string | Observables, maybeObservables?: Observables): IObservable> { let displayName: string; let observables: Observables if (typeof displayNameOrObservables == 'string') { @@ -111,7 +111,7 @@ export function merge[]>(displayNameOrObser observables = displayNameOrObservables displayName = `merge(${observables.map(s => s.displayName).join(', ')})` } - return new BoundObservable(displayName, observables, identity) + return new BoundObservable(displayName, observables.slice(), identity) } export class BoundObservable extends Observable { diff --git a/packages/event-reduce/src/types.ts b/packages/event-reduce/src/types.ts index 9199867..a71a95c 100644 --- a/packages/event-reduce/src/types.ts +++ b/packages/event-reduce/src/types.ts @@ -4,11 +4,4 @@ export type Scoped> = & Omit & { [K in keyof Scope]?: K extends keyof T ? T[K] : never } -export type MergedObservables[]> = IObservable> - -type MergedValues[]> = - Sources extends [IObservable, ...infer Rest extends IObservable[]] - ? First | MergedValues - : never - export type ObservedValue> = Observable extends IObservable ? T : never From aae443740d5a2d7f7d69f31a2c234cc5973b6204 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 14 Jun 2026 09:38:07 +1000 Subject: [PATCH 70/83] Optimize core update paths --- packages/event-reduce/src/observable.ts | 5 +++-- packages/event-reduce/src/reduction.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/event-reduce/src/observable.ts b/packages/event-reduce/src/observable.ts index cf931a7..2bb95fe 100644 --- a/packages/event-reduce/src/observable.ts +++ b/packages/event-reduce/src/observable.ts @@ -3,7 +3,7 @@ import { getPath, updateChildren } from "./ownership" import { isEventsClass } from "./symbols" import { currentIteration, endSourceAccessed, iterationSource, startSourceAccessed } from "./tracking" import type { ObservedValue } from "./types" -import { dispose, emptyArray, identity, nameOfCallback, nameOfFunction } from "./utils" +import { dispose, emptyArray, identity, isObject, nameOfCallback, nameOfFunction } from "./utils" export interface IObservable extends Disposable { readonly displayName: string @@ -59,7 +59,8 @@ export abstract class Observable implements IObservable { if (!this.skipUpdate(newValue)) { this.lastUpdated = currentIteration - this.updateChildren(this._value, newValue) + if (isObject(this._value) || isObject(newValue)) + this.updateChildren(this._value, newValue) this._value = newValue for (let observer of this._observers) diff --git a/packages/event-reduce/src/reduction.ts b/packages/event-reduce/src/reduction.ts index 27f3d1e..12c6aee 100644 --- a/packages/event-reduce/src/reduction.ts +++ b/packages/event-reduce/src/reduction.ts @@ -34,6 +34,7 @@ export type Reducer = (current: Result, next: Next) => Result export class Reduction extends Observable implements IReduction, IObserver { private readonly _sources = new Set>() + private _singleReducer?: (current: T, next: any) => T private _reducers = new Map, (current: T, next: any) => T>() private _sourcesLastChecked = -1 private _json: Derivation> @@ -72,6 +73,7 @@ export class Reduction extends Observable implements IReduc source.subscribe(this) this._sources.add(source) this._reducers.set(source, reduce) + this._singleReducer = this._reducers.size == 1 ? reduce : undefined this._sourcesLastChecked = Math.max(this._sourcesLastChecked, source.lastUpdated) // Avoid firing reduction because of new source return this @@ -81,7 +83,7 @@ export class Reduction extends Observable implements IReduc sourceUpdated(source: IObservable, value: S) { if (source.lastUpdated > this.lastUpdated) { - let reduce = this._reducers.get(source) + let reduce = this._singleReducer ?? this._reducers.get(source) if (reduce) { let previousValue = this._value let nextValue!: T @@ -113,7 +115,7 @@ export class Reduction extends Observable implements IReduc try { if (this._sourcesLastChecked < currentIteration) { - let newerSource = Array.from(this._sources).find(s => s.lastUpdated > this._sourcesLastChecked) + let newerSource = this._sources.values().find(s => s.lastUpdated > this._sourcesLastChecked) if (newerSource) this.sourceUpdated(newerSource, (newerSource as Observable).value) } From 555994d85b4726aaa759fc6d4e297b76525146e3 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 14 Jun 2026 09:34:21 +1000 Subject: [PATCH 71/83] Coalesce React render invalidations --- packages/event-reduce-react/src/rendering.ts | 20 ++++++++++++-------- packages/event-reduce/src/tracking.ts | 7 +++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/event-reduce-react/src/rendering.ts b/packages/event-reduce-react/src/rendering.ts index 178ffba..932681c 100644 --- a/packages/event-reduce-react/src/rendering.ts +++ b/packages/event-reduce-react/src/rendering.ts @@ -1,7 +1,7 @@ import { Effect, log, startLogging } from "event-reduce" import { LogValue } from "event-reduce/lib/logging" import { IObservable } from "event-reduce/lib/observable" -import { beginTransition } from "event-reduce/lib/tracking" +import { beginTransition, queueTransitionEffect } from "event-reduce/lib/tracking" import { constant, dispose, emptyArray, nameOfFunction } from "event-reduce/lib/utils" import { Children, Fragment, ReactElement, ReactNode, createElement, isValidElement, useState } from "react" import { useSyncExternalStore } from "use-sync-external-store/shim" @@ -40,13 +40,17 @@ class RenderEffect extends Effect { constructor(displayName: string) { super(displayName, constant(undefined!), (observable) => { - log('⚛️🚩 (render invalidated)', this.displayName, [observable]) - this._trigger = observable - this._triggerState = process.env.NODE_ENV !== 'production' - ? { triggeredBy: observable.path } - : { version: ++this._version } - for (let subscriber of this._observers) - subscriber() + if (!this._trigger) { + log('⚛️🚩 (render invalidated)', this.displayName, [observable]) + this._trigger = observable + this._triggerState = process.env.NODE_ENV !== 'production' + ? { triggeredBy: observable.path } + : { version: ++this._version } + queueTransitionEffect(() => { + for (let subscriber of this._observers) + subscriber() + }) + } }) this.attach() } diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts index 40018fe..517da0e 100644 --- a/packages/event-reduce/src/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -5,6 +5,7 @@ import { dispose, nullDisposable } from "./utils" export let currentIteration = 0 export let iterationSource: IObservable | Effect | null = null +let transitionEffectQueue = [] as (() => void)[] let currentAccessTracker: AccessTracker | null = null let lastSourceAccessed: IObservable | null = null @@ -19,6 +20,8 @@ export function beginTransition(source: IObservable | Effect, isTrigge return { [dispose]() { + for (let effect = transitionEffectQueue.shift(); effect; effect = transitionEffectQueue.shift()) + effect() iterationSource = null disposeGarbage() } @@ -28,6 +31,10 @@ export function beginTransition(source: IObservable | Effect, isTrigge } } +export function queueTransitionEffect(effect: () => void) { + transitionEffectQueue.push(effect) +} + export function collectAccessedSources(observer: IObserver | null) { return new AccessTracker(observer) } From 57ca366db3916c71722e0bfa9cdff7ba5300da62 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 20 May 2026 18:34:11 +1000 Subject: [PATCH 72/83] Optimizing Effect.isStale Fast-track for invalidatingSource Looping for early exit and avoiding array allocations --- packages/event-reduce/src/effect.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/event-reduce/src/effect.ts b/packages/event-reduce/src/effect.ts index cfda3f2..de87102 100644 --- a/packages/event-reduce/src/effect.ts +++ b/packages/event-reduce/src/effect.ts @@ -3,10 +3,10 @@ import { collectAccessedSources, currentIteration } from "./tracking" import { dispose } from "./utils" export class Effect implements IObserver { - private _attached = false private readonly _sources = new Set>() + private _attached = false + private _lastRun = -1 // Stale from the start invalidatingSource?: IObservable - lastRun = -2 // Stale from the start constructor( public readonly displayName: string, @@ -33,22 +33,30 @@ export class Effect implements IObserver { } sourceUpdated(source: IObservable, value: S): void { - if (source.lastUpdated > this.lastRun) { + if (source.lastUpdated > this._lastRun) { this.invalidatingSource = source this._onSourceUpdatedSinceLastRun(source) } } - get isStale() { return this.sourceLastUpdated > this.lastRun } + get isStale() { + if (this._lastRun < 0 + || this.invalidatingSource?.lastUpdated! > this._lastRun) // Fast-track for the most common case + return true - get sourceLastUpdated() { return Math.max(-1, ...[...this._sources].map(s => s.lastUpdated)) } + for (let source of this._sources) + if (source.lastUpdated > this._lastRun) + return true + + return false + } /** * Forces the effect to run. * @param effect Optional function to use to run the effect. If not provided, the original effect function will be used. **/ run(effect?: () => T) { - this.lastRun = currentIteration + this._lastRun = currentIteration delete this.invalidatingSource let result: T From 3ad976756f04c81a4a0818dcd860f91dd9276537 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 20 May 2026 18:35:34 +1000 Subject: [PATCH 73/83] Optimizing updateChildren Fast-tracks for scalar <-> graph node changes Removing redundant checks --- packages/event-reduce/src/ownership.ts | 16 ++++++-- packages/event-reduce/src/symbols.ts | 2 +- packages/event-reduce/src/utils.ts | 56 +++++++++++++------------- 3 files changed, 42 insertions(+), 32 deletions(-) diff --git a/packages/event-reduce/src/ownership.ts b/packages/event-reduce/src/ownership.ts index 470f39b..c88d041 100644 --- a/packages/event-reduce/src/ownership.ts +++ b/packages/event-reduce/src/ownership.ts @@ -1,5 +1,5 @@ import { log } from "./logging" -import { type IObservable, isObservable, Observable } from "./observable" +import { type IObservable, isObservable } from "./observable" import { type EventsClass, isEventsClass, isModel, isStateProperty, type Model } from "./symbols" import { dispose, emptyArray, emptyMap, isDisposable, isObject, nameOfFunction } from "./utils" @@ -17,14 +17,24 @@ export function updateChildren(parent: GraphNode, previousValue: unknown, nextVa if (Object.is(previousValue, nextValue)) return + if (!isObject(previousValue) && isGraphNode(nextValue)) { + addParent(nextValue, parent, parentPath) // Fast-track for adding single node + return + } + + if (!isObject(nextValue) && isGraphNode(previousValue)) { + removeParent(previousValue, parent) // Fast-track for removing single node + return + } + let previousChildren: ReadonlyMap = emptyMap - if (isObject(previousValue) || previousValue instanceof Observable) { + if (isObject(previousValue)) { previousChildren = childSnapshots.get(previousValue) ?? getChildNodes(previousValue) childSnapshots.delete(previousValue) } let newChildren: ReadonlyMap = emptyMap - if (isObject(nextValue) || nextValue instanceof Observable) { + if (isObject(nextValue)) { newChildren = getChildNodes(nextValue) childSnapshots.set(nextValue, newChildren) } diff --git a/packages/event-reduce/src/symbols.ts b/packages/event-reduce/src/symbols.ts index bf8ba20..453033f 100644 --- a/packages/event-reduce/src/symbols.ts +++ b/packages/event-reduce/src/symbols.ts @@ -36,7 +36,7 @@ export function markEventsClass(value: object) { } export function isEventsClass(value: unknown): value is EventsClass { - return value instanceof Object + return isObject(value) && eventsBrand in value } diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index cfccd68..5ea2c19 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -1,4 +1,4 @@ -export const emptyArray = Object.freeze([]) as readonly any[]; +export const emptyArray = Object.freeze([]) as readonly any[] export const emptySet = Object.freeze(new Set()) as ReadonlySet export const emptyMap = Object.freeze(new Map()) as ReadonlyMap @@ -7,21 +7,21 @@ export function constant(value: T) { return identity.bind(null, value) as () export function identity(value: T) { return value; } export function filteredName(baseName: string, predicate: Function) { - return `${baseName}.filter(${nameOfCallback(predicate)})`; + return `${baseName}.filter(${nameOfCallback(predicate)})` } export function scopedName(baseName: string, scope: object) { - return `${baseName}.scoped({ ${Object.entries(scope).map(([k, v]) => `${k}: ${v}`).join(', ')} })`; + return `${baseName}.scoped({ ${Object.entries(scope).map(([k, v]) => `${k}: ${v}`).join(', ')} })` } /** Returns explicit name of function, falling back to function content. */ export function nameOfCallback(fn: Function) { - return nameOfFunction(fn) || String(fn); + return nameOfFunction(fn) || String(fn) } /** Returns explicit name of function. */ export function nameOfFunction(fn: Function) { - return (fn as any).displayName as string || fn.name; + return (fn as any).displayName as string || fn.name } export function jsonPath(keys: readonly PropertyKey[]) { @@ -30,7 +30,7 @@ export function jsonPath(keys: readonly PropertyKey[]) { : typeof key == 'number' || typeof key == 'symbol' ? `[${String(key)}]` : /^[\w\$]+$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`) - .join(''); + .join('') } /** Utility base class for anything with a dynamic displayName */ @@ -41,77 +41,77 @@ export class NamedBase { set displayName(name: string) { this._getDisplayName = constant(name); } /** Just for avoiding unnecessary arrow functions, but importantly reflects changes to the _getDisplayName callback. */ - protected readonly displayNameGetter = () => this._getDisplayName(); + protected readonly displayNameGetter = () => this._getDisplayName() } -export function matchesScope(scope: Scope): (value: Value) => boolean; -export function matchesScope(scope: Scope, value: Value): boolean; +export function matchesScope(scope: Scope): (value: Value) => boolean +export function matchesScope(scope: Scope, value: Value): boolean export function matchesScope(scope: Scope, value?: Value) { return arguments.length == 2 ? matchesScope(scope)(value) : (value: Value) => Object.entries(scope) - .every(([k, v]) => value[k as keyof Scope] === v); + .every(([k, v]) => value[k as keyof Scope] === v) } export function argsAre[]>(args: any[], ...typeChecks: Checks): args is CheckedTypes { return typeChecks.length <= args.length && typeChecks.every((typeCheck, i) => typeCheck(args[i])) - && args.slice(typeChecks.length).every(arg => arg === undefined); + && args.slice(typeChecks.length).every(arg => arg === undefined) } -type TypeCheck = (value: any) => value is T; +type TypeCheck = (value: any) => value is T type CheckedTypes[]> = Checks extends [TypeCheck, ...infer Rest extends TypeCheck[]] ? [First, ...CheckedTypes] - : []; + : [] export function isFunction(value: any): value is Function { - return typeof value == 'function'; + return typeof value == 'function' } export function isPlainObject(value: T): value is T & object { return isObject(value) - && Object.getPrototypeOf(value) == Object.prototype; + && Object.getPrototypeOf(value) == Object.prototype } export function isObject(value: T): value is T & object { - return !!value && typeof value == 'object'; + return !!value && typeof value == 'object' } export function isString(value: any): value is string { - return typeof value == 'string'; + return typeof value == 'string' } export function isUndefined(value: any): value is undefined { - return value === undefined; + return value === undefined } export function getOrAdd(target: any, key: string | symbol, create: (base: T | undefined) => T): T { return target.hasOwnProperty(key) ? target[key] - : (target[key] = create(target[key])); + : (target[key] = create(target[key])) } export function getOrAddWeakEntry(cache: WeakMap, key: K, create: (key: K) => V): V { - let cached = cache.get(key); + let cached = cache.get(key) if (!cached) - cache.set(key, cached = create(key)); - return cached; + cache.set(key, cached = create(key)) + return cached } export function firstIntersection(a: Set, b: Set) { for (let item of a) if (b.has(item)) - return item; + return item } export function getAllPropertyDescriptors(obj: object) { - let props = new Map(); + let props = new Map() for (let proto of getPrototypeChain(obj)) for (let [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) if (!props.has(key)) props.set(key, descriptor) - return props; + return props } export function* getPrototypeChain(obj: object) { @@ -126,15 +126,15 @@ export function isDisposable(value: any): value is Disposable { && dispose in value } -export const dispose: typeof Symbol.dispose = Symbol.dispose ?? Symbol('Symbol.dispose'); +export const dispose: typeof Symbol.dispose = Symbol.dispose ?? Symbol('Symbol.dispose') export const nullDisposable: Disposable = Object.freeze({ [dispose]() { } }) /** Shim for `using` declaration until it's properly supported. */ export function using(disposable: Resource, action: (resource: Resource) => void) { try { - action(disposable); + action(disposable) } finally { - disposable[dispose](); + disposable[dispose]() } } \ No newline at end of file From 583c3c18ad1bb19d3214154a460209537cae9757 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 14 Jun 2026 09:24:31 +1000 Subject: [PATCH 74/83] Removing useObservedProps and replacing useAtom with useObservableValue --- packages/event-reduce-react/src/hooks.ts | 32 +++--------------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index 5cb5fde..bd52386 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -3,7 +3,7 @@ import { trackManagedObservableCreation } from "event-reduce/lib/ownership" import { Reduction } from "event-reduce/lib/reduction" import { isModel } from "event-reduce/lib/symbols" import { dispose, emptyArray } from "event-reduce/lib/utils" -import { DependencyList, useEffect, useMemo, useRef, useState } from "react" +import { DependencyList, useEffect, useMemo, useState } from "react" /** * Creates a model that persists across renders of the component. @@ -52,34 +52,8 @@ export function useReduced(initial: T, name?: string): IReduction { return reduction } -export function useObservedProps(values: T, name: string = '(anonymous observed values)') { - let observableValues = useState(() => ({} as { [K in keyof T]?: ObservableValue }))[0] - let nameBase = (name || '') + '.' - - // Update any values that are already being observed - useEffect(() => { - for (let [key, observableValue] of Object.entries(observableValues) as [keyof T, ObservableValue][]) - observableValue.setValue(values[key]) - }) - - let latestValues = useRef(values) - latestValues.current = values - - // Create observable values as properties are accessed - return new Proxy({} as T, { - get(_, key) { - return (observableValues[key as keyof T] - ??= new ObservableValue(nameBase + String(key), - (latestValues.current)[key as keyof T])) - .value - } - }) -} - -export function useAtom(value: T, name: string = '(anonymous observed value)') { - let observableValue = useState(() => new ObservableValue(name, value))[0] - observableValue.setValue(value) - return observableValue +export function useObservableValue(initialValue: T, name: string = '(anonymous observed value)') { + return useState(() => new ObservableValue(name, initialValue))[0] } class InvalidModelError extends Error { From 14b58e5b7b47c5c1692535f412abc6b443055859 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 14 Jun 2026 09:24:51 +1000 Subject: [PATCH 75/83] Some minor cleanup --- packages/event-reduce-react/src/rendering.ts | 10 ++++++---- packages/event-reduce/src/tracking.ts | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/event-reduce-react/src/rendering.ts b/packages/event-reduce-react/src/rendering.ts index 932681c..3ef1dc0 100644 --- a/packages/event-reduce-react/src/rendering.ts +++ b/packages/event-reduce-react/src/rendering.ts @@ -46,10 +46,7 @@ class RenderEffect extends Effect { this._triggerState = process.env.NODE_ENV !== 'production' ? { triggeredBy: observable.path } : { version: ++this._version } - queueTransitionEffect(() => { - for (let subscriber of this._observers) - subscriber() - }) + queueTransitionEffect(this.notifySubscribers.bind(this)) } }) this.attach() @@ -62,6 +59,11 @@ class RenderEffect extends Effect { triggerSnapshot = () => this._triggerState + notifySubscribers() { + for (let subscriber of this._observers) + subscriber() + } + override run(deriveValue: () => T) { let previousValue = this._previousValue let nextValue!: T diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts index 517da0e..381be85 100644 --- a/packages/event-reduce/src/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -20,7 +20,8 @@ export function beginTransition(source: IObservable | Effect, isTrigge return { [dispose]() { - for (let effect = transitionEffectQueue.shift(); effect; effect = transitionEffectQueue.shift()) + let effect: undefined | (() => void) + while (effect = transitionEffectQueue.shift()) effect() iterationSource = null disposeGarbage() From 0601c3d184b483045e40f565d50cdf6045d5e2f6 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 24 Jun 2026 19:44:28 +1000 Subject: [PATCH 76/83] Renaming ObservableValue to Signal and adding signal factory function --- packages/event-reduce-react/src/hooks.ts | 6 +-- packages/event-reduce/src/index.ts | 2 +- packages/event-reduce/src/observableValue.ts | 37 ---------------- packages/event-reduce/src/signal.ts | 45 ++++++++++++++++++++ packages/event-reduce/src/testing.ts | 10 ++--- tests/Cleanup.test.ts | 26 +++++------ tests/Derivation.test.ts | 12 +++--- tests/Observable.test.ts | 6 +-- tests/ObservableValue.test.ts | 23 +++------- tests/Reduction.test.ts | 22 +++++----- tests/Rendering.test.ts | 6 +-- 11 files changed, 96 insertions(+), 99 deletions(-) delete mode 100644 packages/event-reduce/src/observableValue.ts create mode 100644 packages/event-reduce/src/signal.ts diff --git a/packages/event-reduce-react/src/hooks.ts b/packages/event-reduce-react/src/hooks.ts index bd52386..d0d97e7 100644 --- a/packages/event-reduce-react/src/hooks.ts +++ b/packages/event-reduce-react/src/hooks.ts @@ -1,4 +1,4 @@ -import { asyncEvent, derive, event, IDerivation, IReduction, ObservableValue, reduce } from "event-reduce" +import { asyncEvent, derive, event, type IDerivation, type IReduction, reduce, signal } from "event-reduce" import { trackManagedObservableCreation } from "event-reduce/lib/ownership" import { Reduction } from "event-reduce/lib/reduction" import { isModel } from "event-reduce/lib/symbols" @@ -52,8 +52,8 @@ export function useReduced(initial: T, name?: string): IReduction { return reduction } -export function useObservableValue(initialValue: T, name: string = '(anonymous observed value)') { - return useState(() => new ObservableValue(name, initialValue))[0] +export function useSignal(initialValue: T, name: string = '(anonymous signal)') { + return useState(() => signal(initialValue, name))[0] } class InvalidModelError extends Error { diff --git a/packages/event-reduce/src/index.ts b/packages/event-reduce/src/index.ts index 5f7e68e..6b3f028 100644 --- a/packages/event-reduce/src/index.ts +++ b/packages/event-reduce/src/index.ts @@ -5,6 +5,6 @@ export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, export { enableLogging, log, startLogging } from "./logging" export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" export { merge, type IObservable, type IObserver } from "./observable" -export { ObservableValue } from "./observableValue" export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" export { getState, type State } from "./state" +export { signal, type ISignal } from "./signal" diff --git a/packages/event-reduce/src/observableValue.ts b/packages/event-reduce/src/observableValue.ts deleted file mode 100644 index ea3c992..0000000 --- a/packages/event-reduce/src/observableValue.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Derivation } from "./derivation"; -import { updateChildren } from "./ownership"; -import { IObservable, Observable } from "./observable"; -import { getState, State } from "./state"; -import { beginTransition } from "./tracking"; -import { dispose } from "./utils"; - -export class ObservableValue extends Observable implements IObservable { - private _json: Derivation> - - constructor( - displayName: string, - initialValue: T, - private _isEqual = Object.is - ) { - super(displayName); - this._json = new Derivation(`toJSON(${this.displayName})`, () => getState(this.value)) - this._json.suppressLogging = true - updateChildren(this, undefined, initialValue) - this._value = initialValue // Avoid triggering a transition in constructor - } - - toJSON() { return this._json.value; } - - override get value() { return super.value } - - override setValue(value: T) { - let transition = beginTransition(this, true) - try { - super.setValue(value); - } finally { - transition[dispose]() - } - } - - override skipUpdate(nextValue: T) { return this._isEqual(this._value, nextValue); } -} diff --git a/packages/event-reduce/src/signal.ts b/packages/event-reduce/src/signal.ts new file mode 100644 index 0000000..4d15444 --- /dev/null +++ b/packages/event-reduce/src/signal.ts @@ -0,0 +1,45 @@ +import { Derivation } from "./derivation"; +import { IObservable, Observable } from "./observable"; +import { updateChildren } from "./ownership"; +import { toJSON, JSON } from "./state"; +import { beginTransition } from "./tracking"; +import { dispose, isObject } from "./utils"; + +export interface ISignal extends IObservable { + readonly value: T + setValue(newValue: T): void +} + +export function signal(initialValue: T, displayName?: string): ISignal { + return new Signal(displayName ?? "(anonymous value)", initialValue) +} + +export class Signal extends Observable implements IObservable { + private _json: Derivation> + + constructor( + displayName: string, + initialValue: T, + ) { + super(displayName); + this._json = new Derivation(`toJSON(${this.displayName})`, () => toJSON(this.value)) + this._json.suppressLogging = true + updateChildren(this, undefined, initialValue) + this._value = initialValue // Avoid triggering a transition in constructor + } + + toJSON() { return this._json.value } + + override get value() { return super.value } + + override setValue(value: T) { + let transition = beginTransition(this, isObject(value) ? value : { value }) + try { + super.setValue(value) + } finally { + transition[dispose]() + } + } + + override skipUpdate(nextValue: T) { return false } +} diff --git a/packages/event-reduce/src/testing.ts b/packages/event-reduce/src/testing.ts index b5f808a..839e92c 100644 --- a/packages/event-reduce/src/testing.ts +++ b/packages/event-reduce/src/testing.ts @@ -3,7 +3,7 @@ import { Derivation } from "./derivation" import { event } from "./events" import { getModelProperties } from "./models" import { isObservable, Observable } from "./observable" -import { ObservableValue } from "./observableValue" +import { Signal } from "./signal" import { Reduction } from "./reduction" import { beginTransition } from "./tracking" import { dispose, isObject } from "./utils" @@ -52,13 +52,13 @@ export function modelProxy(model: T = {} as T) { if (!isObject(model) || Array.isArray(model)) return model - let properties = {} as Record> + let properties = {} as Record> // Pre-populate observable values with plain value fields Object.entries(Object.getOwnPropertyDescriptors(model)) .forEach(([key, prop]) => { if ('value' in prop) - properties[key] = new ObservableValue(String(key), prop.value) + properties[key] = new Signal(String(key), prop.value) }) let proxy: T = new Proxy(model, { @@ -98,8 +98,8 @@ export function modelProxy(model: T = {} as T) { return proxy - function getOrAddObservableValue(key: PropertyKey): ObservableValue { - return properties[String(key)] ??= new ObservableValue(String(key), model[key as keyof T]) + function getOrAddObservableValue(key: PropertyKey): Signal { + return properties[String(key)] ??= new Signal(String(key), model[key as keyof T]) } } diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index ab4d1c4..afe8ee2 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, spyOn, test, type Mock } from "bun:test" -import { ObservableValue, createModel, derive, derived, event, model, reduce, reduced, state } from "event-reduce" +import { createModel, derive, derived, event, ISignal, model, reduce, reduced, signal, state } from "event-reduce" import { getModelProperties } from "event-reduce/lib/models" import { isModel } from "event-reduce/lib/symbols" import { dispose } from "event-reduce/lib/utils" @@ -39,7 +39,7 @@ describe("subscription cleanup", () => { }) test("when derivation returns a new model, disposes old model", () => { - let observable = new ObservableValue('test Observable', 0) + let observable = signal(0, 'test Observable') let derivation = derive(() => new Model(observable.value)) let modelInstance = derivation.value let unsubscribe = spyOnDispose(modelInstance, 'reducedProp') @@ -51,7 +51,7 @@ describe("subscription cleanup", () => { }) test("when derivation returns the same model, doesn't dispose model", () => { - let observable = new ObservableValue('test Observable', 0) + let observable = signal(0, 'test Observable') let modelInstance = new Model(0) let derivation = derive(() => { observable.value @@ -67,17 +67,17 @@ describe("subscription cleanup", () => { }) describe("when derivation returns a model from another observable value", () => { - let model1: ObservableValue - let model2: ObservableValue - let useModel2: ObservableValue + let model1: ISignal + let model2: ISignal + let useModel2: ISignal let derivation: ReturnType> let modelInstance: Model let disposeModelSpy: Mock<() => void> beforeEach(() => { - model1 = new ObservableValue('model 1', new Model(1)) - model2 = new ObservableValue('model 2', new Model(2)) - useModel2 = new ObservableValue('switch', false) + model1 = signal(new Model(1), 'model 1') + model2 = signal(new Model(2), 'model 2') + useModel2 = signal(false, 'switch') derivation = derive(() => useModel2.value ? model2.value : model1.value) modelInstance = derivation.value disposeModelSpy = spyOnDispose(modelInstance, 'reducedProp') @@ -145,7 +145,7 @@ describe("subscription cleanup", () => { reduce([model1, model2]) .on(removeModel, ms => ms.slice(0, -1))) - new ObservableValue('other value', model2) + signal(model2, 'other value') removeModel() @@ -213,7 +213,7 @@ describe("subscription cleanup", () => { return remaining })) - new ObservableValue('other value', model2) + signal(model2, 'other value') removeModel() @@ -292,7 +292,7 @@ describe("subscription cleanup", () => { @derived get externallyOwned() { return this._ownedModel; } } - let externalOwner = new ObservableValue('external owner', new Model(3)) + let externalOwner = signal(new Model(3), 'external owner') let outerModel = new OuterModel(externalOwner.value) as OuterModel & Disposable let disposeReducedPropValue = spyOnDispose(outerModel.reducedProp, 'reducedProp') let disposeDerivedPropValue = spyOnDispose(outerModel.derivedProp, 'reducedProp') @@ -302,7 +302,7 @@ describe("subscription cleanup", () => { // let disposeDerivedProp = spyOnDispose(outerModel, 'derivedProp') // let disposeExternallyOwnedProp = spyOnDispose(outerModel, 'externallyOwned') - let outerOwner = new ObservableValue('outer owner', outerModel) + let outerOwner = signal(outerModel, 'outer owner') outerOwner.setValue(null!) expect(disposeReducedProp).toHaveBeenCalled() diff --git a/tests/Derivation.test.ts b/tests/Derivation.test.ts index 46bfdca..7edfa91 100644 --- a/tests/Derivation.test.ts +++ b/tests/Derivation.test.ts @@ -1,18 +1,18 @@ import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { derive, event, events, IDerivation, ObservableValue } from "event-reduce"; +import { derive, event, events, signal, type IDerivation, type ISignal } from "event-reduce"; import { InvalidStateError } from "event-reduce/lib/observable"; import { getUnderlyingObservable, SideEffectDuringReactionError } from "event-reduce/lib/tracking"; import { MockObserver } from "./MockObserver"; describe("derive", () => { - let sourceA: ObservableValue; - let sourceB: ObservableValue; + let sourceA: ISignal; + let sourceB: ISignal; let calculation: Mock<() => string>; let sut: IDerivation; beforeEach(() => { - sourceA = new ObservableValue('a', 'a'); - sourceB = new ObservableValue('b', 'b'); + sourceA = signal('a', 'a'); + sourceB = signal('b', 'b'); calculation = mock(() => sourceA.value + sourceB.value); sut = derive(calculation, 'sut'); }); @@ -120,7 +120,7 @@ describe("derive", () => { }); test("derivation that depends on other derivations only updates once", () => { - let observable = new ObservableValue("Observable", "a"); + let observable = signal("a", "Observable"); let inner1 = derive(() => observable.value + "_inner1"); let inner2 = derive(() => observable.value + "_inner2"); let outerCalc = mock(() => inner1.value + inner2.value); diff --git a/tests/Observable.test.ts b/tests/Observable.test.ts index ec540d4..a02c9d8 100644 --- a/tests/Observable.test.ts +++ b/tests/Observable.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { ObservableValue, type IObservable } from "event-reduce"; +import { ISignal, signal, type IObservable } from "event-reduce"; import { MockObserver } from "./MockObserver"; describe("Observable", () => { - let sut: ObservableValue; + let sut: ISignal; let observer: MockObserver; beforeEach(() => { - sut = new ObservableValue('sut', 'initial'); + sut = signal('initial', 'sut'); observer = new MockObserver(); sut.subscribe(observer); }); diff --git a/tests/ObservableValue.test.ts b/tests/ObservableValue.test.ts index d9a49f8..000bd52 100644 --- a/tests/ObservableValue.test.ts +++ b/tests/ObservableValue.test.ts @@ -1,14 +1,14 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { ObservableValue } from "event-reduce"; +import { ISignal, signal } from "event-reduce"; import { getUnderlyingObservable } from "event-reduce/lib/tracking"; import { MockObserver } from "./MockObserver"; -describe("ObservableValue", () => { - let sut: ObservableValue; +describe("signal", () => { + let sut: ISignal; let observer: MockObserver; beforeEach(() => { - sut = new ObservableValue('sut', 'initial'); + sut = signal('initial', 'sut'); observer = new MockObserver(); sut.subscribe(observer); }); @@ -35,20 +35,9 @@ describe("ObservableValue", () => { test("notifies observers", () => expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'different')); }); - test("when value set to same value, observers are not notified", () => { + test("when value set to same value, observers are notified", () => { sut.setValue('initial'); - expect(observer.sourceUpdated).not.toHaveBeenCalled(); - }); - - test("when custom equality treats values as equal, observers are not notified", () => { - let custom = new ObservableValue('custom', 'Alpha', (left, right) => left?.toLowerCase() === right?.toLowerCase()); - let customObserver = new MockObserver(); - custom.subscribe(customObserver); - - custom.setValue('alpha'); - - expect(custom.value).toBe('Alpha'); - expect(customObserver.sourceUpdated).not.toHaveBeenCalled(); + expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'initial'); }); }); \ No newline at end of file diff --git a/tests/Reduction.test.ts b/tests/Reduction.test.ts index c2d5d81..b5fc445 100644 --- a/tests/Reduction.test.ts +++ b/tests/Reduction.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { ObservableValue, createModel, event, events, IReduction, reduce } from 'event-reduce'; -import { CircularSubscriptionError } from "event-reduce/lib/reduction"; +import { createModel, event, events, IReduction, ISignal, reduce, signal } from 'event-reduce'; import { InvalidStateError } from "event-reduce/lib/observable"; +import { CircularSubscriptionError } from "event-reduce/lib/reduction"; import { CircularDependencyError, collectAccessedSources } from "event-reduce/lib/tracking"; import { dispose } from "event-reduce/lib/utils"; import { MockObserver } from "./MockObserver"; @@ -24,11 +24,11 @@ describe("reduce", () => { test("starts with initial value", () => expect(sut.value).toBe(1)); describe("when subscribed to an observable", () => { - let observableValue: ObservableValue; + let observableValue: ISignal; let reducer: Mock<(previous: number, event: string) => number>; beforeEach(() => { - observableValue = new ObservableValue('test', 'test'); + observableValue = signal('test', 'test'); reducer = mock(); sut.on(observableValue, reducer); }); @@ -62,11 +62,11 @@ describe("reduce", () => { describe("when a reducer accesses a reduced value", () => { let other: ReturnType>; - let observable: ObservableValue; + let observable: ISignal; beforeEach(() => { other = createModel(() => reduce(0)); - observable = new ObservableValue('test', 0); + observable = signal(0, 'test'); sut.on(observable, () => other.value); }); @@ -126,12 +126,12 @@ describe("reduce", () => { }) describe("when subscribing to an observable", () => { - let observableValue: ObservableValue; - let getEvent: Mock<(events: typeof eventsObj) => ObservableValue>; + let observableValue: ISignal; + let getEvent: Mock<(events: typeof eventsObj) => ISignal>; let reducer: Mock<(previous: number, event: string) => number>; beforeEach(() => { - observableValue = new ObservableValue('test', 'test'); + observableValue = signal('test', 'test'); getEvent = mock(() => observableValue); reducer = mock(); sut.on(getEvent, reducer); @@ -164,7 +164,7 @@ describe("reduce", () => { test("when reducer returns an event, throws", () => { let eventValue = event('test event'); - let subject = new ObservableValue('test', 'test'); + let subject = signal('test', 'test'); using sut = createModel(() => ({ reduction: reduce(null as any, 'sut') .on(subject, () => eventValue) @@ -184,7 +184,7 @@ describe("reduce", () => { @events class Events { } let eventsValue = new Events(); - let subject = new ObservableValue('test', 'test'); + let subject = signal('test', 'test'); using sut = createModel(() => ({ reduction: reduce(null as any, 'sut') .on(subject, () => eventsValue) diff --git a/tests/Rendering.test.ts b/tests/Rendering.test.ts index 4881736..385237e 100644 --- a/tests/Rendering.test.ts +++ b/tests/Rendering.test.ts @@ -1,16 +1,16 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { ObservableValue } from "event-reduce"; +import { ISignal, signal } from "event-reduce"; import { useReactive } from "event-reduce-react"; describe('useReactive', () => { - let observable: ObservableValue; + let observable: ISignal; let innerBehaviour: () => void; let render: Mock<() => string>; let sut: ReturnType>; beforeEach(() => { - observable = new ObservableValue('test value', 'initial value'); + observable = signal('initial value', 'test value'); innerBehaviour = () => { }; render = mock(() => { innerBehaviour(); From 219fb332a6d142e0aac479027385f529ab72c920 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 24 Jun 2026 19:49:10 +1000 Subject: [PATCH 77/83] Sending model state to dev tools after transition --- packages/event-reduce-react/src/rendering.ts | 2 +- packages/event-reduce/src/derivation.ts | 2 +- packages/event-reduce/src/events.ts | 4 ++-- packages/event-reduce/src/testing.ts | 2 +- packages/event-reduce/src/tracking.ts | 6 ++++-- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/event-reduce-react/src/rendering.ts b/packages/event-reduce-react/src/rendering.ts index 3ef1dc0..c8beb65 100644 --- a/packages/event-reduce-react/src/rendering.ts +++ b/packages/event-reduce-react/src/rendering.ts @@ -70,7 +70,7 @@ class RenderEffect extends Effect { let trigger = this._trigger this._trigger = null - let transition = beginTransition(this, true) + let transition = beginTransition(this, trigger ?? { trigger: 'render' }) let logging = startLogging('⚛️ (render)', this.displayName, emptyArray, () => ({ Trigger: trigger?.path ?? 'render', Previous: loggedReactValue(previousValue), diff --git a/packages/event-reduce/src/derivation.ts b/packages/event-reduce/src/derivation.ts index 4d65caa..3084ee1 100644 --- a/packages/event-reduce/src/derivation.ts +++ b/packages/event-reduce/src/derivation.ts @@ -97,7 +97,7 @@ export class Derivation extends Observable implements IDerivation { let nextValue!: T let trigger = this._derive.invalidatingSource - let transition = beginTransition(this, !iterationSource) // Derivations are always safe to access, and only initiate a transition if necessary + let transition = beginTransition(this, iterationSource ? undefined : trigger ?? { reason: reason ?? '(unknown)' }) // Derivations are always safe to access, and only initiate a transition if necessary let observableCreation = trackManagedObservableCreation() let logging = this.suppressLogging ? nullDisposable : startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ Path: this.path, diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index 3fd6daf..f8577f5 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -3,7 +3,7 @@ import { type IObservable, Observable } from "./observable" import { markEvent } from "./symbols" import { beginTransition } from "./tracking" import { ObservedValue, Scoped } from "./types" -import { dispose, matchesScope, nameOfCallback } from "./utils" +import { dispose, isObject, matchesScope, nameOfCallback } from "./utils" export function event(name: string = "(anonymous event)"): IEvent { return makeEvent({ @@ -247,7 +247,7 @@ export type AdditionalMembersFactory, Member class Event extends Observable { emit(value: T) { - let transition = beginTransition(this, true) + let transition = beginTransition(this, isObject(value) ? value : { value }) try { this.setValue(value) } finally { diff --git a/packages/event-reduce/src/testing.ts b/packages/event-reduce/src/testing.ts index 839e92c..29e700c 100644 --- a/packages/event-reduce/src/testing.ts +++ b/packages/event-reduce/src/testing.ts @@ -24,7 +24,7 @@ export function mutable(model: T): Mutable { property.detach() if (property instanceof Observable) { - let transition = beginTransition(property, true) + let transition = beginTransition(property, isObject(value) ? value : { value }) try { property.setValue(value) } finally { diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts index 381be85..353d47b 100644 --- a/packages/event-reduce/src/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -1,3 +1,4 @@ +import { sendEvent } from "./devtools" import { Effect } from "./effect" import { type IObservable, type IObserver, Observable } from "./observable" import { disposeGarbage } from "./ownership" @@ -10,8 +11,8 @@ let transitionEffectQueue = [] as (() => void)[] let currentAccessTracker: AccessTracker | null = null let lastSourceAccessed: IObservable | null = null -export function beginTransition(source: IObservable | Effect, isTriggerEvent: boolean): Disposable { - if (isTriggerEvent) { +export function beginTransition(source: IObservable | Effect, trigger?: object): Disposable { + if (trigger) { if (iterationSource) throw new SideEffectDuringReactionError(iterationSource, source) @@ -25,6 +26,7 @@ export function beginTransition(source: IObservable | Effect, isTrigge effect() iterationSource = null disposeGarbage() + sendEvent(source.displayName, trigger) } } } else { From 9e7564970210285740229c88c6b856d60de5634c Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 24 Jun 2026 20:23:20 +1000 Subject: [PATCH 78/83] Adding back valueChanged function --- packages/event-reduce/src/index.ts | 1 + packages/event-reduce/src/tracking.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/event-reduce/src/index.ts b/packages/event-reduce/src/index.ts index 6b3f028..e3abe06 100644 --- a/packages/event-reduce/src/index.ts +++ b/packages/event-reduce/src/index.ts @@ -8,3 +8,4 @@ export { merge, type IObservable, type IObserver } from "./observable" export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" export { getState, type State } from "./state" export { signal, type ISignal } from "./signal" +export { valueChanged } from "./tracking" diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts index 353d47b..0747ecc 100644 --- a/packages/event-reduce/src/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -1,6 +1,6 @@ import { sendEvent } from "./devtools" import { Effect } from "./effect" -import { type IObservable, type IObserver, Observable } from "./observable" +import { type IObservable, type IObserver, NoUnderlyingObservableError, Observable } from "./observable" import { disposeGarbage } from "./ownership" import { dispose, nullDisposable } from "./utils" @@ -50,6 +50,14 @@ export function endSourceAccessed(source: IObservable) { lastSourceAccessed = source } +/** Allows subscribing to the changes of the specified observable value. */ +export function valueChanged(observableValue: T) { + let observable = getUnderlyingObservable(observableValue); + if (observable) + return observable; + throw new NoUnderlyingObservableError(observableValue); +} + export function getUnderlyingObservable(value: T): IObservable | null { return lastSourceAccessed && lastSourceAccessed instanceof Observable From b753b7de5f3d78b75a19bcf59aa294d1b1118fae Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Wed, 24 Jun 2026 20:24:36 +1000 Subject: [PATCH 79/83] Exporting functions for getting full state from models --- packages/event-reduce/src/derivation.ts | 18 +++---- packages/event-reduce/src/devtools.ts | 15 +++--- packages/event-reduce/src/index.ts | 4 +- packages/event-reduce/src/models.ts | 4 +- packages/event-reduce/src/reduction.ts | 6 +-- packages/event-reduce/src/signal.ts | 9 ++-- packages/event-reduce/src/state.ts | 46 +++++++++++++---- tests/State.test.ts | 67 +++++++++++++++++-------- 8 files changed, 107 insertions(+), 62 deletions(-) diff --git a/packages/event-reduce/src/derivation.ts b/packages/event-reduce/src/derivation.ts index 3084ee1..6601f72 100644 --- a/packages/event-reduce/src/derivation.ts +++ b/packages/event-reduce/src/derivation.ts @@ -2,7 +2,7 @@ import { Effect } from "./effect" import { startLogging } from "./logging" import { IObservable, IObserver, Observable } from "./observable" import { trackManagedObservableCreation } from "./ownership" -import { getState, State } from "./state" +import { includeDerivedState, ModelJSON, modelJSON } from "./state" import { beginTransition, endSourceAccessed, iterationSource, startSourceAccessed } from "./tracking" import { dispose, emptyArray, nameOfFunction, nullDisposable } from "./utils" @@ -19,16 +19,16 @@ export interface IDerivation extends IObservable, IObserver { export function derive(derive: () => T, displayName?: string): IDerivation export function derive(derive: () => T, valuesEqual: (a: T, b: T) => boolean, displayName?: string): IDerivation -export function derive(derive: () => T, displayNameOrSkip?: string | ((a: T, b: T) => boolean), maybeDisplayName?: string): IDerivation { - let [skipUpdate, displayName] = typeof displayNameOrSkip == 'function' - ? [displayNameOrSkip, maybeDisplayName] - : [Object.is, displayNameOrSkip] - return new Derivation((displayName ?? nameOfFunction(derive)) || Derivation.defaultName, derive, skipUpdate) +export function derive(derive: () => T, displayNameOrValuesEqual?: string | ((a: T, b: T) => boolean), maybeDisplayName?: string): IDerivation { + let [valuesEqual, displayName] = typeof displayNameOrValuesEqual == 'function' + ? [displayNameOrValuesEqual, maybeDisplayName] + : [Object.is, displayNameOrValuesEqual] + return new Derivation((displayName ?? nameOfFunction(derive)) || Derivation.defaultName, derive, valuesEqual) } export class Derivation extends Observable implements IDerivation { private _derive: Effect - private _json?: Derivation> + private _json?: Derivation | undefined> static defaultName = "(anonymous derivation)" suppressLogging = false @@ -47,7 +47,7 @@ export class Derivation extends Observable implements IDerivation { toJSON() { // Lazy init to avoid infinite loop in Derivation constructor if (!this._json) { - this._json = new Derivation(`toJSON(${this.displayName})`, () => getState(this.value)) + this._json = new Derivation(`toJSON(${this.displayName})`, () => includeDerivedState().value ? modelJSON(this.value) : undefined) this._json.suppressLogging = true } return this._json.value @@ -101,7 +101,7 @@ export class Derivation extends Observable implements IDerivation { let observableCreation = trackManagedObservableCreation() let logging = this.suppressLogging ? nullDisposable : startLogging('🔗 (derivation)', this.displayName, emptyArray, () => ({ Path: this.path, - Trigger: trigger?.path ?? '(unknown)', + Trigger: trigger?.path ?? reason ?? '(unknown)', Previous: previousValue, Current: nextValue })) diff --git a/packages/event-reduce/src/devtools.ts b/packages/event-reduce/src/devtools.ts index a9dc735..3c49331 100644 --- a/packages/event-reduce/src/devtools.ts +++ b/packages/event-reduce/src/devtools.ts @@ -1,5 +1,4 @@ -import { getState, State } from "./state" -import { isObject } from "./utils" +import { ModelJSON, modelJSON } from "./state" let modelDevTools = new Map() @@ -8,7 +7,7 @@ export function enableDevTools(model: Model, { restoreStat if (!modelDevTools.has(model) && (window as any).__REDUX_DEVTOOLS_EXTENSION__) { let dev = (window as any).__REDUX_DEVTOOLS_EXTENSION__.connect(options) modelDevTools.set(model, dev) - dev.init(getState(model)) + dev.init(modelJSON(model)) let unsubscribe = dev.subscribe((event: any) => { if (event.type == 'DISPATCH') restoreState?.(model, JSON.parse(event.state)) @@ -22,16 +21,14 @@ export function enableDevTools(model: Model, { restoreStat return () => { } } -export function sendEvent(name: string, arg: any) { +export function sendEvent(name: string, arg: object) { if (process.env.NODE_ENV !== 'production') { if (modelDevTools.size) { - let event = isObject(arg) - ? getState(arg) - : { value: arg } + let event = modelJSON(arg) as any if ('type' in event) event['.type'] = event.type event.type = name - modelDevTools.forEach((dev, model) => dev.send(event, getState(model))) + modelDevTools.forEach((dev, model) => dev.send(event, modelJSON(model))) } } } @@ -50,7 +47,7 @@ export interface IDevToolsOptions { * Callback to restore state, used when time traveling or importing state from a file. Takes the deserialized state as an argument. * event-reduce specific */ - restoreState?: (model: Model, state: State) => void + restoreState?: (model: Model, state: ModelJSON) => void /** * In ms. If more than one action is dispatched in the indicated interval, all new actions will be collected and sent at once. diff --git a/packages/event-reduce/src/index.ts b/packages/event-reduce/src/index.ts index e3abe06..4df2787 100644 --- a/packages/event-reduce/src/index.ts +++ b/packages/event-reduce/src/index.ts @@ -1,11 +1,11 @@ export { derive, type IDerivation } from "./derivation" export { enableDevTools } from "./devtools" export { Effect } from "./effect" -export { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IAsyncObservable, type IEvent } from "./events" +export { asyncEvent, event, makeEvent, makePhasedEvent, type AsyncError, type AsyncResult, type AsyncStart, type AsyncValue, type IAsyncEvent, type IAsyncObservable, type IEvent } from "./events" export { enableLogging, log, startLogging } from "./logging" export { createModel, derived, disposeModel, events, model, reduced, state } from "./models" export { merge, type IObservable, type IObserver } from "./observable" export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction" -export { getState, type State } from "./state" export { signal, type ISignal } from "./signal" +export { getState, stringifyState, type State } from "./state" export { valueChanged } from "./tracking" diff --git a/packages/event-reduce/src/models.ts b/packages/event-reduce/src/models.ts index 1c70ca1..eaa1b37 100644 --- a/packages/event-reduce/src/models.ts +++ b/packages/event-reduce/src/models.ts @@ -2,7 +2,7 @@ import { Derivation, derive, IDerivation } from "./derivation" import { IObservable, isObservable, Observable } from "./observable" import { getChildNodes, trackManagedObservableCreation, updateChildren } from "./ownership" import { IReduction, Reduction } from "./reduction" -import { getState } from "./state" +import { modelJSON } from "./state" import { EventsClass, isEvent, isEventsClass, isModel, markEventsClass, modelBrand, modelProperties, statePropertyBrand } from "./symbols" import { collectAccessedSources, getUnderlyingObservable } from "./tracking" import { ObservedValue } from "./types" @@ -401,7 +401,7 @@ export class StateProperty implements Disposable { updateChildren(_instance, null, this.value) } - toJSON() { return getState(this.value) } + toJSON() { return modelJSON(this.value) } get value() { return this._instance[this.key] } diff --git a/packages/event-reduce/src/reduction.ts b/packages/event-reduce/src/reduction.ts index 12c6aee..87e4675 100644 --- a/packages/event-reduce/src/reduction.ts +++ b/packages/event-reduce/src/reduction.ts @@ -2,7 +2,7 @@ import { Derivation } from "./derivation" import { startLogging } from "./logging" import { allSources, IObservable, IObserver, isObservable, NoUnderlyingObservableError, Observable } from "./observable" import { managedObservableCreated, updateChildren } from "./ownership" -import { getState, State } from "./state" +import { ModelJSON, modelJSON } from "./state" import { collectAccessedSources, currentIteration, endSourceAccessed, getUnderlyingObservable, startSourceAccessed } from "./tracking" import { dispose, emptyArray } from "./utils" @@ -37,7 +37,7 @@ export class Reduction extends Observable implements IReduc private _singleReducer?: (current: T, next: any) => T private _reducers = new Map, (current: T, next: any) => T>() private _sourcesLastChecked = -1 - private _json: Derivation> + private _json: Derivation> static defaultName = "(anonymous reduction)" constructor( @@ -48,7 +48,7 @@ export class Reduction extends Observable implements IReduc ) { super(displayName) - this._json = new Derivation(`toJSON(${this.displayName})`, () => getState(this.value)) + this._json = new Derivation(`toJSON(${this.displayName})`, () => modelJSON(this.value)) this._json.suppressLogging = true managedObservableCreated(this) diff --git a/packages/event-reduce/src/signal.ts b/packages/event-reduce/src/signal.ts index 4d15444..bef6bc3 100644 --- a/packages/event-reduce/src/signal.ts +++ b/packages/event-reduce/src/signal.ts @@ -1,7 +1,7 @@ import { Derivation } from "./derivation"; import { IObservable, Observable } from "./observable"; import { updateChildren } from "./ownership"; -import { toJSON, JSON } from "./state"; +import { ModelJSON, modelJSON } from "./state"; import { beginTransition } from "./tracking"; import { dispose, isObject } from "./utils"; @@ -15,17 +15,18 @@ export function signal(initialValue: T, displayName?: string): ISignal { } export class Signal extends Observable implements IObservable { - private _json: Derivation> + private _json: Derivation> constructor( displayName: string, initialValue: T, ) { super(displayName); - this._json = new Derivation(`toJSON(${this.displayName})`, () => toJSON(this.value)) + this._json = new Derivation(`toJSON(${this.displayName})`, () => modelJSON(this.value)) this._json.suppressLogging = true + + this._value = initialValue updateChildren(this, undefined, initialValue) - this._value = initialValue // Avoid triggering a transition in constructor } toJSON() { return this._json.value } diff --git a/packages/event-reduce/src/state.ts b/packages/event-reduce/src/state.ts index 3aaa7e8..d769565 100644 --- a/packages/event-reduce/src/state.ts +++ b/packages/event-reduce/src/state.ts @@ -1,11 +1,35 @@ -import { isObject } from "./utils" - -export type State = T extends { toJSON(): infer S } - ? State - : T - -export function getState(model: T): State { - return isObject(model) && typeof (model as any).toJSON == 'function' - ? (model as any).toJSON() - : model as State -} \ No newline at end of file +import { ISignal, signal } from "./signal" +import { isObject } from "./utils" + +export type State = T extends { toJSON(): infer S } + ? State + : (T extends object + ? { [K in keyof T]: State } + : T) + +export function getState(model: T, includeDerived = false): State { + return JSON.parse(stringifyState(model, includeDerived)) +} + +export function stringifyState(model: T, includeDerived = false): string { + let config = includeDerivedState() + if (config.value != includeDerived) + config.setValue(includeDerived) + return JSON.stringify(model) +} + +let includeDerivedStateSignal: ISignal | undefined + +export function includeDerivedState() { + return includeDerivedStateSignal ??= signal(false, 'includeDerivedState') +} + +export type ModelJSON = T extends { toJSON(): infer S } + ? ModelJSON + : T + +export function modelJSON(model: T): ModelJSON { + return isObject(model) && typeof (model as any).toJSON == 'function' + ? (model as any).toJSON() + : model as ModelJSON +} diff --git a/tests/State.test.ts b/tests/State.test.ts index 56fb064..3206aa6 100644 --- a/tests/State.test.ts +++ b/tests/State.test.ts @@ -1,22 +1,45 @@ -import { describe, expect, test } from "bun:test"; -import { getState } from "event-reduce"; -import { TestModel } from "./State.test/TestModel"; - -describe("state", () => { - test("getState copies the correct properties", () => { - let testModel = new TestModel('ctor'); - let result = getState(testModel); - expect(JSON.stringify(result)).toBe(JSON.stringify({ - valueProperty: 1, - valuePlusOne: 2, - reducedModel: { value: 'child' }, - modelArray: [ - { value: 'one' }, - { value: 'two' } - ], - mergedModel: { value: 'merged' }, - subClassProperty: 'subClass', - constructorProperty: 'ctor', - })); - }); -}); +import { describe, expect, test } from "bun:test"; +import { getState, stringifyState } from "event-reduce"; +import { TestModel } from "./State.test/TestModel"; + +describe("state", () => { + test("getState copies the correct properties", () => { + let testModel = new TestModel('ctor'); + let result = getState(testModel); + expect(JSON.stringify(result)).toBe(JSON.stringify({ + valueProperty: 1, + reducedModel: { value: 'child' }, + modelArray: [ + { value: 'one' }, + { value: 'two' } + ], + mergedModel: { value: 'merged' }, + subClassProperty: 'subClass', + constructorProperty: 'ctor', + })); + }); + + test("stringifyState can include derived properties", () => { + let testModel = new TestModel('ctor'); + let result = stringifyState(testModel, true); + expect(result).toBe(JSON.stringify({ + valueProperty: 1, + valuePlusOne: 2, + reducedModel: { value: 'child' }, + modelArray: [ + { value: 'one' }, + { value: 'two' } + ], + mergedModel: { value: 'merged' }, + subClassProperty: 'subClass', + constructorProperty: 'ctor', + })); + }); + + test("stringifyState includeDerived is not cached between calls", () => { + let testModel = new TestModel('ctor'); + stringifyState(testModel); + let result = stringifyState(testModel, true); + expect(result).toContain('"valuePlusOne":2'); + }); +}); From bc58279c5ae56a28a837933da3acf79e90454cb6 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Fri, 26 Jun 2026 16:01:13 +1000 Subject: [PATCH 80/83] Utils cleanup --- packages/event-reduce/src/events.ts | 6 ++-- packages/event-reduce/src/index.ts | 1 + packages/event-reduce/src/utils.ts | 51 ----------------------------- 3 files changed, 4 insertions(+), 54 deletions(-) diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index f8577f5..4de5636 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -3,7 +3,7 @@ import { type IObservable, Observable } from "./observable" import { markEvent } from "./symbols" import { beginTransition } from "./tracking" import { ObservedValue, Scoped } from "./types" -import { dispose, isObject, matchesScope, nameOfCallback } from "./utils" +import { dispose, isObject, matchesScope, nameOfCallback, scopedName } from "./utils" export function event(name: string = "(anonymous event)"): IEvent { return makeEvent({ @@ -20,7 +20,7 @@ export function event(name: string = "(anonymous event)"): IEvent { additionalMembers: (e: IEventFunction) => ({ scope>(scope: Scope): IEvent, T> { return bindEvent(e, - `${e.displayName}.scoped(${JSON.stringify(scope)})`, + scopedName(e.displayName, scope), (value: Scoped) => e({ ...value, ...scope } as In), matchesScope(scope)) as IEvent, T> } @@ -104,7 +104,7 @@ export function makePhasedEvent< ), scope : never>(scope: Scope): IPhasedEvent, ScopedArgs>, Return, CurrentContext & Scope> { return bindEvent(e, - `${e.displayName}.scoped(${JSON.stringify(scope)})`, + scopedName(e.displayName, scope), (...args: ScopedArgs>) => { let argsWithScope = args.slice() argsWithScope[argsWithScope.length - 1] = { ...argsWithScope[argsWithScope.length - 1], ...scope } diff --git a/packages/event-reduce/src/index.ts b/packages/event-reduce/src/index.ts index 4df2787..87fe428 100644 --- a/packages/event-reduce/src/index.ts +++ b/packages/event-reduce/src/index.ts @@ -9,3 +9,4 @@ export { extendReduce, reduce, type IReduction, type Reducer } from "./reduction export { signal, type ISignal } from "./signal" export { getState, stringifyState, type State } from "./state" export { valueChanged } from "./tracking" +export { matchesScope, scopedName } from "./utils" diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index 5ea2c19..9ad640b 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -1,15 +1,10 @@ export const emptyArray = Object.freeze([]) as readonly any[] -export const emptySet = Object.freeze(new Set()) as ReadonlySet export const emptyMap = Object.freeze(new Map()) as ReadonlyMap export function constant(value: T) { return identity.bind(null, value) as () => T; } export function identity(value: T) { return value; } -export function filteredName(baseName: string, predicate: Function) { - return `${baseName}.filter(${nameOfCallback(predicate)})` -} - export function scopedName(baseName: string, scope: object) { return `${baseName}.scoped({ ${Object.entries(scope).map(([k, v]) => `${k}: ${v}`).join(', ')} })` } @@ -24,26 +19,6 @@ export function nameOfFunction(fn: Function) { return (fn as any).displayName as string || fn.name } -export function jsonPath(keys: readonly PropertyKey[]) { - return keys.map((key, i) => - i == 0 ? key - : typeof key == 'number' || typeof key == 'symbol' ? `[${String(key)}]` - : /^[\w\$]+$/.test(key) ? `.${key}` - : `[${JSON.stringify(key)}]`) - .join('') -} - -/** Utility base class for anything with a dynamic displayName */ -export class NamedBase { - constructor(private _getDisplayName: () => string) { } - - get displayName() { return this._getDisplayName(); } - set displayName(name: string) { this._getDisplayName = constant(name); } - - /** Just for avoiding unnecessary arrow functions, but importantly reflects changes to the _getDisplayName callback. */ - protected readonly displayNameGetter = () => this._getDisplayName() -} - export function matchesScope(scope: Scope): (value: Value) => boolean export function matchesScope(scope: Scope, value: Value): boolean export function matchesScope(scope: Scope, value?: Value) { @@ -69,11 +44,6 @@ export function isFunction(value: any): value is Function { return typeof value == 'function' } -export function isPlainObject(value: T): value is T & object { - return isObject(value) - && Object.getPrototypeOf(value) == Object.prototype -} - export function isObject(value: T): value is T & object { return !!value && typeof value == 'object' } @@ -86,12 +56,6 @@ export function isUndefined(value: any): value is undefined { return value === undefined } -export function getOrAdd(target: any, key: string | symbol, create: (base: T | undefined) => T): T { - return target.hasOwnProperty(key) - ? target[key] - : (target[key] = create(target[key])) -} - export function getOrAddWeakEntry(cache: WeakMap, key: K, create: (key: K) => V): V { let cached = cache.get(key) if (!cached) @@ -99,12 +63,6 @@ export function getOrAddWeakEntry(cache: WeakMap, ke return cached } -export function firstIntersection(a: Set, b: Set) { - for (let item of a) - if (b.has(item)) - return item -} - export function getAllPropertyDescriptors(obj: object) { let props = new Map() for (let proto of getPrototypeChain(obj)) @@ -129,12 +87,3 @@ export function isDisposable(value: any): value is Disposable { export const dispose: typeof Symbol.dispose = Symbol.dispose ?? Symbol('Symbol.dispose') export const nullDisposable: Disposable = Object.freeze({ [dispose]() { } }) - -/** Shim for `using` declaration until it's properly supported. */ -export function using(disposable: Resource, action: (resource: Resource) => void) { - try { - action(disposable) - } finally { - disposable[dispose]() - } -} \ No newline at end of file From b3b5fc47a055e53b17603677282764e37dedb540 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Fri, 26 Jun 2026 16:01:24 +1000 Subject: [PATCH 81/83] Bumping version --- packages/event-reduce-react/package.json | 2 +- packages/event-reduce/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/event-reduce-react/package.json b/packages/event-reduce-react/package.json index 91036bc..b6b351a 100644 --- a/packages/event-reduce-react/package.json +++ b/packages/event-reduce-react/package.json @@ -1,7 +1,7 @@ { "name": "event-reduce-react", "description": "React integration for event-reduce: state management based on reducing observable events into state", - "version": "0.7.5", + "version": "0.8.0", "author": "Simon Oxtoby", "homepage": "https://github.com/soxtoby/event-reduce", "repository": { diff --git a/packages/event-reduce/package.json b/packages/event-reduce/package.json index 276fd67..b38ba25 100644 --- a/packages/event-reduce/package.json +++ b/packages/event-reduce/package.json @@ -1,7 +1,7 @@ { "name": "event-reduce", "description": "State management based on reducing observable events into state", - "version": "0.7.5", + "version": "0.8.0", "author": "Simon Oxtoby", "homepage": "https://github.com/soxtoby/event-reduce", "repository": { From 8e8f5f03bebce54e2801af1d725f3e9a8256d92f Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 27 Jun 2026 09:40:20 +1000 Subject: [PATCH 82/83] Updating packages Fixing LegacyState test --- .github/workflows/ci.yml | 2 +- bun.lock | 72 ++++++++++++------------ bunfig.toml | 5 +- example/package.json | 10 ++-- package.json | 2 +- packages/event-reduce-react/package.json | 10 ++-- packages/event-reduce/package.json | 2 +- tests-legacy/LegacyState.test.ts | 1 - tests-legacy/package.json | 36 ++++++------ tests/State.test.ts | 8 +-- tests/package.json | 10 ++-- 11 files changed, 79 insertions(+), 79 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ce4f8f..526a4a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: diff --git a/bun.lock b/bun.lock index 59c0a77..83e6552 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "event-reduce-root", "devDependencies": { - "typescript": "~5.9.3", + "typescript": "~6.0.3", }, }, "example": { @@ -14,37 +14,37 @@ "dependencies": { "event-reduce": "workspace:*", "event-reduce-react": "workspace:*", - "react": ">=16.8", - "react-dom": ">=16.8", + "react": ">=19.2.7", + "react-dom": ">=19.2.7", }, "devDependencies": { - "@types/bun": "*", - "@types/react": ">=16.8", + "@types/bun": "1.3.14", + "@types/react": ">=19.2.17", "@types/react-dom": ">=16.8", - "typescript": "~5", + "typescript": "~6.0.3", }, }, "packages/event-reduce": { "name": "event-reduce", - "version": "0.7.5", + "version": "0.8.0", "devDependencies": { - "typescript": "~5", + "typescript": "~6.0.3", }, }, "packages/event-reduce-react": { "name": "event-reduce-react", - "version": "0.7.5", + "version": "0.8.0", "dependencies": { "event-reduce": "workspace:^", - "use-sync-external-store": "*", + "use-sync-external-store": "1.6.0", }, "devDependencies": { - "@types/react": ">=16.8", - "@types/use-sync-external-store": "*", - "typescript": "~5", + "@types/react": ">=19.2.17", + "@types/use-sync-external-store": "1.5.0", + "typescript": "~6.0.3", }, "peerDependencies": { - "react": ">=16.8", + "react": ">=19.2.7", }, }, "tests": { @@ -56,13 +56,13 @@ "event-reduce-react": "workspace:*", }, "devDependencies": { - "@happy-dom/global-registrator": "^20.8.9", + "@happy-dom/global-registrator": "^20.10.6", "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.14", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-test-renderer": "^19.2.4", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-test-renderer": "^19.2.7", "typescript": "~6.0.2", }, }, @@ -74,7 +74,7 @@ "event-reduce-react": "*", }, "devDependencies": { - "typescript": "~5", + "typescript": "~6.0.3", }, }, }, @@ -90,7 +90,7 @@ "@babel/runtime": ["@babel/runtime@7.18.3", "", { "dependencies": { "regenerator-runtime": "^0.13.4" } }, "sha512-38Y8f7YUhce/K7RMwTp7m0uCumpv9hZkitCbBClqQIow1qSbCvGkcegKOXpEWCQLfWmevgRiWokZ1GkpfhbZug=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.8.9" } }, "sha512-DtZeRRHY9A/bisTJziUBBPrdnPui7+R185G/hzi6/Boymhqh7/wi53AY+IvQHS1+7OPaqfO/1XNpngNwthLz+A=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.10.6" } }, "sha512-Nu/IjRkkNxmeG2ywWsyJSO4d1BrWTqVzxCPL+gXj0b97klhmjd6wLzt6Bx/laNpkZ3WLW7zNCqmtMIbIlahqug=="], "@testing-library/dom": ["@testing-library/dom@8.13.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^4.2.0", "aria-query": "^5.0.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.4.4", "pretty-format": "^27.0.2" } }, "sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ=="], @@ -98,15 +98,15 @@ "@types/aria-query": ["@types/aria-query@4.2.2", "", {}, "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig=="], - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.3", "", {}, "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@1.5.0", "", {}, "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw=="], "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], @@ -118,7 +118,9 @@ "aria-query": ["aria-query@5.0.0", "", {}, "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg=="], - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -144,7 +146,7 @@ "event-reduce-tests-legacy": ["event-reduce-tests-legacy@workspace:tests-legacy"], - "happy-dom": ["happy-dom@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA=="], + "happy-dom": ["happy-dom@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -154,13 +156,13 @@ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - "react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="], + "react-is": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="], - "react-test-renderer": ["react-test-renderer@19.2.4", "", { "dependencies": { "react-is": "^19.2.4", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-Ttl5D7Rnmi6JGMUpri4UjB4BAN0FPs4yRDnu2XSsigCWOLm11o8GwRlVsh27ER+4WFqsGtrBuuv5zumUaRCmKw=="], + "react-test-renderer": ["react-test-renderer@19.2.7", "", { "dependencies": { "react-is": "^19.2.7", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-U4TyPDJ9MsC8rFimXuJum8w40aPc9kbOZYO8Pc2/4A884i8hwJsMNA/JNyuOc/f2/37wHvk7HjpVl1V4re7Dig=="], "regenerator-runtime": ["regenerator-runtime@0.13.9", "", {}, "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="], @@ -168,22 +170,18 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "use-sync-external-store": ["use-sync-external-store@1.2.0", "", {}, "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], - "bun-types/@types/node": ["@types/node@17.0.40", "", {}, "sha512-UXdBxNGqTMtm7hCwh9HtncFVLrXoqA3oJW30j6XWp5BH/wu3mVeaxo7cq5benFdBw34HB3XDT2TRPI7rXZ+mDg=="], - - "event-reduce-tests/typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], - "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], diff --git a/bunfig.toml b/bunfig.toml index 7ecd9d8..4dbbd1c 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,4 +1,7 @@ +[install] +minimumReleaseAge = 604800 # 1 week + [test] root = "tests" pathIgnorePatterns = ["**/dist/**"] -preload = ["./tests/setup.ts"] +preload = ["./tests/setup.ts"] \ No newline at end of file diff --git a/example/package.json b/example/package.json index e5c74a0..9546c12 100644 --- a/example/package.json +++ b/example/package.json @@ -12,14 +12,14 @@ "dependencies": { "event-reduce": "workspace:*", "event-reduce-react": "workspace:*", - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=19.2.7", + "react-dom": ">=19.2.7" }, "devDependencies": { - "@types/bun": "*", - "@types/react": ">=16.8", + "@types/bun": "1.3.14", + "@types/react": ">=19.2.17", "@types/react-dom": ">=16.8", - "typescript": "~5" + "typescript": "~6.0.3" }, "browserslist": [ "last 2 Chrome versions" diff --git a/package.json b/package.json index eee249b..132b020 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "example": "bun example/serve.ts" }, "devDependencies": { - "typescript": "~5.9.3" + "typescript": "~6.0.3" }, "resolutions": { "deasync": "0.1.30" diff --git a/packages/event-reduce-react/package.json b/packages/event-reduce-react/package.json index b6b351a..048f864 100644 --- a/packages/event-reduce-react/package.json +++ b/packages/event-reduce-react/package.json @@ -34,14 +34,14 @@ ], "dependencies": { "event-reduce": "workspace:^", - "use-sync-external-store": "*" + "use-sync-external-store": "1.6.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=19.2.7" }, "devDependencies": { - "@types/react": ">=16.8", - "@types/use-sync-external-store": "*", - "typescript": "~5" + "@types/react": ">=19.2.17", + "@types/use-sync-external-store": "1.5.0", + "typescript": "~6.0.3" } } diff --git a/packages/event-reduce/package.json b/packages/event-reduce/package.json index b38ba25..4be9881 100644 --- a/packages/event-reduce/package.json +++ b/packages/event-reduce/package.json @@ -33,6 +33,6 @@ "src" ], "devDependencies": { - "typescript": "~5" + "typescript": "~6.0.3" } } diff --git a/tests-legacy/LegacyState.test.ts b/tests-legacy/LegacyState.test.ts index b874044..4bd7615 100644 --- a/tests-legacy/LegacyState.test.ts +++ b/tests-legacy/LegacyState.test.ts @@ -58,7 +58,6 @@ describe("legacy state", () => { { value: 'two' } ], subClassProperty: 'subClass', - valuePlusOne: 2, mergedModel: { value: 'merged' }, constructorProperty: 'ctor' })); diff --git a/tests-legacy/package.json b/tests-legacy/package.json index a1fe721..2a00f87 100644 --- a/tests-legacy/package.json +++ b/tests-legacy/package.json @@ -1,19 +1,19 @@ -{ - "name": "event-reduce-tests-legacy", - "version": "1.0.0", - "license": "MIT", - "private": true, - "author": "Simon Oxtoby", - "homepage": "https://github.com/soxtoby/event-reduce", - "repository": { - "type": "git", - "url": "https://github.com/soxtoby/event-reduce.git" - }, - "dependencies": { - "event-reduce": "*", - "event-reduce-react": "*" - }, - "devDependencies": { - "typescript": "~5" - } +{ + "name": "event-reduce-tests-legacy", + "version": "1.0.0", + "license": "MIT", + "private": true, + "author": "Simon Oxtoby", + "homepage": "https://github.com/soxtoby/event-reduce", + "repository": { + "type": "git", + "url": "https://github.com/soxtoby/event-reduce.git" + }, + "dependencies": { + "event-reduce": "*", + "event-reduce-react": "*" + }, + "devDependencies": { + "typescript": "~6.0.3" + } } \ No newline at end of file diff --git a/tests/State.test.ts b/tests/State.test.ts index 3206aa6..adecffe 100644 --- a/tests/State.test.ts +++ b/tests/State.test.ts @@ -19,10 +19,10 @@ describe("state", () => { })); }); - test("stringifyState can include derived properties", () => { + test("getState can include derived properties", () => { let testModel = new TestModel('ctor'); - let result = stringifyState(testModel, true); - expect(result).toBe(JSON.stringify({ + let result = getState(testModel, true); + expect(JSON.stringify(result)).toBe(JSON.stringify({ valueProperty: 1, valuePlusOne: 2, reducedModel: { value: 'child' }, @@ -36,7 +36,7 @@ describe("state", () => { })); }); - test("stringifyState includeDerived is not cached between calls", () => { + test("includeDerived is not cached between calls", () => { let testModel = new TestModel('ctor'); stringifyState(testModel); let result = stringifyState(testModel, true); diff --git a/tests/package.json b/tests/package.json index 699016b..0765b57 100644 --- a/tests/package.json +++ b/tests/package.json @@ -15,13 +15,13 @@ "event-reduce-example": "workspace:*" }, "devDependencies": { - "@happy-dom/global-registrator": "^20.8.9", + "@happy-dom/global-registrator": "^20.10.6", "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.14", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-test-renderer": "^19.2.4", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-test-renderer": "^19.2.7", "typescript": "~6.0.2" } } \ No newline at end of file From e1e4661064aa2b5a6d72f928b4b8e7c78631bcf0 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sat, 27 Jun 2026 09:58:24 +1000 Subject: [PATCH 83/83] More cleanup Removing remaining semicolons Re-enabling commented-out tests React tests clean up rendered output --- example/Counter.tsx | 28 +- example/CounterList.tsx | 26 +- example/serve.ts | 6 +- packages/event-reduce-react/src/index.ts | 4 +- packages/event-reduce-react/src/rendering.ts | 2 +- packages/event-reduce/src/events.ts | 6 +- packages/event-reduce/src/logging.ts | 44 +-- packages/event-reduce/src/observable.ts | 8 +- packages/event-reduce/src/reduction.ts | 6 +- packages/event-reduce/src/signal.ts | 14 +- packages/event-reduce/src/tracking.ts | 6 +- packages/event-reduce/src/types.ts | 2 +- packages/event-reduce/src/utils.ts | 4 +- tests-legacy/LegacyModel.test.ts | 6 +- tests-legacy/LegacyState.test.ts | 34 +- tests/Cleanup.test.ts | 26 +- tests/Derivation.test.ts | 162 ++++---- tests/Event.test.ts | 386 +++++++++---------- tests/Example.test.tsx | 212 +++++----- tests/Hooks.test.ts | 126 +++--- tests/MockObserver.ts | 4 +- tests/Model.test.ts | 196 +++++----- tests/Model.test/ExtendedModel.ts | 4 +- tests/Model.test/TestModel.ts | 14 +- tests/Observable.test.ts | 80 ++-- tests/ObservableValue.test.ts | 50 +-- tests/Reduction.test.ts | 216 +++++------ tests/Rendering.test.ts | 52 +-- tests/State.test.ts | 34 +- tests/State.test/BaseModel.ts | 16 +- tests/State.test/ChildModel.ts | 4 +- tests/State.test/TestModel.ts | 6 +- tests/Testing.test.ts | 2 +- 33 files changed, 895 insertions(+), 891 deletions(-) diff --git a/example/Counter.tsx b/example/Counter.tsx index 92d9246..8e9da46 100644 --- a/example/Counter.tsx +++ b/example/Counter.tsx @@ -1,16 +1,16 @@ -import { reactive } from "event-reduce-react"; -import { asyncEvent, derived, events, model, reduce, reduced } from "event-reduce"; -import { CounterListEvents } from "./CounterList"; +import { reactive } from "event-reduce-react" +import { asyncEvent, derived, events, model, reduce, reduced } from "event-reduce" +import { CounterListEvents } from "./CounterList" @events export class CounterEvents { constructor(private _parent: CounterListEvents, private _scope: { id: number }) { } - get incremented() { return this._parent.incremented.scope(this._scope); } - get decremented() { return this._parent.decremented.scope(this._scope); } - get reset() { return this._parent.reset.scope(this._scope); } - get removed() { return this._parent.counterRemoved.scope(this._scope); } - valueFetched = asyncEvent(); + get incremented() { return this._parent.incremented.scope(this._scope) } + get decremented() { return this._parent.decremented.scope(this._scope) } + get reset() { return this._parent.reset.scope(this._scope) } + get removed() { return this._parent.counterRemoved.scope(this._scope) } + valueFetched = asyncEvent() } @model @@ -20,7 +20,7 @@ export class CounterModel { private _initial: { id: number, count?: number } ) { } - get id() { return this._initial.id; } + get id() { return this._initial.id } @reduced get count() { @@ -29,15 +29,15 @@ export class CounterModel { .on(e => e.decremented, (current) => current - 1) .on(e => e.reset, () => 0) .on(e => e.valueFetched.resolved, (_, { result }) => result) - .value; + .value } @derived - get countTimesTwo() { return this.count * 2; } + get countTimesTwo() { return this.count * 2 } } export const Counter = reactive(function Counter({ model }: { model: CounterModel }) { - let events = model.events; + let events = model.events return

@@ -64,6 +64,6 @@ export const Counter = reactive(function Counter({ model }: { model: CounterMode function onFetch() { - events.valueFetched(Promise.resolve(100)); + events.valueFetched(Promise.resolve(100)) } -}); +}) diff --git a/example/CounterList.tsx b/example/CounterList.tsx index 4541357..ce0edac 100644 --- a/example/CounterList.tsx +++ b/example/CounterList.tsx @@ -1,17 +1,17 @@ -import { reactive } from "event-reduce-react"; -import { event, events, model, reduce, reduced } from "event-reduce"; -import { Counter, CounterEvents, CounterModel } from "./Counter"; +import { reactive } from "event-reduce-react" +import { event, events, model, reduce, reduced } from "event-reduce" +import { Counter, CounterEvents, CounterModel } from "./Counter" @events export class CounterListEvents { - counterAdded = event(); - counterRemoved = event<{ id: number }>(); - incremented = event<{ id: number }>(); - decremented = event<{ id: number }>(); - reset = event<{ id: number }>(); + counterAdded = event() + counterRemoved = event<{ id: number }>() + incremented = event<{ id: number }>() + decremented = event<{ id: number }>() + reset = event<{ id: number }>() } -let uid = 1; +let uid = 1 @model export class CounterListModel { @@ -22,19 +22,19 @@ export class CounterListModel { return reduce([] as CounterModel[], this.events) .on(e => e.counterAdded, (cs) => cs.concat(this.createCounter({ id: uid++ }))) .on(e => e.counterRemoved, (cs, { id }) => cs.filter(c => c.id != id)) - .value; + .value } private createCounter(initial: { id: number, count?: number }) { - return new CounterModel(new CounterEvents(this.events, { id: initial.id }), initial); + return new CounterModel(new CounterEvents(this.events, { id: initial.id }), initial) } } -export const CounterList = reactive(function CounterList({ model }: { model: CounterListModel; }) { +export const CounterList = reactive(function CounterList({ model }: { model: CounterListModel }) { return <>
{model.counters.map(c => )} -}); +}) diff --git a/example/serve.ts b/example/serve.ts index 1bf1c96..0726cab 100644 --- a/example/serve.ts +++ b/example/serve.ts @@ -1,4 +1,4 @@ -import index from "./index.html"; +import index from "./index.html" let server = Bun.serve({ port: 1234, @@ -7,6 +7,6 @@ let server = Bun.serve({ hmr: true, console: true } -}); +}) -console.log(`🚀 Server running at ${server.url}`); \ No newline at end of file +console.log(`🚀 Server running at ${server.url}`) \ No newline at end of file diff --git a/packages/event-reduce-react/src/index.ts b/packages/event-reduce-react/src/index.ts index 4798efb..40e0569 100644 --- a/packages/event-reduce-react/src/index.ts +++ b/packages/event-reduce-react/src/index.ts @@ -1,2 +1,2 @@ -export * from "./hooks"; -export * from "./rendering"; \ No newline at end of file +export * from "./hooks" +export * from "./rendering" \ No newline at end of file diff --git a/packages/event-reduce-react/src/rendering.ts b/packages/event-reduce-react/src/rendering.ts index c8beb65..d5b57d9 100644 --- a/packages/event-reduce-react/src/rendering.ts +++ b/packages/event-reduce-react/src/rendering.ts @@ -102,7 +102,7 @@ function loggedReactValue(value: unknown) { let type = reactElementName(node.type) ?.split('(').at(-1)!.split(')')[0] // Unwrap HOC names .replace(/^[0-9-]/, '_$&') // Escape leading number or dash - .replace(/[^a-zA-Z0-9:_.-]/g, '_'); // Escape rest of element name + .replace(/[^a-zA-Z0-9:_.-]/g, '_') // Escape rest of element name let el = xmlDoc.createElement(type || ':unknown:') for (let child of Children.toArray((node.props as any).children)) el.appendChild(xmlTree(child)) diff --git a/packages/event-reduce/src/events.ts b/packages/event-reduce/src/events.ts index 4de5636..e18b756 100644 --- a/packages/event-reduce/src/events.ts +++ b/packages/event-reduce/src/events.ts @@ -173,9 +173,9 @@ function makeObservableFunction, Args extend ): IEventFunction, Args, Return> & Members { let eventFn = fire as Observable & IEventFunction, Args, Return> markEvent(eventFn) - Object.setPrototypeOf(eventFn, innerEvent); - eventFn.apply = Function.prototype.apply; - eventFn.call = Function.prototype.call; + Object.setPrototypeOf(eventFn, innerEvent) + eventFn.apply = Function.prototype.apply + eventFn.call = Function.prototype.call return additionalMembers ? Object.assign(eventFn, additionalMembers(eventFn), { [membersFactory]: additionalMembers as AdditionalMembersFactory, any> }) : eventFn as Observable & IEventFunction, Args, Return> & Members diff --git a/packages/event-reduce/src/logging.ts b/packages/event-reduce/src/logging.ts index 5329646..9b76248 100644 --- a/packages/event-reduce/src/logging.ts +++ b/packages/event-reduce/src/logging.ts @@ -1,16 +1,16 @@ -import { dispose, nullDisposable } from "./utils"; +import { dispose, nullDisposable } from "./utils" -let loggingEnabled = false; +let loggingEnabled = false -let logStack = [[]] as LogItem[][]; +let logStack = [[]] as LogItem[][] -type LogItem = LogMessage | LogGroup; +type LogItem = LogMessage | LogGroup -type LogMessage = any[]; +type LogMessage = any[] type LogGroup = { - message: LogMessage; - children: LogItem[]; + message: LogMessage + children: LogItem[] } /** @@ -20,13 +20,13 @@ type LogGroup = { export class LogValue { constructor(public args: unknown[]) { } } export function enableLogging(enable = true) { - loggingEnabled = enable; + loggingEnabled = enable } export function startLogging(type: string, displayName: string, args: readonly any[], getInfo?: () => Info): Disposable { if (process.env.NODE_ENV !== 'production' && loggingEnabled) { - logStack.push([]); - return { [dispose]() { logMessage(type, displayName, args, getInfo, logStack.pop()!); } } + logStack.push([]) + return { [dispose]() { logMessage(type, displayName, args, getInfo, logStack.pop()!) } } } else { return nullDisposable } @@ -34,40 +34,40 @@ export function startLogging(type: string, displayName: str export function log(type: string, displayName: string, args: readonly any[], getInfo?: () => Info) { if (process.env.NODE_ENV !== 'production' && loggingEnabled) - logMessage(type, displayName, args, getInfo); + logMessage(type, displayName, args, getInfo) } function logMessage(type: string, displayName: string, args: readonly any[], getInfo?: () => Info, childLogs: LogItem[] = []) { try { let group = { - message: [`${displayName} %c${type}`, 'color: grey; font-weight: normal;', ...args], + message: [`${displayName} %c${type}`, 'color: grey font-weight: normal', ...args], children: Object.entries(getInfo?.() ?? {} as Info) .map(([key, value]) => value instanceof LogValue ? [`${key}:`, ...value.args] : [`${key}:`, value]) .concat(childLogs) - }; - logStack.at(-1)!.push(group); + } + logStack.at(-1)!.push(group) } catch (e) { - logStack.at(-1)!.push([`${displayName} ${type} (logging failed: ${e instanceof Error ? e.message : String(e)})`, ...args]); + logStack.at(-1)!.push([`${displayName} ${type} (logging failed: ${e instanceof Error ? e.message : String(e)})`, ...args]) } if (logStack.length == 1) { - flushLogs(logStack[0]); - logStack = [[]]; + flushLogs(logStack[0]) + logStack = [[]] } } function flushLogs(logs: LogItem[]) { for (let log of logs) { if (Array.isArray(log)) { - console.log(...log); + console.log(...log) } else if (!log.children.length) { - console.log(...log.message); + console.log(...log.message) } else { - console.groupCollapsed(...log.message); - flushLogs(log.children); - console.groupEnd(); + console.groupCollapsed(...log.message) + flushLogs(log.children) + console.groupEnd() } } } \ No newline at end of file diff --git a/packages/event-reduce/src/observable.ts b/packages/event-reduce/src/observable.ts index 2bb95fe..50d5854 100644 --- a/packages/event-reduce/src/observable.ts +++ b/packages/event-reduce/src/observable.ts @@ -103,7 +103,7 @@ export function bind(displayName: string, observable: IObservable, export function merge[]>(observables: Observables): IObservable> export function merge[]>(displayName: string, observables: Observables): IObservable> export function merge[]>(displayNameOrObservables: string | Observables, maybeObservables?: Observables): IObservable> { - let displayName: string; + let displayName: string let observables: Observables if (typeof displayNameOrObservables == 'string') { displayName = displayNameOrObservables @@ -162,7 +162,7 @@ export class NoUnderlyingObservableError extends Error { constructor( public value: unknown ) { - super("Couldn't detect observable for value. Make sure you pass in an observable value directly."); + super("Couldn't detect observable for value. Make sure you pass in an observable value directly.") } } @@ -170,7 +170,7 @@ export class InvalidStateError extends Error { constructor( public observable: IObservable, public value: IEventFunction | object - ) { super(`Tried to store ${isObservable(value) ? value.displayName : value} in ${observable.displayName}. ${nameOfFunction(value.constructor)}s cannot be state.`); } + ) { super(`Tried to store ${isObservable(value) ? value.displayName : value} in ${observable.displayName}. ${nameOfFunction(value.constructor)}s cannot be state.`) } } export class StateChangeOutsideTransitionError extends Error { @@ -178,6 +178,6 @@ export class StateChangeOutsideTransitionError extends Error { public source: IObservable ) { super(`Tried to change state of ${source.displayName} outside of a transition. ` - + 'This usually means a bug in event-reduce, or in a custom observable implementation.'); + + 'This usually means a bug in event-reduce, or in a custom observable implementation.') } } diff --git a/packages/event-reduce/src/reduction.ts b/packages/event-reduce/src/reduction.ts index 87e4675..805b0be 100644 --- a/packages/event-reduce/src/reduction.ts +++ b/packages/event-reduce/src/reduction.ts @@ -57,7 +57,7 @@ export class Reduction extends Observable implements IReduc updateChildren(this, null, initialValue) } - toJSON() { return this._json.value; } + toJSON() { return this._json.value } on(source: IObservable, reduce: Reducer): this on(getSource: (events: Events) => IObservable, reduce: Reducer): this @@ -67,7 +67,7 @@ export class Reduction extends Observable implements IReduc if (process.env.NODE_ENV !== 'production') { if (allSources(source).find(s => s == this)) - throw new CircularSubscriptionError(this, source); + throw new CircularSubscriptionError(this, source) } source.subscribe(this) @@ -149,6 +149,6 @@ export class CircularSubscriptionError extends Error { public reduction: IReduction, public source: IObservable ) { - super(`Cannot subscribe to '${source.displayName}', as it depends on this reduction, '${reduction.displayName}'.`); + super(`Cannot subscribe to '${source.displayName}', as it depends on this reduction, '${reduction.displayName}'.`) } } diff --git a/packages/event-reduce/src/signal.ts b/packages/event-reduce/src/signal.ts index bef6bc3..55dbf39 100644 --- a/packages/event-reduce/src/signal.ts +++ b/packages/event-reduce/src/signal.ts @@ -1,9 +1,9 @@ -import { Derivation } from "./derivation"; -import { IObservable, Observable } from "./observable"; -import { updateChildren } from "./ownership"; -import { ModelJSON, modelJSON } from "./state"; -import { beginTransition } from "./tracking"; -import { dispose, isObject } from "./utils"; +import { Derivation } from "./derivation" +import { IObservable, Observable } from "./observable" +import { updateChildren } from "./ownership" +import { ModelJSON, modelJSON } from "./state" +import { beginTransition } from "./tracking" +import { dispose, isObject } from "./utils" export interface ISignal extends IObservable { readonly value: T @@ -21,7 +21,7 @@ export class Signal extends Observable implements IObservable { displayName: string, initialValue: T, ) { - super(displayName); + super(displayName) this._json = new Derivation(`toJSON(${this.displayName})`, () => modelJSON(this.value)) this._json.suppressLogging = true diff --git a/packages/event-reduce/src/tracking.ts b/packages/event-reduce/src/tracking.ts index 0747ecc..93da2bb 100644 --- a/packages/event-reduce/src/tracking.ts +++ b/packages/event-reduce/src/tracking.ts @@ -52,10 +52,10 @@ export function endSourceAccessed(source: IObservable) { /** Allows subscribing to the changes of the specified observable value. */ export function valueChanged(observableValue: T) { - let observable = getUnderlyingObservable(observableValue); + let observable = getUnderlyingObservable(observableValue) if (observable) - return observable; - throw new NoUnderlyingObservableError(observableValue); + return observable + throw new NoUnderlyingObservableError(observableValue) } export function getUnderlyingObservable(value: T): IObservable | null { diff --git a/packages/event-reduce/src/types.ts b/packages/event-reduce/src/types.ts index a71a95c..a6e6f78 100644 --- a/packages/event-reduce/src/types.ts +++ b/packages/event-reduce/src/types.ts @@ -1,4 +1,4 @@ -import type { IObservable } from "./observable"; +import type { IObservable } from "./observable" export type Scoped> = & Omit diff --git a/packages/event-reduce/src/utils.ts b/packages/event-reduce/src/utils.ts index 9ad640b..e045147 100644 --- a/packages/event-reduce/src/utils.ts +++ b/packages/event-reduce/src/utils.ts @@ -2,8 +2,8 @@ export const emptyArray = Object.freeze([]) as readonly any[] export const emptyMap = Object.freeze(new Map()) as ReadonlyMap -export function constant(value: T) { return identity.bind(null, value) as () => T; } -export function identity(value: T) { return value; } +export function constant(value: T) { return identity.bind(null, value) as () => T } +export function identity(value: T) { return value } export function scopedName(baseName: string, scope: object) { return `${baseName}.scoped({ ${Object.entries(scope).map(([k, v]) => `${k}: ${v}`).join(', ')} })` diff --git a/tests-legacy/LegacyModel.test.ts b/tests-legacy/LegacyModel.test.ts index 7f45ec7..2cda2dd 100644 --- a/tests-legacy/LegacyModel.test.ts +++ b/tests-legacy/LegacyModel.test.ts @@ -221,9 +221,9 @@ describe("events decorator", () => { }) test("sets event parents", () => { - expect(sut.eventField.path).toBe('TestEvents.eventField'); - expect(sut.eventGetter.path).toBe('TestEvents.eventGetter'); - }); + expect(sut.eventField.path).toBe('TestEvents.eventField') + expect(sut.eventGetter.path).toBe('TestEvents.eventGetter') + }) test("snapshots getter event", () => { sut.eventGetter diff --git a/tests-legacy/LegacyState.test.ts b/tests-legacy/LegacyState.test.ts index 4bd7615..59bb4ee 100644 --- a/tests-legacy/LegacyState.test.ts +++ b/tests-legacy/LegacyState.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { derived, getState, model, reduce, reduced, state } from "event-reduce"; +import { beforeEach, describe, expect, test } from "bun:test" +import { derived, getState, model, reduce, reduced, state } from "event-reduce" describe("legacy state", () => { @model @@ -7,31 +7,31 @@ describe("legacy state", () => { constructor(@state('constructorProperty') public constructorProperty: string) { } @reduced - valueProperty = reduce(1).value; + valueProperty = reduce(1).value @derived get valuePlusOne() { - return this.valueProperty + 1; + return this.valueProperty + 1 } func() { } @reduced - reducedModel = reduce(new ChildModel('child')).value; + reducedModel = reduce(new ChildModel('child')).value @reduced - modelArray = reduce([new ChildModel('one'), new ChildModel('two')]).value; + modelArray = reduce([new ChildModel('one'), new ChildModel('two')]).value @state - mergedModel = new ChildModel('merged'); + mergedModel = new ChildModel('merged') - ignoredValue = 'ignored'; + ignoredValue = 'ignored' } @model class TestModel extends BaseModel { @state - subClassProperty = 'subClass'; + subClassProperty = 'subClass' } @model @@ -39,17 +39,17 @@ describe("legacy state", () => { constructor(private _initialValue: string) { } @reduced - value = reduce(this._initialValue).value; + value = reduce(this._initialValue).value } - let sut: TestModel; + let sut: TestModel beforeEach(() => { - sut = new TestModel('ctor'); - }); + sut = new TestModel('ctor') + }) test("getState copies the correct properties", () => { - let result = getState(sut); + let result = getState(sut) expect(JSON.stringify(result)).toBe(JSON.stringify({ valueProperty: 1, reducedModel: { value: 'child' }, @@ -60,6 +60,6 @@ describe("legacy state", () => { subClassProperty: 'subClass', mergedModel: { value: 'merged' }, constructorProperty: 'ctor' - })); - }); -}); + })) + }) +}) diff --git a/tests/Cleanup.test.ts b/tests/Cleanup.test.ts index afe8ee2..0eb6c02 100644 --- a/tests/Cleanup.test.ts +++ b/tests/Cleanup.test.ts @@ -251,10 +251,10 @@ describe("subscription cleanup", () => { expect(disposeModelSpy).toHaveBeenCalled() }) - /* describe("when array items are also in another observable value", () => { + describe("when array items are also in another observable value", () => { test("when array value is disposed, doesn't dispose items", () => { - let arrayValue = new ObservableValue('array value', [new Model(0)]) - new ObservableValue('other value', arrayValue.value[0]) + let arrayValue = signal([new Model(0)], 'array value') + signal(arrayValue.value[0], 'other value') let disposeModelSpy = spyOnDispose(arrayValue.value[0], 'reducedProp') arrayValue[dispose]() @@ -265,15 +265,15 @@ describe("subscription cleanup", () => { describe("when object values are also in another observable value", () => { test("when object value is disposed, doesn't dispose values", () => { - let objectValue = new ObservableValue('object value', { model: new Model(0) }) - new ObservableValue('other value', objectValue.value.model) + let objectValue = signal({ model: new Model(0) }, 'object value') + signal(objectValue.value.model, 'other value') let disposeModelSpy = spyOnDispose(objectValue.value.model, 'reducedProp') objectValue[dispose]() expect(disposeModelSpy).not.toHaveBeenCalled() }) - }); */ + }) test("when model disposed, disposes all properties correctly", () => { @model @@ -281,16 +281,16 @@ describe("subscription cleanup", () => { constructor(private _ownedModel: Model) { } @reduced - get reducedProp() { return reduce(new Model(0)).value; } + get reducedProp() { return reduce(new Model(0)).value } @derived - get derivedProp() { return new Model(1); } + get derivedProp() { return new Model(1) } @state stateProp = new Model(2) @derived - get externallyOwned() { return this._ownedModel; } + get externallyOwned() { return this._ownedModel } } let externalOwner = signal(new Model(3), 'external owner') let outerModel = new OuterModel(externalOwner.value) as OuterModel & Disposable @@ -299,15 +299,15 @@ describe("subscription cleanup", () => { let disposeStatePropValue = spyOnDispose(outerModel.stateProp, 'reducedProp') let disposeExternallyOwnedPropValue = spyOnDispose(outerModel.externallyOwned, 'reducedProp') let disposeReducedProp = spyOnDispose(outerModel, 'reducedProp') - // let disposeDerivedProp = spyOnDispose(outerModel, 'derivedProp') - // let disposeExternallyOwnedProp = spyOnDispose(outerModel, 'externallyOwned') + let disposeDerivedProp = spyOnDispose(outerModel, 'derivedProp') + let disposeExternallyOwnedProp = spyOnDispose(outerModel, 'externallyOwned') let outerOwner = signal(outerModel, 'outer owner') outerOwner.setValue(null!) expect(disposeReducedProp).toHaveBeenCalled() - // expect(disposeDerivedProp).toHaveBeenCalled() - // expect(disposeExternallyOwnedProp).toHaveBeenCalled() + expect(disposeDerivedProp).toHaveBeenCalled() + expect(disposeExternallyOwnedProp).toHaveBeenCalled() expect(disposeReducedPropValue).toHaveBeenCalled() expect(disposeDerivedPropValue).toHaveBeenCalled() diff --git a/tests/Derivation.test.ts b/tests/Derivation.test.ts index 7edfa91..7c10514 100644 --- a/tests/Derivation.test.ts +++ b/tests/Derivation.test.ts @@ -1,138 +1,138 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { derive, event, events, signal, type IDerivation, type ISignal } from "event-reduce"; -import { InvalidStateError } from "event-reduce/lib/observable"; -import { getUnderlyingObservable, SideEffectDuringReactionError } from "event-reduce/lib/tracking"; -import { MockObserver } from "./MockObserver"; +import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { derive, event, events, signal, type IDerivation, type ISignal } from "event-reduce" +import { InvalidStateError } from "event-reduce/lib/observable" +import { getUnderlyingObservable, SideEffectDuringReactionError } from "event-reduce/lib/tracking" +import { MockObserver } from "./MockObserver" describe("derive", () => { - let sourceA: ISignal; - let sourceB: ISignal; - let calculation: Mock<() => string>; - let sut: IDerivation; + let sourceA: ISignal + let sourceB: ISignal + let calculation: Mock<() => string> + let sut: IDerivation beforeEach(() => { - sourceA = signal('a', 'a'); - sourceB = signal('b', 'b'); - calculation = mock(() => sourceA.value + sourceB.value); - sut = derive(calculation, 'sut'); - }); + sourceA = signal('a', 'a') + sourceB = signal('b', 'b') + calculation = mock(() => sourceA.value + sourceB.value) + sut = derive(calculation, 'sut') + }) - test("has provided name", () => expect(sut.displayName).toBe('sut')); + test("has provided name", () => expect(sut.displayName).toBe('sut')) describe("when value accessed", () => { - let result: string; + let result: string beforeEach(() => { - result = sut.value; - }); + result = sut.value + }) - test("returns result of calculation", () => expect(result).toBe('ab')); + test("returns result of calculation", () => expect(result).toBe('ab')) - test("derivation is last accessed value", () => expect(getUnderlyingObservable(result)!).toBe(sut)); + test("derivation is last accessed value", () => expect(getUnderlyingObservable(result)!).toBe(sut)) test("when accessed again, returns same value and doesn't re-compute the value", () => { - let result2 = sut.value; + let result2 = sut.value - expect(result2).toBe(result); - expect(calculation).toHaveBeenCalledTimes(1); - }); + expect(result2).toBe(result) + expect(calculation).toHaveBeenCalledTimes(1) + }) test("when an observable value changed then accessed again, returns updated value", () => { - sourceB.setValue('B'); + sourceB.setValue('B') - let result2 = sut.value; + let result2 = sut.value - expect(result2).toBe('aB'); - }); + expect(result2).toBe('aB') + }) test("when multiple observable values changed then accessed again, returns updated value and re-computes only once", () => { - calculation.mockClear(); - sourceA.setValue('A'); - sourceB.setValue('B'); + calculation.mockClear() + sourceA.setValue('A') + sourceB.setValue('B') - let result2 = sut.value; + let result2 = sut.value - expect(result2).toBe('AB'); - expect(calculation).toHaveBeenCalledTimes(1); - }); + expect(result2).toBe('AB') + expect(calculation).toHaveBeenCalledTimes(1) + }) describe("when subscribed to", () => { - let observer: MockObserver; + let observer: MockObserver beforeEach(() => { - observer = new MockObserver(); - sut.subscribe(observer); - }); + observer = new MockObserver() + sut.subscribe(observer) + }) - test("doesn't notify immediately", () => expect(observer.sourceUpdated).not.toHaveBeenCalled()); + test("doesn't notify immediately", () => expect(observer.sourceUpdated).not.toHaveBeenCalled()) test("when an observable value changed, notifies observer", () => { - sourceA.setValue('A'); + sourceA.setValue('A') - expect(observer.sourceUpdated).toHaveBeenCalled(); - }); - }); - }); + expect(observer.sourceUpdated).toHaveBeenCalled() + }) + }) + }) test("when derivation fires an event, throws", () => { - let sideEffect = event('some event'); - let derivation = derive(() => sideEffect()); + let sideEffect = event('some event') + let derivation = derive(() => sideEffect()) try { - derivation.value; - expect.unreachable("Should have thrown"); + derivation.value + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(SideEffectDuringReactionError); - expect((e as SideEffectDuringReactionError).reactingTo).toBe(derivation); - expect((e as SideEffectDuringReactionError).sideEffect).toBe(sideEffect); + expect(e).toBeInstanceOf(SideEffectDuringReactionError) + expect((e as SideEffectDuringReactionError).reactingTo).toBe(derivation) + expect((e as SideEffectDuringReactionError).sideEffect).toBe(sideEffect) } - }); + }) test("when derivation returns an event, accessing value throws", () => { let eventValue = event('derived event') - let derivation = derive(() => sourceA.value && eventValue); + let derivation = derive(() => sourceA.value && eventValue) try { - derivation.value; - expect.unreachable("Should have thrown"); + derivation.value + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(InvalidStateError); - expect((e as InvalidStateError).observable).toBe(derivation); - expect((e as InvalidStateError).value).toBe(eventValue); + expect(e).toBeInstanceOf(InvalidStateError) + expect((e as InvalidStateError).observable).toBe(derivation) + expect((e as InvalidStateError).value).toBe(eventValue) } - }); + }) test("when derivation returns an events class, accessing value throws", () => { @events class Events { } - let eventsValue = new Events(); - let derivation = derive(() => sourceA.value && eventsValue); + let eventsValue = new Events() + let derivation = derive(() => sourceA.value && eventsValue) try { - derivation.value; - expect.unreachable("Should have thrown"); + derivation.value + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(InvalidStateError); - expect((e as InvalidStateError).observable).toBe(derivation); - expect((e as InvalidStateError).value).toBe(eventsValue); + expect(e).toBeInstanceOf(InvalidStateError) + expect((e as InvalidStateError).observable).toBe(derivation) + expect((e as InvalidStateError).value).toBe(eventsValue) } - }); -}); + }) +}) test("derivation that depends on other derivations only updates once", () => { - let observable = signal("a", "Observable"); - let inner1 = derive(() => observable.value + "_inner1"); - let inner2 = derive(() => observable.value + "_inner2"); - let outerCalc = mock(() => inner1.value + inner2.value); - let outer = derive(outerCalc); - outer.subscribe(new MockObserver()); // Outer must be observed for sources to trigger an update + let observable = signal("a", "Observable") + let inner1 = derive(() => observable.value + "_inner1") + let inner2 = derive(() => observable.value + "_inner2") + let outerCalc = mock(() => inner1.value + inner2.value) + let outer = derive(outerCalc) + outer.subscribe(new MockObserver()) // Outer must be observed for sources to trigger an update // Initial access to set up dependencies - outer.value; - expect(outerCalc).toHaveBeenCalledTimes(1); + outer.value + expect(outerCalc).toHaveBeenCalledTimes(1) // This should trigger updates to inner1 and inner2, but outer should only re-calculate once - observable.setValue("b"); - outer.value; - expect(outerCalc).toHaveBeenCalledTimes(2); -}); + observable.setValue("b") + outer.value + expect(outerCalc).toHaveBeenCalledTimes(2) +}) diff --git a/tests/Event.test.ts b/tests/Event.test.ts index 2192c60..628f835 100644 --- a/tests/Event.test.ts +++ b/tests/Event.test.ts @@ -1,317 +1,317 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type IAsyncObservable } from 'event-reduce'; -import { makePhasedEvent, type IPhasedObservable } from "event-reduce/lib/events"; -import { SideEffectDuringReactionError } from "event-reduce/lib/tracking"; -import { MockObserver } from "./MockObserver"; +import { beforeEach, describe, expect, test } from "bun:test" +import { asyncEvent, event, type AsyncError, type AsyncResult, type AsyncStart, type IAsyncObservable } from 'event-reduce' +import { makePhasedEvent, type IPhasedObservable } from "event-reduce/lib/events" +import { SideEffectDuringReactionError } from "event-reduce/lib/tracking" +import { MockObserver } from "./MockObserver" describe("event", () => { - type TestType = { foo: string, bar: number }; - let sut: ReturnType>; + type TestType = { foo: string, bar: number } + let sut: ReturnType> beforeEach(() => { - sut = event('sut'); - }); + sut = event('sut') + }) describe("when subscribed to", () => { - let subscriber: MockObserver; + let subscriber: MockObserver beforeEach(() => { subscriber = new MockObserver() - sut.subscribe(subscriber); - }); + sut.subscribe(subscriber) + }) test("when called, passes value to subscribers", () => { - let value = { foo: 'foo', bar: 1 }; + let value = { foo: 'foo', bar: 1 } - sut(value); + sut(value) - expect(subscriber.sourceUpdated).toHaveBeenCalledWith(sut, value); - }); - }); + expect(subscriber.sourceUpdated).toHaveBeenCalledWith(sut, value) + }) + }) test("when handler calls another event, throws", () => { - let otherEvent = event('otherEvent'); + let otherEvent = event('otherEvent') let observer = new MockObserver() observer.sourceUpdated = () => otherEvent(undefined) - sut.subscribe(observer); + sut.subscribe(observer) try { - sut({ foo: 'foo', bar: 1 }); - expect.unreachable("Should have thrown"); + sut({ foo: 'foo', bar: 1 }) + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(SideEffectDuringReactionError); - expect((e as SideEffectDuringReactionError).reactingTo).toBe(sut); - expect((e as SideEffectDuringReactionError).sideEffect).toBe(otherEvent); + expect(e).toBeInstanceOf(SideEffectDuringReactionError) + expect((e as SideEffectDuringReactionError).reactingTo).toBe(sut) + expect((e as SideEffectDuringReactionError).sideEffect).toBe(otherEvent) } - }); + }) describe("scope", () => { - let scoped: any; - let rootObserver: MockObserver; - let scopedObserver: MockObserver; + let scoped: any + let rootObserver: MockObserver + let scopedObserver: MockObserver beforeEach(() => { - scoped = sut.scope({ foo: 'foo' }); + scoped = sut.scope({ foo: 'foo' }) rootObserver = new MockObserver() scopedObserver = new MockObserver() - sut.subscribe(rootObserver); - scoped.subscribe(scopedObserver); - }); + sut.subscribe(rootObserver) + scoped.subscribe(scopedObserver) + }) test("when non-matching value passed to parent event, scoped event does not pass on value", () => { - sut({ foo: 'bar', bar: 1 }); + sut({ foo: 'bar', bar: 1 }) - expect(scopedObserver.sourceUpdated).not.toHaveBeenCalled(); - }); + expect(scopedObserver.sourceUpdated).not.toHaveBeenCalled() + }) test("when matching value passed to parent event, scoped event passed on value", () => { - let value = { foo: 'foo', bar: 1 }; + let value = { foo: 'foo', bar: 1 } - sut(value); + sut(value) - expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, value); - }); + expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, value) + }) test("when called with partial value, scoped event fills in scope values", () => { - scoped({ bar: 2 }); + scoped({ bar: 2 }) - expect(rootObserver.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ foo: 'foo', bar: 2 })); - expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, expect.objectContaining({ foo: 'foo', bar: 2 })); - }); - }); -}); + expect(rootObserver.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ foo: 'foo', bar: 2 })) + expect(scopedObserver.sourceUpdated).toHaveBeenCalledWith(scoped, expect.objectContaining({ foo: 'foo', bar: 2 })) + }) + }) +}) describe("asyncEvent", () => { - type Result = { foo: string; }; - type Context = { bar: string; }; - let sut: ReturnType>; + type Result = { foo: string } + type Context = { bar: string } + let sut: ReturnType> - let started: MockObserver>; - let resolved: MockObserver>; - let rejected: MockObserver>; + let started: MockObserver> + let resolved: MockObserver> + let rejected: MockObserver> beforeEach(() => { - sut = asyncEvent(); + sut = asyncEvent() started = new MockObserver() resolved = new MockObserver() rejected = new MockObserver() - sut.started.subscribe(started); - sut.resolved.subscribe(resolved); - sut.rejected.subscribe(rejected); - }); + sut.started.subscribe(started) + sut.resolved.subscribe(resolved) + sut.rejected.subscribe(rejected) + }) describe("when called", () => { - let promise: Promise; - let resolve: (value: Result) => void; - let reject: (error: any) => void; - let context: Context; + let promise: Promise + let resolve: (value: Result) => void + let reject: (error: any) => void + let context: Context beforeEach(() => { - ({ promise, resolve, reject } = Promise.withResolvers()); - context = { bar: 'context' }; - sut(promise, context); - }); + ({ promise, resolve, reject } = Promise.withResolvers()) + context = { bar: 'context' } + sut(promise, context) + }) - test("started event fired", () => expect(started.sourceUpdated).toHaveBeenCalledWith(sut.started, expect.objectContaining({ phase: 'started', promise, context }))); + test("started event fired", () => expect(started.sourceUpdated).toHaveBeenCalledWith(sut.started, expect.objectContaining({ phase: 'started', promise, context }))) test("when promise resolved, resolved event fired", async () => { - let result = { foo: 'result' }; - resolve(result); - await promise; + let result = { foo: 'result' } + resolve(result) + await promise - expect(resolved.sourceUpdated).toHaveBeenCalledWith(sut.resolved, expect.objectContaining({ phase: 'resolved', result, context })); - }); + expect(resolved.sourceUpdated).toHaveBeenCalledWith(sut.resolved, expect.objectContaining({ phase: 'resolved', result, context })) + }) test("when promise rejected, rejected event fired", async () => { - let error = { message: 'error' }; - reject(error); - await promise.catch(() => { }); // Catch to prevent unhandled rejection + let error = { message: 'error' } + reject(error) + await promise.catch(() => { }) // Catch to prevent unhandled rejection - expect(rejected.sourceUpdated).toHaveBeenCalledWith(sut.rejected, expect.objectContaining({ phase: 'rejected', error, context })); - }); - }); + expect(rejected.sourceUpdated).toHaveBeenCalledWith(sut.rejected, expect.objectContaining({ phase: 'rejected', error, context })) + }) + }) describe("filterContext", () => { - let filtered: IAsyncObservable; - let filteredStarted: MockObserver>; - let filteredResolved: MockObserver>; - let filteredRejected: MockObserver>; + let filtered: IAsyncObservable + let filteredStarted: MockObserver> + let filteredResolved: MockObserver> + let filteredRejected: MockObserver> beforeEach(() => { - filtered = sut.filterContext(context => context.bar == 'match'); - filteredStarted = new MockObserver(); - filteredResolved = new MockObserver(); - filteredRejected = new MockObserver(); - filtered.started.subscribe(filteredStarted); - filtered.resolved.subscribe(filteredResolved); - filtered.rejected.subscribe(filteredRejected); - }); + filtered = sut.filterContext(context => context.bar == 'match') + filteredStarted = new MockObserver() + filteredResolved = new MockObserver() + filteredRejected = new MockObserver() + filtered.started.subscribe(filteredStarted) + filtered.resolved.subscribe(filteredResolved) + filtered.rejected.subscribe(filteredRejected) + }) test("when context matches, filters all async event phases through", async () => { - let resolved = Promise.resolve({ foo: 'result' }); - let rejected = Promise.reject({ message: 'error' }); - rejected.catch(() => { }); // Catch to prevent unhandled rejection + let resolved = Promise.resolve({ foo: 'result' }) + let rejected = Promise.reject({ message: 'error' }) + rejected.catch(() => { }) // Catch to prevent unhandled rejection - sut(resolved, { bar: 'match' }); - sut(rejected, { bar: 'match' }); - await resolved; - await rejected.catch(() => { }); + sut(resolved, { bar: 'match' }) + sut(rejected, { bar: 'match' }) + await resolved + await rejected.catch(() => { }) - expect(filteredStarted.sourceUpdated).toHaveBeenCalledWith(filtered.started, expect.objectContaining({ phase: 'started', context: { bar: 'match' } })); - expect(filteredResolved.sourceUpdated).toHaveBeenCalledWith(filtered.resolved, expect.objectContaining({ phase: 'resolved', result: { foo: 'result' }, context: { bar: 'match' } })); - expect(filteredRejected.sourceUpdated).toHaveBeenCalledWith(filtered.rejected, expect.objectContaining({ phase: 'rejected', error: { message: 'error' }, context: { bar: 'match' } })); - }); + expect(filteredStarted.sourceUpdated).toHaveBeenCalledWith(filtered.started, expect.objectContaining({ phase: 'started', context: { bar: 'match' } })) + expect(filteredResolved.sourceUpdated).toHaveBeenCalledWith(filtered.resolved, expect.objectContaining({ phase: 'resolved', result: { foo: 'result' }, context: { bar: 'match' } })) + expect(filteredRejected.sourceUpdated).toHaveBeenCalledWith(filtered.rejected, expect.objectContaining({ phase: 'rejected', error: { message: 'error' }, context: { bar: 'match' } })) + }) test("when context does not match, filters all async event phases out", async () => { - let resolved = Promise.resolve({ foo: 'result' }); - let rejected = Promise.reject({ message: 'error' }); - rejected.catch(() => { }); // Catch to prevent unhandled rejection + let resolved = Promise.resolve({ foo: 'result' }) + let rejected = Promise.reject({ message: 'error' }) + rejected.catch(() => { }) // Catch to prevent unhandled rejection - sut(resolved, { bar: 'other' }); - sut(rejected, { bar: 'other' }); - await resolved; - await rejected.catch(() => { }); + sut(resolved, { bar: 'other' }) + sut(rejected, { bar: 'other' }) + await resolved + await rejected.catch(() => { }) - expect(filteredStarted.sourceUpdated).not.toHaveBeenCalled(); - expect(filteredResolved.sourceUpdated).not.toHaveBeenCalled(); - expect(filteredRejected.sourceUpdated).not.toHaveBeenCalled(); - }); + expect(filteredStarted.sourceUpdated).not.toHaveBeenCalled() + expect(filteredResolved.sourceUpdated).not.toHaveBeenCalled() + expect(filteredRejected.sourceUpdated).not.toHaveBeenCalled() + }) test("can chain context filters", async () => { let chained = sut .filterContext(context => context.bar.length > 1) - .filterContext(context => context.bar == 'match'); - let observed = new MockObserver>(); - chained.resolved.subscribe(observed); + .filterContext(context => context.bar == 'match') + let observed = new MockObserver>() + chained.resolved.subscribe(observed) - let resolved = Promise.resolve({ foo: 'result' }); - sut(resolved, { bar: 'match' }); - await resolved; + let resolved = Promise.resolve({ foo: 'result' }) + sut(resolved, { bar: 'match' }) + await resolved - expect(observed.sourceUpdated).toHaveBeenCalledWith(chained.resolved, expect.objectContaining({ phase: 'resolved', result: { foo: 'result' }, context: { bar: 'match' } })); - }); + expect(observed.sourceUpdated).toHaveBeenCalledWith(chained.resolved, expect.objectContaining({ phase: 'resolved', result: { foo: 'result' }, context: { bar: 'match' } })) + }) test("sets display names for filtered async observables and their phases", () => { - let filtered = sut.filterContext(context => context.bar == 'match', 'matching'); + let filtered = sut.filterContext(context => context.bar == 'match', 'matching') - expect(filtered.displayName).toBe('matching'); - expect(filtered.started.displayName).toBe('matching.started'); - expect(filtered.resolved.displayName).toBe('matching.resolved'); - expect(filtered.rejected.displayName).toBe('matching.rejected'); - }); + expect(filtered.displayName).toBe('matching') + expect(filtered.started.displayName).toBe('matching.started') + expect(filtered.resolved.displayName).toBe('matching.resolved') + expect(filtered.rejected.displayName).toBe('matching.rejected') + }) test("supports context type guards", () => { type MixedContext = | { kind: 'matching'; bar: string } - | { kind: 'other'; baz: string }; + | { kind: 'other'; baz: string } - let mixed = asyncEvent(); - let matching = mixed.filterContext((context): context is Extract => context.kind == 'matching'); - let expected: IAsyncObservable = matching; + let mixed = asyncEvent() + let matching = mixed.filterContext((context): context is Extract => context.kind == 'matching') + let expected: IAsyncObservable = matching - expected.resolved.subscribe(new MockObserver>()); - }); - }); -}); + expected.resolved.subscribe(new MockObserver>()) + }) + }) +}) describe("makePhasedEvent", () => { - type Context = { id: string, group: string }; + type Context = { id: string, group: string } type Value = | { phase: 'started', input: string, context: Context } | { phase: 'next', value: string, context: Context } - | { phase: 'completed', context: Context }; + | { phase: 'completed', context: Context } function testPhasedEvent(name = 'test phased') { return makePhasedEvent({ name, phases: ['started', 'next', 'completed'], fire(emit: (value: Value) => void, input: string, context: Context) { - emit({ phase: 'started', input, context }); + emit({ phase: 'started', input, context }) return () => { - emit({ phase: 'next', value: input.toUpperCase(), context }); - emit({ phase: 'completed', context }); - }; + emit({ phase: 'next', value: input.toUpperCase(), context }) + emit({ phase: 'completed', context }) + } } - }); + }) } test("adds phase discriminator and exposes phase observables", () => { - let sut = testPhasedEvent(); - let all = new MockObserver(); - let started = new MockObserver(); - let next = new MockObserver(); - sut.subscribe(all); - sut.started.subscribe(started); - sut.next.subscribe(next); - - let flush: () => void = sut('value', { id: '1', group: 'a' }); - flush(); - - expect(started.sourceUpdated).toHaveBeenCalledWith(sut.started, expect.objectContaining({ phase: 'started', input: 'value' })); - expect(next.sourceUpdated).toHaveBeenCalledWith(sut.next, expect.objectContaining({ phase: 'next', value: 'VALUE' })); - expect(all.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ phase: 'completed', context: { id: '1', group: 'a' } })); - }); + let sut = testPhasedEvent() + let all = new MockObserver() + let started = new MockObserver() + let next = new MockObserver() + sut.subscribe(all) + sut.started.subscribe(started) + sut.next.subscribe(next) + + let flush: () => void = sut('value', { id: '1', group: 'a' }) + flush() + + expect(started.sourceUpdated).toHaveBeenCalledWith(sut.started, expect.objectContaining({ phase: 'started', input: 'value' })) + expect(next.sourceUpdated).toHaveBeenCalledWith(sut.next, expect.objectContaining({ phase: 'next', value: 'VALUE' })) + expect(all.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ phase: 'completed', context: { id: '1', group: 'a' } })) + }) test("filters all phases by context", () => { - let sut = testPhasedEvent(); - let filtered = sut.filterContext(context => context.group == 'a'); - let next = new MockObserver(); - filtered.next.subscribe(next); + let sut = testPhasedEvent() + let filtered = sut.filterContext(context => context.group == 'a') + let next = new MockObserver() + filtered.next.subscribe(next) - sut('skip', { id: '1', group: 'b' })(); - sut('keep', { id: '2', group: 'a' })(); + sut('skip', { id: '1', group: 'b' })() + sut('keep', { id: '2', group: 'a' })() - expect(next.sourceUpdated).toHaveBeenCalledTimes(1); - expect(next.sourceUpdated).toHaveBeenCalledWith(filtered.next, expect.objectContaining({ phase: 'next', value: 'KEEP', context: { id: '2', group: 'a' } })); - }); + expect(next.sourceUpdated).toHaveBeenCalledTimes(1) + expect(next.sourceUpdated).toHaveBeenCalledWith(filtered.next, expect.objectContaining({ phase: 'next', value: 'KEEP', context: { id: '2', group: 'a' } })) + }) test("scopes context by merging into last argument", () => { - let sut = testPhasedEvent(); - let scoped = sut.scope({ id: 'scoped' }); - let expectedScopedSource: IPhasedObservable = scoped; - let root = new MockObserver(); - let scopedNext = new MockObserver(); - sut.subscribe(root); - expectedScopedSource.next.subscribe(scopedNext); + let sut = testPhasedEvent() + let scoped = sut.scope({ id: 'scoped' }) + let expectedScopedSource: IPhasedObservable = scoped + let root = new MockObserver() + let scopedNext = new MockObserver() + sut.subscribe(root) + expectedScopedSource.next.subscribe(scopedNext) - scoped('value', { group: 'a' }); + scoped('value', { group: 'a' }) - expect(root.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ phase: 'started', context: { id: 'scoped', group: 'a' } })); - expect(scopedNext.sourceUpdated).not.toHaveBeenCalled(); + expect(root.sourceUpdated).toHaveBeenCalledWith(sut, expect.objectContaining({ phase: 'started', context: { id: 'scoped', group: 'a' } })) + expect(scopedNext.sourceUpdated).not.toHaveBeenCalled() - sut('other', { id: 'scoped', group: 'a' })(); + sut('other', { id: 'scoped', group: 'a' })() - expect(scopedNext.sourceUpdated).toHaveBeenCalledWith(scoped.next, expect.objectContaining({ phase: 'next', context: { id: 'scoped', group: 'a' } })); - }); + expect(scopedNext.sourceUpdated).toHaveBeenCalledWith(scoped.next, expect.objectContaining({ phase: 'next', context: { id: 'scoped', group: 'a' } })) + }) test("sets display names for phases", () => { - let sut = testPhasedEvent('custom'); + let sut = testPhasedEvent('custom') - expect(sut.started.displayName).toBe('custom.started'); - expect(sut.next.displayName).toBe('custom.next'); - expect(sut.completed.displayName).toBe('custom.completed'); + expect(sut.started.displayName).toBe('custom.started') + expect(sut.next.displayName).toBe('custom.next') + expect(sut.completed.displayName).toBe('custom.completed') - sut.setDisplayName('renamed'); + sut.setDisplayName('renamed') - expect(sut.started.displayName).toBe('renamed.started'); - expect(sut.next.displayName).toBe('renamed.next'); - expect(sut.completed.displayName).toBe('renamed.completed'); - }); + expect(sut.started.displayName).toBe('renamed.started') + expect(sut.next.displayName).toBe('renamed.next') + expect(sut.completed.displayName).toBe('renamed.completed') + }) test("supports context type guards", () => { type MixedContext = | { kind: 'matching'; id: string } - | { kind: 'other'; id: string }; - type MixedValue = { phase: 'next', value: string, context: MixedContext }; + | { kind: 'other'; id: string } + type MixedValue = { phase: 'next', value: string, context: MixedContext } let sut = makePhasedEvent({ name: 'mixed', phases: ['next'], fire: (emit: (value: MixedValue) => void, value: string, context: MixedContext) => emit({ phase: 'next', value, context }) - }); - let matching = sut.filterContext((context): context is Extract => context.kind == 'matching'); - let expected: IPhasedObservable<{ phase: 'next', value: string, context: { kind: 'matching', id: string } }, { kind: 'matching', id: string }> = matching; + }) + let matching = sut.filterContext((context): context is Extract => context.kind == 'matching') + let expected: IPhasedObservable<{ phase: 'next', value: string, context: { kind: 'matching'; id: string } }, { kind: 'matching'; id: string }> = matching - expected.next.subscribe(new MockObserver()); - }); -}); + expected.next.subscribe(new MockObserver()) + }) +}) diff --git a/tests/Example.test.tsx b/tests/Example.test.tsx index 38e69b8..f67ddb2 100644 --- a/tests/Example.test.tsx +++ b/tests/Example.test.tsx @@ -1,180 +1,184 @@ -import { fireEvent, render, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, test } from "bun:test"; -import { Counter, CounterEvents, CounterModel } from "event-reduce-example/Counter"; -import { CounterList, CounterListEvents, CounterListModel } from "event-reduce-example/CounterList"; -import { eventProxy, Mutable, mutable } from "event-reduce/lib/testing"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { Counter, CounterEvents, CounterModel } from "event-reduce-example/Counter" +import { CounterList, CounterListEvents, CounterListModel } from "event-reduce-example/CounterList" +import { eventProxy, Mutable, mutable } from "event-reduce/lib/testing" describe("CounterListModel", () => { - let events: CounterListEvents; - let sut: CounterListModel; + let events: CounterListEvents + let sut: CounterListModel beforeEach(() => { - events = new CounterListEvents(); - sut = new CounterListModel(events); - }); + events = new CounterListEvents() + sut = new CounterListModel(events) + }) - test("there are no counters by default", () => expect(sut.counters.length).toBe(0)); + test("there are no counters by default", () => expect(sut.counters.length).toBe(0)) describe("when counter added", () => { - let firstCounter: CounterModel; + let firstCounter: CounterModel beforeEach(() => { - events.counterAdded(); - firstCounter = sut.counters[0]; - }); + events.counterAdded() + firstCounter = sut.counters[0] + }) - test("has a counter", () => expect(sut.counters.length).toBe(1)); + test("has a counter", () => expect(sut.counters.length).toBe(1)) describe("when another counter added", () => { beforeEach(() => { - events.counterAdded(); - }); + events.counterAdded() + }) - test("keeps first counter", () => expect(sut.counters[0]).toBe(firstCounter)); + test("keeps first counter", () => expect(sut.counters[0]).toBe(firstCounter)) - test("has two counters", () => expect(sut.counters.length).toBe(2)); + test("has two counters", () => expect(sut.counters.length).toBe(2)) test("when second counter removed, only has first counter left", () => { - events.counterRemoved({ id: sut.counters[1].id }); + events.counterRemoved({ id: sut.counters[1].id }) - expect(sut.counters.length).toBe(1); - expect(sut.counters[0]).toBe(firstCounter); - }); - }); - }); -}); + expect(sut.counters.length).toBe(1) + expect(sut.counters[0]).toBe(firstCounter) + }) + }) + }) +}) describe("CounterList", () => { - let model: CounterListModel; - let sut: ReturnType; + let model: CounterListModel + let sut: ReturnType beforeEach(() => { - model = new CounterListModel(new CounterListEvents()); - sut = render(); - }); + model = new CounterListModel(new CounterListEvents()) + sut = render() + }) + + afterEach(cleanup) describe("when 'Add Counter' button clicked", () => { beforeEach(() => { - fireEvent.click(sut.getByText('Add Counter')); - }); + fireEvent.click(sut.getByText('Add Counter')) + }) - test("counter added", () => expect(sut.getByTestId('counter')).toBeDefined()); + test("counter added", () => expect(sut.getByTestId('counter')).toBeDefined()) test("when 'Remove' button clicked, counter removed", () => { - fireEvent.click(sut.getByText('Remove')); + fireEvent.click(sut.getByText('Remove')) - expect(sut.queryByTestId('counter')).toBeNull(); - }); - }); -}); + expect(sut.queryByTestId('counter')).toBeNull() + }) + }) +}) describe("CounterModel", () => { - let id: number; - let initialCount: number; - let parentEvents: any; - let events: CounterEvents; - let sut: Mutable; + let id: number + let initialCount: number + let parentEvents: any + let events: CounterEvents + let sut: Mutable beforeEach(() => { - id = 1; - initialCount = 3; - parentEvents = eventProxy(); - events = new CounterEvents(parentEvents, { id }); - sut = mutable(new CounterModel(events, { id, count: initialCount })); - }); + id = 1 + initialCount = 3 + parentEvents = eventProxy() + events = new CounterEvents(parentEvents, { id }) + sut = mutable(new CounterModel(events, { id, count: initialCount })) + }) describe("count", () => { - test("has initial count by default", () => expect(sut.count).toBe(3)); + test("has initial count by default", () => expect(sut.count).toBe(3)) test("when counter incremented, is increased by 1", () => { - parentEvents.incremented({ id }); + parentEvents.incremented({ id }) - expect(sut.count).toBe(initialCount + 1); - }); + expect(sut.count).toBe(initialCount + 1) + }) test("when counter decremented, is decreased by 1", () => { - parentEvents.decremented({ id }); + parentEvents.decremented({ id }) - expect(sut.count).toBe(initialCount - 1); - }); + expect(sut.count).toBe(initialCount - 1) + }) test("when counter reset, becomes 0", () => { - parentEvents.reset({ id }); + parentEvents.reset({ id }) - expect(sut.count).toBe(0); - }); + expect(sut.count).toBe(0) + }) describe("when value fetched asynchronously", () => { - let request: Promise; - let resolve: (value: number) => void; + let request: Promise + let resolve: (value: number) => void beforeEach(() => { - ({ promise: request, resolve } = Promise.withResolvers()); - events.valueFetched(request); - }); + ({ promise: request, resolve } = Promise.withResolvers()) + events.valueFetched(request) + }) - test("remains the same", () => expect(sut.count).toBe(initialCount)); + test("remains the same", () => expect(sut.count).toBe(initialCount)) test("when value returned, becomes the result", async () => { - let answer = 42; - resolve(answer); - await request; + let answer = 42 + resolve(answer) + await request - expect(sut.count).toBe(answer); - }); - }); - }); + expect(sut.count).toBe(answer) + }) + }) + }) describe("countTimesTwo", () => { - test("is two times initial count by default", () => expect(sut.countTimesTwo).toBe(initialCount * 2)); + test("is two times initial count by default", () => expect(sut.countTimesTwo).toBe(initialCount * 2)) test("when count updated, is two times the new count", () => { - let newCount = 11; - sut.count = newCount; + let newCount = 11 + sut.count = newCount - expect(sut.countTimesTwo).toBe(newCount * 2); - }); - }); -}); + expect(sut.countTimesTwo).toBe(newCount * 2) + }) + }) +}) describe("Counter", () => { - let id: number; - let model: CounterModel; - let sut: ReturnType; + let id: number + let model: CounterModel + let sut: ReturnType beforeEach(() => { - id = 1; - model = new CounterModel(new CounterEvents(eventProxy(), { id }), { id, count: 3 }); - sut = render(); - }); + id = 1 + model = new CounterModel(new CounterEvents(eventProxy(), { id }), { id, count: 3 }) + sut = render() + }) + + afterEach(cleanup) - test("shows count", () => expect(sut.getByTestId('count').textContent).toBe('3')); + test("shows count", () => expect(sut.getByTestId('count').textContent).toBe('3')) - test("shows count times two", () => expect(sut.getByTestId('countTimesTwo').textContent).toBe('6')); + test("shows count times two", () => expect(sut.getByTestId('countTimesTwo').textContent).toBe('6')) test("when '+' button clicked, count incremented", () => { - fireEvent.click(sut.getByText('+')); + fireEvent.click(sut.getByText('+')) - expect(sut.getByTestId('count').textContent).toBe('4'); - }); + expect(sut.getByTestId('count').textContent).toBe('4') + }) test("when '-' button clicked, count decremented", () => { - fireEvent.click(sut.getByText('-')); + fireEvent.click(sut.getByText('-')) - expect(sut.getByTestId('count').textContent).toBe('2'); - }); + expect(sut.getByTestId('count').textContent).toBe('2') + }) test("when '0' button clicked, count reset to 0", () => { - fireEvent.click(sut.getByText('0')); + fireEvent.click(sut.getByText('0')) - expect(sut.getByTestId('count').textContent).toBe('0'); - }); + expect(sut.getByTestId('count').textContent).toBe('0') + }) test("when 'Fetch' button clicked, count set to 100", async () => { - fireEvent.click(sut.getByText('Fetch')); + fireEvent.click(sut.getByText('Fetch')) await waitFor(() => { - expect(sut.getByTestId('count').textContent).toBe('100'); - }); - }); -}); + expect(sut.getByTestId('count').textContent).toBe('100') + }) + }) +}) diff --git a/tests/Hooks.test.ts b/tests/Hooks.test.ts index 7ce8bf5..513a3fd 100644 --- a/tests/Hooks.test.ts +++ b/tests/Hooks.test.ts @@ -1,113 +1,113 @@ -import { renderHook, RenderHookResult } from "@testing-library/react"; -import { beforeEach, describe, expect, test } from "bun:test"; -import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react"; -import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce"; -import { mutable } from "event-reduce/lib/testing"; -import { IDerivation } from "event-reduce"; -import { MockObserver } from "./MockObserver"; +import { renderHook, RenderHookResult } from "@testing-library/react" +import { beforeEach, describe, expect, test } from "bun:test" +import { useAsyncEvent, useDerived, useEvent, useReduced } from "event-reduce-react" +import { event, IEvent, model, reduce, reduced, type AsyncResult } from "event-reduce" +import { mutable } from "event-reduce/lib/testing" +import { IDerivation } from "event-reduce" +import { MockObserver } from "./MockObserver" describe("useEvent", () => { - let sut: RenderHookResult>, unknown>; + let sut: RenderHookResult>, unknown> let initialResult: IEvent beforeEach(() => { - sut = renderHook(() => useEvent()); - initialResult = sut.result.current; - }); + sut = renderHook(() => useEvent()) + initialResult = sut.result.current + }) test("returns an event", () => { let eventSpy = new MockObserver() - initialResult.subscribe(eventSpy); + initialResult.subscribe(eventSpy) - initialResult(3); + initialResult(3) - expect(eventSpy.sourceUpdated).toHaveBeenCalledWith(initialResult, 3); - }); + expect(eventSpy.sourceUpdated).toHaveBeenCalledWith(initialResult, 3) + }) test("when rendered again, returns the same event instance", () => { - sut.rerender(); + sut.rerender() - expect(sut.result.current).toBe(initialResult); - }); -}); + expect(sut.result.current).toBe(initialResult) + }) +}) describe("useAsyncEvent", () => { - let sut: RenderHookResult>, unknown>; - let initialResult: ReturnType>; + let sut: RenderHookResult>, unknown> + let initialResult: ReturnType> beforeEach(() => { - sut = renderHook(() => useAsyncEvent()); - initialResult = sut.result.current; - }); + sut = renderHook(() => useAsyncEvent()) + initialResult = sut.result.current + }) test("returns an async event", async () => { let eventSpy = new MockObserver>() - initialResult.resolved.subscribe(eventSpy); + initialResult.resolved.subscribe(eventSpy) - let promise = Promise.resolve(3); - initialResult(promise); - await promise; + let promise = Promise.resolve(3) + initialResult(promise) + await promise - expect(eventSpy.sourceUpdated).toHaveBeenCalledWith(initialResult.resolved, expect.objectContaining({ result: 3 })); - }); + expect(eventSpy.sourceUpdated).toHaveBeenCalledWith(initialResult.resolved, expect.objectContaining({ result: 3 })) + }) test("when rendered again, returns the same event instance", () => { - sut.rerender(); + sut.rerender() - expect(sut.result.current).toBe(initialResult); - }); -}); + expect(sut.result.current).toBe(initialResult) + }) +}) describe("useDerived", () => { - @model class SourceModel { @reduced get value() { return reduce(1).value; } } - let observable: ReturnType>; + @model class SourceModel { @reduced get value() { return reduce(1).value } } + let observable: ReturnType> let sut: RenderHookResult, unknown> let initialResult: IDerivation beforeEach(() => { - observable = mutable(new SourceModel()); - sut = renderHook(() => useDerived(() => observable.value * 2)); - initialResult = sut.result.current; - }); + observable = mutable(new SourceModel()) + sut = renderHook(() => useDerived(() => observable.value * 2)) + initialResult = sut.result.current + }) test("returns a derivation", () => { - expect(initialResult.value).toBe(2); + expect(initialResult.value).toBe(2) - observable.value = 3; + observable.value = 3 - expect(initialResult.value).toBe(6); - }); + expect(initialResult.value).toBe(6) + }) test("when rendered again, returns the same derivation instance", () => { - sut.rerender(); + sut.rerender() - expect(sut.result.current).toBe(initialResult); - }); -}); + expect(sut.result.current).toBe(initialResult) + }) +}) describe("useReduced", () => { - let added: ReturnType>; - let sut: RenderHookResult>, unknown>; - let initialResult: ReturnType>; + let added: ReturnType> + let sut: RenderHookResult>, unknown> + let initialResult: ReturnType> beforeEach(() => { - added = event(); + added = event() sut = renderHook(() => useReduced(1) - .on(added, (current, addedVal) => current + addedVal)); - initialResult = sut.result.current; - }); + .on(added, (current, addedVal) => current + addedVal)) + initialResult = sut.result.current + }) test("returns a reduction", () => { - expect(initialResult.value).toBe(1); + expect(initialResult.value).toBe(1) - added(2); + added(2) - expect(initialResult.value).toBe(3); - }); + expect(initialResult.value).toBe(3) + }) test("when rendered again, returns the same reduction instance", () => { - sut.rerender(); + sut.rerender() - expect(sut.result.current).toBe(initialResult); - }); -}); + expect(sut.result.current).toBe(initialResult) + }) +}) diff --git a/tests/MockObserver.ts b/tests/MockObserver.ts index 27d5754..0aa3829 100644 --- a/tests/MockObserver.ts +++ b/tests/MockObserver.ts @@ -1,5 +1,5 @@ -import { mock } from "bun:test"; -import { IObservable, IObserver } from "event-reduce"; +import { mock } from "bun:test" +import { IObservable, IObserver } from "event-reduce" export class MockObserver implements IObserver { displayName = 'mock observer' diff --git a/tests/Model.test.ts b/tests/Model.test.ts index 4640098..709253c 100644 --- a/tests/Model.test.ts +++ b/tests/Model.test.ts @@ -1,73 +1,73 @@ -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { asyncEvent, derive, derived, event, events, model, reduce, reduced, state } from "event-reduce"; -import { EventsMarkedAsStateError, MissingModelDecoratorError } from "event-reduce/lib/models"; -import { decrement, ExtendedModel } from "./Model.test/ExtendedModel"; -import { increment, TestModel } from "./Model.test/TestModel"; +import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { asyncEvent, derive, derived, event, events, model, reduce, reduced, state } from "event-reduce" +import { EventsMarkedAsStateError, MissingModelDecoratorError } from "event-reduce/lib/models" +import { decrement, ExtendedModel } from "./Model.test/ExtendedModel" +import { increment, TestModel } from "./Model.test/TestModel" describe("models", () => { describe("basic model tests", () => { - let testModel: TestModel; - let extendedModel: ExtendedModel; + let testModel: TestModel + let extendedModel: ExtendedModel beforeEach(() => { - testModel = new TestModel(); - extendedModel = new ExtendedModel(); - }); + testModel = new TestModel() + extendedModel = new ExtendedModel() + }) - test("property has initial value", () => expect(testModel.property).toBe(1)); + test("property has initial value", () => expect(testModel.property).toBe(1)) - test("accessor property has initial value", () => expect(testModel.accessorProp).toBe(1)); + test("accessor property has initial value", () => expect(testModel.accessorProp).toBe(1)) - test("extended property has same initial value", () => expect(extendedModel.property).toBe(1)); + test("extended property has same initial value", () => expect(extendedModel.property).toBe(1)) - test("extended accessor property has same initial value", () => expect(extendedModel.accessorProp).toBe(1)); + test("extended accessor property has same initial value", () => expect(extendedModel.accessorProp).toBe(1)) describe("when reduction updated", () => { beforeEach(() => { - increment(); - }); + increment() + }) - test("property value updated", () => expect(testModel.property).toBe(2)); + test("property value updated", () => expect(testModel.property).toBe(2)) - test("accessor property value updated", () => expect(testModel.accessorProp).toBe(2)); + test("accessor property value updated", () => expect(testModel.accessorProp).toBe(2)) - test("dependent property value updated", () => expect(testModel.dependentProperty).toBe(2)); + test("dependent property value updated", () => expect(testModel.dependentProperty).toBe(2)) - test("extended property value updated", () => expect(extendedModel.property).toBe(2)); + test("extended property value updated", () => expect(extendedModel.property).toBe(2)) - test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(2)); + test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(2)) - test("derived property value updated", () => expect(testModel.derivedProperty).toBe(4)); + test("derived property value updated", () => expect(testModel.derivedProperty).toBe(4)) - test("property based on derived value updated", () => expect(testModel.basedOnDerivedProperty).toBe(4)); - }); + test("property based on derived value updated", () => expect(testModel.basedOnDerivedProperty).toBe(4)) + }) describe("when extended reduction updated", () => { beforeEach(() => { - decrement(); - }); + decrement() + }) - test("property value unaffected", () => expect(testModel.property).toBe(1)); + test("property value unaffected", () => expect(testModel.property).toBe(1)) - test("accessor property value unaffected", () => expect(testModel.accessorProp).toBe(1)); + test("accessor property value unaffected", () => expect(testModel.accessorProp).toBe(1)) - test("extended property value updated", () => expect(extendedModel.property).toBe(0)); + test("extended property value updated", () => expect(extendedModel.property).toBe(0)) - test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(0)); - }); + test("extended accessor property value updated", () => expect(extendedModel.accessorProp).toBe(0)) + }) test("when derivation creates a new model that accesses an observable value in its constructor, accessed observable value is not counted as an observable for the derivation", () => { @model class DerivedModel { constructor() { - testModel.property; + testModel.property } } - let derivation = derive(() => new DerivedModel()); - derivation.update(); + let derivation = derive(() => new DerivedModel()) + derivation.update() - expect([...derivation.getSources()]).toHaveLength(0); - }); + expect([...derivation.getSources()]).toHaveLength(0) + }) test("when reducer creates a new model that observes the same event that created it, doesn't throw", () => { @model @@ -76,145 +76,145 @@ describe("models", () => { get child() { return reduce(null as TestModel | null) .on(increment, () => new TestModel()) - .value; + .value } } - let parentModel = new Parent(); + let parentModel = new Parent() - increment(undefined); - expect(parentModel.child).toBeInstanceOf(TestModel); - }); + increment(undefined) + expect(parentModel.child).toBeInstanceOf(TestModel) + }) test("when event is marked as state, throws when constructed", () => { @model class BadModel { @state - event = event('bad state'); + event = event('bad state') } try { - new BadModel(); - expect.unreachable("Should have thrown"); + new BadModel() + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(EventsMarkedAsStateError); - expect((e as EventsMarkedAsStateError).model).toBeDefined(); - expect((e as EventsMarkedAsStateError).property).toBe('event'); + expect(e).toBeInstanceOf(EventsMarkedAsStateError) + expect((e as EventsMarkedAsStateError).model).toBeDefined() + expect((e as EventsMarkedAsStateError).property).toBe('event') } - }); + }) test("when class is not decorated with model, @reduced throws when constructed", () => { class Bad { @reduced get x() { - return reduce(1).value; + return reduce(1).value } } - expect(() => new Bad()).toThrow(MissingModelDecoratorError); - }); + expect(() => new Bad()).toThrow(MissingModelDecoratorError) + }) test("when class is not decorated with model, @derived throws when constructed", () => { class Bad { @derived get x() { - return 1; + return 1 } } - expect(() => new Bad()).toThrow(MissingModelDecoratorError); - }); + expect(() => new Bad()).toThrow(MissingModelDecoratorError) + }) test("when class is not decorated with model, @state throws when constructed", () => { class Bad { @state - x = 1; + x = 1 } - expect(() => new Bad()).toThrow(MissingModelDecoratorError); - }); + expect(() => new Bad()).toThrow(MissingModelDecoratorError) + }) test("when base class defines toJSON, model decorator does not override it", () => { class BaseModel { toJSON() { - return { serializedValue: "base serializer" }; + return { serializedValue: "base serializer" } } } @model class DerivedModel extends BaseModel { @state - value = 1; + value = 1 } - let sut = new DerivedModel(); + let sut = new DerivedModel() - expect(sut.toJSON()).toEqual({ serializedValue: "base serializer" }); - expect(JSON.stringify(sut)).toBe(JSON.stringify({ serializedValue: "base serializer" })); - }); - }); -}); + expect(sut.toJSON()).toEqual({ serializedValue: "base serializer" }) + expect(JSON.stringify(sut)).toBe(JSON.stringify({ serializedValue: "base serializer" })) + }) + }) +}) describe("events decorator", () => { - let getterSpy: Mock<() => void>; + let getterSpy: Mock<() => void> @events class TestEvents { - eventField = asyncEvent(); + eventField = asyncEvent() get eventGetter() { - getterSpy(); - return asyncEvent(); + getterSpy() + return asyncEvent() } } - let sut: TestEvents; + let sut: TestEvents beforeEach(() => { - getterSpy = mock(); - sut = new TestEvents(); - }); + getterSpy = mock() + sut = new TestEvents() + }) - test("keeps class name", () => expect(TestEvents.name).toBe('TestEvents')); + test("keeps class name", () => expect(TestEvents.name).toBe('TestEvents')) test("sets event name", () => { - expect(sut.eventField.displayName).toBe('eventField'); - expect(sut.eventField.started.displayName).toBe('eventField.started'); - expect(sut.eventField.resolved.displayName).toBe('eventField.resolved'); - expect(sut.eventField.rejected.displayName).toBe('eventField.rejected'); + expect(sut.eventField.displayName).toBe('eventField') + expect(sut.eventField.started.displayName).toBe('eventField.started') + expect(sut.eventField.resolved.displayName).toBe('eventField.resolved') + expect(sut.eventField.rejected.displayName).toBe('eventField.rejected') - expect(sut.eventGetter.displayName).toBe('eventGetter'); - expect(sut.eventGetter.started.displayName).toBe('eventGetter.started'); - expect(sut.eventGetter.resolved.displayName).toBe('eventGetter.resolved'); - expect(sut.eventGetter.rejected.displayName).toBe('eventGetter.rejected'); - }); + expect(sut.eventGetter.displayName).toBe('eventGetter') + expect(sut.eventGetter.started.displayName).toBe('eventGetter.started') + expect(sut.eventGetter.resolved.displayName).toBe('eventGetter.resolved') + expect(sut.eventGetter.rejected.displayName).toBe('eventGetter.rejected') + }) test("sets event parents", () => { - expect(sut.eventField.path).toBe('TestEvents.eventField'); - expect(sut.eventGetter.path).toBe('TestEvents.eventGetter'); - }); + expect(sut.eventField.path).toBe('TestEvents.eventField') + expect(sut.eventGetter.path).toBe('TestEvents.eventGetter') + }) test("snapshots getter event", () => { - sut.eventGetter; - sut.eventGetter; + sut.eventGetter + sut.eventGetter - expect(getterSpy).toHaveBeenCalledTimes(1); - }); + expect(getterSpy).toHaveBeenCalledTimes(1) + }) test("when event class is marked as state, throws when constructed", () => { @model class BadModel { @state - events = new TestEvents(); + events = new TestEvents() } try { - new BadModel(); - expect.unreachable("Should have thrown"); + new BadModel() + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(EventsMarkedAsStateError); - expect((e as EventsMarkedAsStateError).model).toBeDefined(); - expect((e as EventsMarkedAsStateError).property).toBe('events'); + expect(e).toBeInstanceOf(EventsMarkedAsStateError) + expect((e as EventsMarkedAsStateError).model).toBeDefined() + expect((e as EventsMarkedAsStateError).property).toBe('events') } - }); -}); + }) +}) diff --git a/tests/Model.test/ExtendedModel.ts b/tests/Model.test/ExtendedModel.ts index dc03f6f..b1428f2 100644 --- a/tests/Model.test/ExtendedModel.ts +++ b/tests/Model.test/ExtendedModel.ts @@ -1,5 +1,5 @@ -import { event, extendReduce, model, reduced } from "event-reduce"; -import { TestModel } from "./TestModel"; +import { event, extendReduce, model, reduced } from "event-reduce" +import { TestModel } from "./TestModel" // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved diff --git a/tests/Model.test/TestModel.ts b/tests/Model.test/TestModel.ts index 6f73922..6217d56 100644 --- a/tests/Model.test/TestModel.ts +++ b/tests/Model.test/TestModel.ts @@ -1,4 +1,4 @@ -import { derive, derived, event, model, reduce, reduced } from "event-reduce"; +import { derive, derived, event, model, reduce, reduced } from "event-reduce" // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -10,33 +10,33 @@ export class TestModel { get property() { return reduce(1) .on(increment, c => c + 1) - .value; + .value } @reduced accessor accessorProp = reduce(1) .on(increment, c => c + 1) - .value; + .value @reduced get dependentProperty() { return reduce(1) .on(derive(() => this.property), (_, p) => p) - .value; + .value } @derived get derivedProperty() { - return this.property * 2; + return this.property * 2 } @derived - get bool() { return true; } + get bool() { return true } @reduced get basedOnDerivedProperty() { return reduce(0) .on(derive(() => this.derivedProperty), (_, d) => d) - .value; + .value } } diff --git a/tests/Observable.test.ts b/tests/Observable.test.ts index a02c9d8..1ac2d02 100644 --- a/tests/Observable.test.ts +++ b/tests/Observable.test.ts @@ -1,69 +1,69 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { ISignal, signal, type IObservable } from "event-reduce"; -import { MockObserver } from "./MockObserver"; +import { beforeEach, describe, expect, test } from "bun:test" +import { ISignal, signal, type IObservable } from "event-reduce" +import { MockObserver } from "./MockObserver" describe("Observable", () => { - let sut: ISignal; - let observer: MockObserver; + let sut: ISignal + let observer: MockObserver beforeEach(() => { - sut = signal('initial', 'sut'); - observer = new MockObserver(); - sut.subscribe(observer); - }); + sut = signal('initial', 'sut') + observer = new MockObserver() + sut.subscribe(observer) + }) test("when updated, notifies subscribed observers", () => { - sut.setValue('updated'); + sut.setValue('updated') - expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'updated'); - }); + expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'updated') + }) test("when unsubscribed, stops notifying observer", () => { - sut.unsubscribe(observer); + sut.unsubscribe(observer) - sut.setValue('updated'); + sut.setValue('updated') - expect(observer.sourceUpdated).not.toHaveBeenCalled(); - }); + expect(observer.sourceUpdated).not.toHaveBeenCalled() + }) test("map", () => { - let mapped = sut.map(value => value.toUpperCase()); - let observed = values(mapped); + let mapped = sut.map(value => value.toUpperCase()) + let observed = values(mapped) - sut.setValue('a'); - sut.setValue('b'); + sut.setValue('a') + sut.setValue('b') - expect(observed).toEqual(['A', 'B']); - }); + expect(observed).toEqual(['A', 'B']) + }) test("filter", () => { - let filtered = sut.filter(value => value.length > 1); - let observed = values(filtered); + let filtered = sut.filter(value => value.length > 1) + let observed = values(filtered) - sut.setValue('a'); - sut.setValue('bb'); - sut.setValue('ccc'); + sut.setValue('a') + sut.setValue('bb') + sut.setValue('ccc') - expect(observed).toEqual(['bb', 'ccc']); - }); + expect(observed).toEqual(['bb', 'ccc']) + }) test("filterMap", () => { - let filtered = sut.filterMap(value => value.length > 1 ? value.length : undefined); - let observed = values(filtered); + let filtered = sut.filterMap(value => value.length > 1 ? value.length : undefined) + let observed = values(filtered) - sut.setValue('a'); - sut.setValue('bb'); - sut.setValue('cccc'); + sut.setValue('a') + sut.setValue('bb') + sut.setValue('cccc') - expect(observed).toEqual([2, 4]); - }); + expect(observed).toEqual([2, 4]) + }) function values(observable: IObservable) { - let observed: T[] = []; + let observed: T[] = [] observable.subscribe({ displayName: 'value collector', sourceUpdated: (_, value) => observed.push(value) - }); - return observed; + }) + return observed } -}); \ No newline at end of file +}) \ No newline at end of file diff --git a/tests/ObservableValue.test.ts b/tests/ObservableValue.test.ts index 000bd52..8223d82 100644 --- a/tests/ObservableValue.test.ts +++ b/tests/ObservableValue.test.ts @@ -1,43 +1,43 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { ISignal, signal } from "event-reduce"; -import { getUnderlyingObservable } from "event-reduce/lib/tracking"; -import { MockObserver } from "./MockObserver"; +import { beforeEach, describe, expect, test } from "bun:test" +import { ISignal, signal } from "event-reduce" +import { getUnderlyingObservable } from "event-reduce/lib/tracking" +import { MockObserver } from "./MockObserver" describe("signal", () => { - let sut: ISignal; - let observer: MockObserver; + let sut: ISignal + let observer: MockObserver beforeEach(() => { - sut = signal('initial', 'sut'); - observer = new MockObserver(); - sut.subscribe(observer); - }); + sut = signal('initial', 'sut') + observer = new MockObserver() + sut.subscribe(observer) + }) describe("when value accessed", () => { - let result: string; + let result: string beforeEach(() => { - result = sut.value; - }); + result = sut.value + }) - test("returns provided value", () => expect(result).toBe('initial')); + test("returns provided value", () => expect(result).toBe('initial')) - test("tracks the accessed observable", () => expect(getUnderlyingObservable(result)).toBe(sut)); - }); + test("tracks the accessed observable", () => expect(getUnderlyingObservable(result)).toBe(sut)) + }) describe("when value changed", () => { beforeEach(() => { - sut.setValue('different'); - }); + sut.setValue('different') + }) - test("updates the value", () => expect(sut.value).toBe('different')); + test("updates the value", () => expect(sut.value).toBe('different')) - test("notifies observers", () => expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'different')); - }); + test("notifies observers", () => expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'different')) + }) test("when value set to same value, observers are notified", () => { - sut.setValue('initial'); + sut.setValue('initial') - expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'initial'); - }); -}); \ No newline at end of file + expect(observer.sourceUpdated).toHaveBeenCalledWith(sut, 'initial') + }) +}) \ No newline at end of file diff --git a/tests/Reduction.test.ts b/tests/Reduction.test.ts index b5fc445..010fd84 100644 --- a/tests/Reduction.test.ts +++ b/tests/Reduction.test.ts @@ -1,152 +1,152 @@ -import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { createModel, event, events, IReduction, ISignal, reduce, signal } from 'event-reduce'; -import { InvalidStateError } from "event-reduce/lib/observable"; -import { CircularSubscriptionError } from "event-reduce/lib/reduction"; -import { CircularDependencyError, collectAccessedSources } from "event-reduce/lib/tracking"; -import { dispose } from "event-reduce/lib/utils"; -import { MockObserver } from "./MockObserver"; +import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { createModel, event, events, IReduction, ISignal, reduce, signal } from 'event-reduce' +import { InvalidStateError } from "event-reduce/lib/observable" +import { CircularSubscriptionError } from "event-reduce/lib/reduction" +import { CircularDependencyError, collectAccessedSources } from "event-reduce/lib/tracking" +import { dispose } from "event-reduce/lib/utils" +import { MockObserver } from "./MockObserver" describe("reduce", () => { describe("when unbound", () => { - let subscriber: MockObserver; - let sut: IReduction; + let subscriber: MockObserver + let sut: IReduction beforeEach(() => { - subscriber = new MockObserver(); - sut = createModel(() => reduce(1, 'sut')); - sut.subscribe(subscriber); - }); + subscriber = new MockObserver() + sut = createModel(() => reduce(1, 'sut')) + sut.subscribe(subscriber) + }) afterEach(() => { - sut[dispose](); - }); + sut[dispose]() + }) - test("starts with initial value", () => expect(sut.value).toBe(1)); + test("starts with initial value", () => expect(sut.value).toBe(1)) describe("when subscribed to an observable", () => { - let observableValue: ISignal; - let reducer: Mock<(previous: number, event: string) => number>; + let observableValue: ISignal + let reducer: Mock<(previous: number, event: string) => number> beforeEach(() => { - observableValue = signal('test', 'test'); - reducer = mock(); - sut.on(observableValue, reducer); - }); + observableValue = signal('test', 'test') + reducer = mock() + sut.on(observableValue, reducer) + }) describe("when observable emits a value", () => { beforeEach(() => { - reducer.mockReturnValue(3); - observableValue.setValue('foo'); - }); + reducer.mockReturnValue(3) + observableValue.setValue('foo') + }) test("reducer called with previous value and observable value", () => { - expect(reducer).toHaveBeenCalledWith(1, 'foo'); - }); + expect(reducer).toHaveBeenCalledWith(1, 'foo') + }) - test("value becomes return value of reducer", () => expect(sut.value).toBe(3)); + test("value becomes return value of reducer", () => expect(sut.value).toBe(3)) describe("when another value is emitted", () => { beforeEach(() => { - reducer.mockReturnValue(4); - observableValue.setValue('bar'); - }); + reducer.mockReturnValue(4) + observableValue.setValue('bar') + }) test("reducer called with previous value and observable value", () => { - expect(reducer).toHaveBeenCalledWith(3, 'bar'); - }); + expect(reducer).toHaveBeenCalledWith(3, 'bar') + }) - test("value becomes return value of reducer", () => expect(sut.value).toBe(4)); - }); - }); - }); + test("value becomes return value of reducer", () => expect(sut.value).toBe(4)) + }) + }) + }) describe("when a reducer accesses a reduced value", () => { - let other: ReturnType>; - let observable: ISignal; + let other: ReturnType> + let observable: ISignal beforeEach(() => { - other = createModel(() => reduce(0)); - observable = signal(0, 'test'); - sut.on(observable, () => other.value); - }); + other = createModel(() => reduce(0)) + observable = signal(0, 'test') + sut.on(observable, () => other.value) + }) afterEach(() => { - other[dispose](); + other[dispose]() }) test("when other value based on same source, uses latest value", () => { - other.on(observable, (_, value) => value); + other.on(observable, (_, value) => value) - observable.setValue(3); + observable.setValue(3) - expect(other.value).toBe(3); - expect(sut.value).toBe(3); - }); + expect(other.value).toBe(3) + expect(sut.value).toBe(3) + }) test("when other value also accesses current reduction, throws", () => { - other.on(observable, () => sut.value); + other.on(observable, () => sut.value) try { - observable.setValue(1); - expect.unreachable("Should have thrown"); + observable.setValue(1) + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(CircularDependencyError); + expect(e).toBeInstanceOf(CircularDependencyError) } - }); + }) test("when other value not based on same event, doesn't throw", () => { - observable.setValue(0); - }); - }); + observable.setValue(0) + }) + }) test("when subscribing to an observable based on itself, throws", () => { - let observable = sut.filter(n => n > 3); + let observable = sut.filter(n => n > 3) try { - sut.on(observable, (_, n) => n); - expect.unreachable("Should have thrown"); + sut.on(observable, (_, n) => n) + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(CircularSubscriptionError); - expect((e as CircularSubscriptionError).source).toBe(observable); - expect((e as CircularSubscriptionError).reduction).toBe(sut); + expect(e).toBeInstanceOf(CircularSubscriptionError) + expect((e as CircularSubscriptionError).source).toBe(observable) + expect((e as CircularSubscriptionError).reduction).toBe(sut) } - }); - }); + }) + }) describe("when bound to events object", () => { - let eventsObj: {}; - let sut: IReduction; + let eventsObj: {} + let sut: IReduction beforeEach(() => { - eventsObj = {}; - sut = createModel(() => reduce(1, eventsObj)); - }); + eventsObj = {} + sut = createModel(() => reduce(1, eventsObj)) + }) afterEach(() => { - sut[dispose](); + sut[dispose]() }) describe("when subscribing to an observable", () => { - let observableValue: ISignal; - let getEvent: Mock<(events: typeof eventsObj) => ISignal>; - let reducer: Mock<(previous: number, event: string) => number>; + let observableValue: ISignal + let getEvent: Mock<(events: typeof eventsObj) => ISignal> + let reducer: Mock<(previous: number, event: string) => number> beforeEach(() => { - observableValue = signal('test', 'test'); - getEvent = mock(() => observableValue); - reducer = mock(); - sut.on(getEvent, reducer); - }); + observableValue = signal('test', 'test') + getEvent = mock(() => observableValue) + reducer = mock() + sut.on(getEvent, reducer) + }) test("event getter called with bound events", () => { - expect(getEvent).toHaveBeenCalledWith(eventsObj); - }); + expect(getEvent).toHaveBeenCalledWith(eventsObj) + }) test("reduction subscribed to result of event getter", () => { - observableValue.setValue('foo'); - expect(reducer).toHaveBeenCalledWith(1, 'foo'); - }); - }); - }); + observableValue.setValue('foo') + expect(reducer).toHaveBeenCalledWith(1, 'foo') + }) + }) + }) test("accessed reductions updated when value is accessed", () => { using sut = createModel(() => ({ @@ -155,48 +155,48 @@ describe("reduce", () => { })) using tracker = collectAccessedSources(null) - sut.r1.value; - sut.r2.value; + sut.r1.value + sut.r2.value - expect(tracker.accessed).toContain(sut.r1 as any); - expect(tracker.accessed).toContain(sut.r2 as any); - }); + expect(tracker.accessed).toContain(sut.r1 as any) + expect(tracker.accessed).toContain(sut.r2 as any) + }) test("when reducer returns an event, throws", () => { - let eventValue = event('test event'); - let subject = signal('test', 'test'); + let eventValue = event('test event') + let subject = signal('test', 'test') using sut = createModel(() => ({ reduction: reduce(null as any, 'sut') .on(subject, () => eventValue) })) try { - subject.setValue('foo'); - expect.unreachable("Should have thrown"); + subject.setValue('foo') + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(InvalidStateError); - expect((e as InvalidStateError).observable).toBe(sut.reduction); - expect((e as InvalidStateError).value).toBe(eventValue); + expect(e).toBeInstanceOf(InvalidStateError) + expect((e as InvalidStateError).observable).toBe(sut.reduction) + expect((e as InvalidStateError).value).toBe(eventValue) } - }); + }) test("when reducer returns an events object, throws", () => { @events class Events { } - let eventsValue = new Events(); - let subject = signal('test', 'test'); + let eventsValue = new Events() + let subject = signal('test', 'test') using sut = createModel(() => ({ reduction: reduce(null as any, 'sut') .on(subject, () => eventsValue) })) try { - subject.setValue('foo'); - expect.unreachable("Should have thrown"); + subject.setValue('foo') + expect.unreachable("Should have thrown") } catch (e) { - expect(e).toBeInstanceOf(InvalidStateError); - expect((e as InvalidStateError).observable).toBe(sut.reduction); - expect((e as InvalidStateError).value).toBe(eventsValue); + expect(e).toBeInstanceOf(InvalidStateError) + expect((e as InvalidStateError).observable).toBe(sut.reduction) + expect((e as InvalidStateError).value).toBe(eventsValue) } - }); -}); + }) +}) diff --git a/tests/Rendering.test.ts b/tests/Rendering.test.ts index 385237e..27650ce 100644 --- a/tests/Rendering.test.ts +++ b/tests/Rendering.test.ts @@ -1,41 +1,41 @@ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test"; -import { ISignal, signal } from "event-reduce"; -import { useReactive } from "event-reduce-react"; +import { act, renderHook } from "@testing-library/react" +import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test" +import { ISignal, signal } from "event-reduce" +import { useReactive } from "event-reduce-react" describe('useReactive', () => { - let observable: ISignal; - let innerBehaviour: () => void; - let render: Mock<() => string>; - let sut: ReturnType>; + let observable: ISignal + let innerBehaviour: () => void + let render: Mock<() => string> + let sut: ReturnType> beforeEach(() => { - observable = signal('initial value', 'test value'); - innerBehaviour = () => { }; + observable = signal('initial value', 'test value') + innerBehaviour = () => { } render = mock(() => { - innerBehaviour(); - return observable.value; - }); - sut = renderHook(() => useReactive(render)); - }); + innerBehaviour() + return observable.value + }) + sut = renderHook(() => useReactive(render)) + }) - test("render function called immediately", () => expect(render).toHaveBeenCalledTimes(1)); + test("render function called immediately", () => expect(render).toHaveBeenCalledTimes(1)) - test("returns result of render function", () => expect(sut.result.current).toBe('initial value')); + test("returns result of render function", () => expect(sut.result.current).toBe('initial value')) test("when re-rendered, render function called again", () => { - sut.rerender(); + sut.rerender() - expect(render).toHaveBeenCalledTimes(2); // In case render function uses hooks - }); + expect(render).toHaveBeenCalledTimes(2) // In case render function uses hooks + }) describe("when accessed observable value changed", () => { beforeEach(() => { - act(() => observable.setValue('new value')); - }); + act(() => observable.setValue('new value')) + }) - test("render function called again", () => expect(render).toHaveBeenCalledTimes(2)); + test("render function called again", () => expect(render).toHaveBeenCalledTimes(2)) - test("returns updated result", () => expect(sut.result.current).toBe('new value')); - }); -}); + test("returns updated result", () => expect(sut.result.current).toBe('new value')) + }) +}) diff --git a/tests/State.test.ts b/tests/State.test.ts index adecffe..ee6c888 100644 --- a/tests/State.test.ts +++ b/tests/State.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, test } from "bun:test"; -import { getState, stringifyState } from "event-reduce"; -import { TestModel } from "./State.test/TestModel"; +import { describe, expect, test } from "bun:test" +import { getState, stringifyState } from "event-reduce" +import { TestModel } from "./State.test/TestModel" describe("state", () => { test("getState copies the correct properties", () => { - let testModel = new TestModel('ctor'); - let result = getState(testModel); + let testModel = new TestModel('ctor') + let result = getState(testModel) expect(JSON.stringify(result)).toBe(JSON.stringify({ valueProperty: 1, reducedModel: { value: 'child' }, @@ -16,12 +16,12 @@ describe("state", () => { mergedModel: { value: 'merged' }, subClassProperty: 'subClass', constructorProperty: 'ctor', - })); - }); + })) + }) test("getState can include derived properties", () => { - let testModel = new TestModel('ctor'); - let result = getState(testModel, true); + let testModel = new TestModel('ctor') + let result = getState(testModel, true) expect(JSON.stringify(result)).toBe(JSON.stringify({ valueProperty: 1, valuePlusOne: 2, @@ -33,13 +33,13 @@ describe("state", () => { mergedModel: { value: 'merged' }, subClassProperty: 'subClass', constructorProperty: 'ctor', - })); - }); + })) + }) test("includeDerived is not cached between calls", () => { - let testModel = new TestModel('ctor'); - stringifyState(testModel); - let result = stringifyState(testModel, true); - expect(result).toContain('"valuePlusOne":2'); - }); -}); + let testModel = new TestModel('ctor') + stringifyState(testModel) + let result = stringifyState(testModel, true) + expect(result).toContain('"valuePlusOne":2') + }) +}) diff --git a/tests/State.test/BaseModel.ts b/tests/State.test/BaseModel.ts index e7ccbd7..3fdef2a 100644 --- a/tests/State.test/BaseModel.ts +++ b/tests/State.test/BaseModel.ts @@ -1,5 +1,5 @@ -import { derived, model, reduce, reduced, state } from "event-reduce"; -import { ChildModel } from "./ChildModel"; +import { derived, model, reduce, reduced, state } from "event-reduce" +import { ChildModel } from "./ChildModel" // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -9,24 +9,24 @@ export class BaseModel { constructor(public constructorProperty: string) { } @reduced - get valueProperty() { return reduce(1).value; } + get valueProperty() { return reduce(1).value } @derived - get valuePlusOne() { return this.valueProperty + 1; } + get valuePlusOne() { return this.valueProperty + 1 } func() { } @reduced - get reducedModel() { return reduce(new ChildModel('child')).value; } + get reducedModel() { return reduce(new ChildModel('child')).value } @reduced get modelArray() { return reduce([new ChildModel('one'), new ChildModel('two')]) - .value; + .value } @state - mergedModel = new ChildModel('merged'); + mergedModel = new ChildModel('merged') - ignoredValue = 'ignored'; + ignoredValue = 'ignored' } diff --git a/tests/State.test/ChildModel.ts b/tests/State.test/ChildModel.ts index 8eb26bd..c2c1f0c 100644 --- a/tests/State.test/ChildModel.ts +++ b/tests/State.test/ChildModel.ts @@ -1,4 +1,4 @@ -import { model, reduce, reduced } from "event-reduce"; +import { model, reduce, reduced } from "event-reduce" // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @@ -7,5 +7,5 @@ export class ChildModel { constructor(private _initialValue: string) { } @reduced - get value() { return reduce(this._initialValue).value; } + get value() { return reduce(this._initialValue).value } } diff --git a/tests/State.test/TestModel.ts b/tests/State.test/TestModel.ts index 791ab39..bec02dc 100644 --- a/tests/State.test/TestModel.ts +++ b/tests/State.test/TestModel.ts @@ -1,10 +1,10 @@ -import { model, state } from "event-reduce"; -import { BaseModel } from "./BaseModel"; +import { model, state } from "event-reduce" +import { BaseModel } from "./BaseModel" // TODO move back into Model.test.ts after https://github.com/oven-sh/bun/issues/28010 is resolved @model export class TestModel extends BaseModel { @state - subClassProperty = 'subClass'; + subClassProperty = 'subClass' } diff --git a/tests/Testing.test.ts b/tests/Testing.test.ts index 3276d84..7a9b577 100644 --- a/tests/Testing.test.ts +++ b/tests/Testing.test.ts @@ -23,7 +23,7 @@ describe("mutable", () => { } @derived - get valuePlusOne() { return this.value + 1; } + get valuePlusOne() { return this.value + 1 } } let baseModel: Model