From 95d476cc16ef38dca81b03ea1946294defc235e7 Mon Sep 17 00:00:00 2001 From: nihil-pro Date: Tue, 19 Nov 2024 22:23:06 +0300 Subject: [PATCH 1/3] component wasn't rerender because of useCallback without dependencies fixed serializer Symbol --- .idea/vcs.xml | 6 ++++++ src/Observable.ts | 2 +- src/observer.hoc.ts | 6 +++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.idea/vcs.xml b/.idea/vcs.xml index e69de29..35eb1dd 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Observable.ts b/src/Observable.ts index a098920..6ffcb4a 100644 --- a/src/Observable.ts +++ b/src/Observable.ts @@ -159,7 +159,7 @@ function observableProxyHandler(adm: ObservableAdministration) { const value = Reflect.get(target, property, receiver); // for serializer - if (property === Symbol.for(`type:${property.toString()}`)) { + if (typeof property === 'symbol') { return value; } diff --git a/src/observer.hoc.ts b/src/observer.hoc.ts index 41f03c9..985cc42 100644 --- a/src/observer.hoc.ts +++ b/src/observer.hoc.ts @@ -15,11 +15,11 @@ interface ObserverOptions { name?: string } -function useObservable(fn: () => T, name: string, options = {} as ObserverOptions) { +function useObservable(fn: () => T, name: string, options = {} as ObserverOptions, props: any) { const debugName = options?.name || name const debug = options?.debug || false const { 0: value, 1: render } = useState(0) - const work = useCallback(fn, []) + const work = useCallback(fn, [props]) const cb = useCallback((reason?: Set) => { render((prev) => 1 - prev) if (debug) { @@ -63,7 +63,7 @@ export function observer( rc: ForwardRefRenderFunction | FunctionComponent | ForwardRefExoticComponent & RefAttributes>, options?: ObserverOptions ) { - let observedComponent = (props: any, ref: Ref) => useObservable(() => rc(props, ref), rc.name, options) + let observedComponent = (props: any, ref: Ref) => useObservable(() => rc(props, ref), rc.name, options, props) observedComponent = memo(observedComponent) return observedComponent } From 641e9cf8c908175f74cc932dd0054d453b5ee5a9 Mon Sep 17 00:00:00 2001 From: nihil-pro Date: Tue, 26 Nov 2024 18:45:26 +0300 Subject: [PATCH 2/3] fixed computed properties --- package.json | 2 +- src/Observable.transaction.ts | 14 ++++++-------- src/Observable.ts | 33 +++++++++++++++++++++++++++++++++ src/tests/Observable.test.ts | 3 ++- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 0c2e0a4..5950126 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kr-observable", - "version": "1.0.16", + "version": "1.0.18", "description": "A proxy-based observable with a hoc for react/preact", "module": "./dist/index.js", "main": "./dist/index.js", diff --git a/src/Observable.transaction.ts b/src/Observable.transaction.ts index 7bd8c74..a58fb1e 100644 --- a/src/Observable.transaction.ts +++ b/src/Observable.transaction.ts @@ -15,14 +15,13 @@ interface TransactionResult { } class ObservableTransactionsImpl { - static #current: Function = null; static #track: Map = new Map(); + static #stack: Function[] = [] static report(administration: ObservableAdministration, property: string | symbol) { - if (!this.#current || typeof property === 'symbol') { - return; - } - const stats = this.#track.get(this.#current); + const current = this.#stack.at(-1) + if (!current || typeof property === 'symbol') { return; } + const stats = this.#track.get(current); if (stats) { let read = stats.read.get(administration); if (!read) { @@ -42,15 +41,14 @@ class ObservableTransactionsImpl { let result: any; let exception!: Error; try { - this.#current = work; + this.#stack.push(work) result = work(); - stats = this.#track.get(work); + this.#stack.pop() stats.count++; stats.read.forEach((k, o) => o.subscribe(cb, k)); } catch (e) { exception = e as Error; } - this.#current = null; return { stats, result, diff --git a/src/Observable.ts b/src/Observable.ts index 6ffcb4a..14c90b1 100644 --- a/src/Observable.ts +++ b/src/Observable.ts @@ -217,11 +217,44 @@ function structureProxyHandler(property: string | symbol, adm: ObservableAdminis }; } + +function computed(property: string | symbol, descriptor: PropertyDescriptor, adm: ObservableAdministration): PropertyDescriptor { + let firstCall = true + let value: any + return { + ...descriptor, + get() { + ObservableTransactions.report(adm, property) + const work = () => { + const newValue = descriptor.get.call(this) + if (!firstCall && value !== newValue) { + adm.report(property, newValue) + } + value = newValue + if (firstCall) { + ObservableTransactions.transaction(work, work) + firstCall = false + } + return value + } + } + } +} + + export class Observable { [isObservable] = true constructor() { const adm = new ObservableAdministration(); Reflect.set(adm, Symbol.for('whoami'), this) + const proto = Reflect.getPrototypeOf(this) + Reflect.ownKeys(proto) + .forEach(property => { + const descriptor = Reflect.getOwnPropertyDescriptor(proto, property) + if (descriptor.get) { + Reflect.defineProperty(proto, property, computed(property, descriptor, adm)); + } + }) return new Proxy(this, observableProxyHandler(adm)) } } diff --git a/src/tests/Observable.test.ts b/src/tests/Observable.test.ts index 01a85b8..6384667 100644 --- a/src/tests/Observable.test.ts +++ b/src/tests/Observable.test.ts @@ -371,4 +371,5 @@ describe('Observable Array', () => { withArray.unsubscribe(onSizeChange) withArray.array = [] }) -}) \ No newline at end of file +}) + From 467b958f4ddf21adbcf6daced64c696cf9f2ddd5 Mon Sep 17 00:00:00 2001 From: nihil-pro Date: Sun, 15 Dec 2024 13:09:28 +0300 Subject: [PATCH 3/3] add api to ignore properties --- package.json | 3 +- readme.md | 19 +- src/Observable.administration.ts | 109 +++-- src/Observable.computed.ts | 48 ++ src/Observable.map.ts | 3 + src/Observable.set.ts | 3 + src/Observable.transaction.ts | 90 ++-- src/Observable.ts | 221 +++++---- src/Subscribers.notifier.ts | 28 +- src/observer.hoc.ts | 62 ++- src/tests/Observable.test.ts | 741 +++++++++++++++---------------- 11 files changed, 735 insertions(+), 592 deletions(-) create mode 100644 src/Observable.computed.ts diff --git a/package.json b/package.json index 5950126..867fd18 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "kr-observable", - "version": "1.0.18", + "version": "1.0.22", "description": "A proxy-based observable with a hoc for react/preact", "module": "./dist/index.js", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "type": "module", "scripts": { "build": "rm -rf dist && tsc", "test": "node --test --import tsx --experimental-strip-types" diff --git a/readme.md b/readme.md index de1fc26..02b63a9 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,7 @@ # Observable -Proxy based state-manager -1. Easy to use, see examples below -2. Supports classes and plain objects +A proxy based state manager & reactive programming library +1. Easy to use and great DX. See examples below; +2. Supports classes and plain objects; 3. Supports subclassing 4. Tiny, no dependencies 5. Framework-agnostic.
@@ -77,6 +77,7 @@ const Component = observer(function component() { ``` More complicated example on [CodeSandbox](https://codesandbox.io/p/sandbox/v7zf47) + ## Api reference ### observer @@ -170,6 +171,18 @@ example.plain.foo = '' // foo was changed, new value = '' example.plain.nestedArray.push(42) // nestedArray was changed, new value = 42 ``` +### Ignore properties +The static `ignore` property allows you to exclude some properties +```typescript +import { Observable } from 'kr-observable'; + +class Foo extends Observable { + static ignore = ['foo'] + foo = 1 // won't be observable + bar = 2 +} +``` + ### makeObservable Has the same API as Observable, but works only with plain objects ```typescript diff --git a/src/Observable.administration.ts b/src/Observable.administration.ts index b95b71b..2860321 100644 --- a/src/Observable.administration.ts +++ b/src/Observable.administration.ts @@ -2,45 +2,94 @@ import { SubscribersNotifier } from './Subscribers.notifier.js'; import { Listener, Subscriber } from './types.js'; export class ObservableAdministration { - #subscribers: Map> = new Map(); - #listeners: Set = new Set(); - #changes: Set = new Set(); - #reportable = false + state = 0 + ignore = Object.create(null); + subscribers: Map> = new Map; + listeners: Set | undefined; + changes: Set = new Set(); + reportable = false + notified: Set = new Set; subscribe = (subscriber: Subscriber, keys: Set) => { - if (this.#subscribers.size < this.#subscribers.set(subscriber, keys).size) { - this.#reportable = true + if (this.subscribers.size < this.subscribers.set(subscriber, keys).size) { + this.reportable = true } }; - unsubscribe = (subscriber: Subscriber) => { - this.#subscribers.delete(subscriber) - if (this.#listeners.size === 0 && this.#subscribers.size === 0) { this.#reportable = false} - }; listen = (listener: Listener) => { - if (this.#listeners.size < this.#listeners.add(listener).size) { - this.#reportable = true + if (!this.listeners) { + this.listeners = new Set() + this.reportable = true + } + this.listeners.add(listener) + }; + unsubscribe = (subscriber: Subscriber) => { + this.subscribers.delete(subscriber) + if (this.subscribers.size === 0) { + if (this.listeners?.size === 0) { + this.reportable = false + } } }; unlisten = (listener: Listener) => { - this.#listeners.delete(listener) - if (this.#listeners.size === 0 && this.#subscribers.size === 0) { this.#reportable = false} + this.listeners.delete(listener) + if (this.listeners.size === 0) { + this.listeners = undefined + if (this.subscribers?.size === 0) { + this.reportable = false + } + } }; - report = (property: string | symbol, value: any) => { - if (!this.#reportable) { return } - this.#listeners.forEach(cb => cb(property, value)); - if (this.#changes.size < this.#changes.add(property).size) { - this.#notify(); + batch = () => { + // toDo + // testing new strategy + if (this.state === 1) { + this.state = 0 + this.notify() } + } + report = (property: string | symbol, value: any) => { + if (!this.reportable) { return } + this.listeners?.forEach(cb => cb(property, value)); + this.changes.add(property) }; - #notify() { - const notified: Set = new Set(); - this.#changes.forEach(change => { - this.#subscribers.forEach((keys, cb) => { - if (keys.has(change) && !notified.has(cb)) { - SubscribersNotifier.notify(cb, this.#changes); - notified.add(cb); + skipped = false + notify() { + if (this.changes.size === 0) { return; } + const changes = new Set(this.changes) + this.changes.clear() + this.subscribers.forEach((keys, cb) => { + let isSubscribed = false + for (const k of keys) { + if (changes.has(k)) { + isSubscribed = true; + break } - }); - this.#changes.delete(change); - }); + } + if (isSubscribed && !this.notified.has(cb)) { + const s = this.changes.size + SubscribersNotifier.notify(cb, new Set(changes)); + if (this.changes.size === s) { + this.notified.add(cb) + } else { + this.skipped = true + this.notify() + } + } + }) + queueMicrotask(() => { + if (!this.skipped) { + this.notified.clear() + this.changes.clear() + this.skipped = false + } + }) } -} \ No newline at end of file +} + +const trap = Object.create(null) +trap.report = 1 +trap.subscribe = 1 +trap.unsubscribe = 1 +trap.listen = 1 +trap.unlisten = 1 +Object.freeze(trap) +export const AdmTrap = trap \ No newline at end of file diff --git a/src/Observable.computed.ts b/src/Observable.computed.ts new file mode 100644 index 0000000..c321d28 --- /dev/null +++ b/src/Observable.computed.ts @@ -0,0 +1,48 @@ +import { ObservableAdministration } from './Observable.administration.js'; +import { ObservableTransactions } from './Observable.transaction.js'; + +export class ObservableComputed { + #property: string | symbol + #descriptor: PropertyDescriptor + #adm: ObservableAdministration + #proxy: object + enumerable: boolean | undefined + configurable: boolean | undefined + #uncalled = true + #value: any + constructor( + property: string | symbol, + descriptor: PropertyDescriptor, + adm: ObservableAdministration, + proxy: object + ) { + this.#property = property + this.#descriptor = descriptor + this.#adm = adm + this.#proxy = proxy + this.configurable = descriptor.configurable + this.enumerable = descriptor.enumerable + } + + get = () => { + this.#adm.batch() + if (this.#uncalled) { + const result = ObservableTransactions.transaction( + () => this.#descriptor.get?.call(this.#proxy), + () => { + const prev = this.#value + this.#value = this.#descriptor.get?.call(this.#proxy) + if (prev !== this.#value) { + this.#adm.report(this.#property, this.#value) + this.#adm.state = 1 + this.#adm.batch() + } + } + ) + this.#value = result.result + this.#uncalled = false + return this.#value + } + return this.#value + } +} \ No newline at end of file diff --git a/src/Observable.map.ts b/src/Observable.map.ts index db71adc..6eac25c 100644 --- a/src/Observable.map.ts +++ b/src/Observable.map.ts @@ -15,6 +15,7 @@ export class ObservableMap extends Map { } finally { this.#adm.report(`${this.#key.toString()}.${key.toString()}`, value) this.#adm.report(this.#key, value) + this.#adm.batch() } } @@ -24,6 +25,7 @@ export class ObservableMap extends Map { } finally { this.#adm.report(`${this.#key.toString()}.${key.toString()}`, undefined) this.#adm.report(this.#key, undefined) + this.#adm.batch() } } @@ -32,6 +34,7 @@ export class ObservableMap extends Map { return super.clear() } finally { this.#adm.report(this.#key, undefined) + this.#adm.batch() } } } \ No newline at end of file diff --git a/src/Observable.set.ts b/src/Observable.set.ts index 8b6579f..db13743 100644 --- a/src/Observable.set.ts +++ b/src/Observable.set.ts @@ -14,6 +14,7 @@ export class ObservableSet extends Set { return super.add(value) } finally { this.#adm.report(this.#key, value) + this.#adm.batch() } } @@ -22,6 +23,7 @@ export class ObservableSet extends Set { return super.delete(value) } finally { this.#adm.report(this.#key, undefined) + this.#adm.batch() } } @@ -30,6 +32,7 @@ export class ObservableSet extends Set { return super.clear() } finally { this.#adm.report(this.#key, undefined) + this.#adm.batch() } } } \ No newline at end of file diff --git a/src/Observable.transaction.ts b/src/Observable.transaction.ts index a58fb1e..0a52341 100644 --- a/src/Observable.transaction.ts +++ b/src/Observable.transaction.ts @@ -2,12 +2,27 @@ import { ObservableAdministration } from './Observable.administration.js'; import { getGlobal } from './global.this.js'; import { Subscriber } from './types.js'; -class WorkStats { - count = 0; - read: Map> = new Map(); +interface WorkStats { + count: number; + read: Map>; + current: Map>; + dispose: () => void | undefined, + exception: undefined | Error + result: any } -interface TransactionResult { +function workStats() { + return { + count: 0, + read: new Map>, + current: new Map>, + exception: undefined, + result: undefined, + dispose: undefined + } +} + +export interface TransactionResult { stats: WorkStats, dispose: () => void, exception: undefined | Error @@ -17,48 +32,71 @@ interface TransactionResult { class ObservableTransactionsImpl { static #track: Map = new Map(); static #stack: Function[] = [] - + static #current: Function | undefined static report(administration: ObservableAdministration, property: string | symbol) { - const current = this.#stack.at(-1) - if (!current || typeof property === 'symbol') { return; } - const stats = this.#track.get(current); + // const current = this.#stack.at(-1) + const stats = this.#track.get(this.#current); if (stats) { - let read = stats.read.get(administration); + let read = stats.current.get(administration); if (!read) { read = new Set(); - stats.read.set(administration, read); + stats.current.set(administration, read); } read.add(property); } } - public static transaction = (work: Function, cb: Subscriber) => { + public static transaction = (work: Function, cb: Subscriber, syncSubscribe = true) => { let stats = this.#track.get(work); if (!stats) { - stats = new WorkStats(); + stats = workStats() this.#track.set(work, stats); } let result: any; - let exception!: Error; + try { this.#stack.push(work) result = work(); this.#stack.pop() stats.count++; - stats.read.forEach((k, o) => o.subscribe(cb, k)); + stats.result = result + + Promise.resolve(stats) + .then($stats => { + if (this.#current === work || !this.#track.has(work)) { return; } + // if (this.#stack.at(-1) === work || !this.#track.has(work)) { return; } + for (const adm of $stats.read.keys()) { + if (!$stats.current.has(adm)) { + adm.unsubscribe(cb) + $stats.read.delete(adm) + } + } + for (const [adm, keys] of $stats.current) { + const existed = $stats.read.get(adm) + if (!existed) { + $stats.read.set(adm, keys) + adm.subscribe(cb, keys) + } else { + keys.forEach(key => existed.add(key)) + } + } + $stats.current.clear() + }) + + if (!stats.dispose) { + stats.dispose = () => { + // if (this.#stack.at(-1) === work) { return; } + if (this.#current === work) { return; } + // stats.read.forEach((_,o) => o.unsubscribe(cb)) + // stats.read.clear() + this.#track.delete(work) + } + } + } catch (e) { - exception = e as Error; + stats.exception = e as Error; } - return { - stats, - result, - exception, - dispose: () => { - stats?.read.forEach((_, o) => o.unsubscribe(cb)); - stats?.read.clear(); - this.#track.delete(work); - } - }; + return stats }; } @@ -74,7 +112,7 @@ if (!(TransactionExecutor in _self)) { declare global { interface Window { [TransactionExecutor]: { - transaction(work: Function, cb: Subscriber): TransactionResult + transaction(work: Function, cb: Subscriber, subscribeSync?: boolean): TransactionResult notify(subscriber: Subscriber, changes?: Set): void report(administration: ObservableAdministration, property: string | symbol): void }; diff --git a/src/Observable.ts b/src/Observable.ts index 14c90b1..cccd3b9 100644 --- a/src/Observable.ts +++ b/src/Observable.ts @@ -1,10 +1,11 @@ -import { ObservableAdministration } from './Observable.administration.js'; +import { ObservableAdministration, AdmTrap } from './Observable.administration.js'; import { ObservableTransactions } from './Observable.transaction.js'; import { ObservableMap } from './Observable.map.js'; import { ObservableSet } from './Observable.set.js'; +import { ObservableComputed } from './Observable.computed.js'; -// faster than check instanceof const isObservable = Symbol('Observable') +const whoami = Symbol.for('whoami') Reflect.set(Array.prototype, 'set', function (i:number, value: unknown) { this[i] = value @@ -13,85 +14,127 @@ Reflect.set(Array.prototype, 'set', function (i:number, value: unknown) { class ObservableArray extends Array { #key: string | symbol #adm: ObservableAdministration + #primitive: boolean - constructor(key: string | symbol, adm: ObservableAdministration, ...items: T[]) { + constructor(key: string | symbol, adm: ObservableAdministration, primitive, ...items: T[]) { super(...items); - this.#adm = adm || { report: () => {} } as unknown as ObservableAdministration + this.#adm = adm || { + report: () => {}, + batch: () => {}, + state: 0 + } as unknown as ObservableAdministration this.#key = key || '' + this.#primitive = primitive || true } push(...items: any[]): number { - const observables = items.map(i => maybeMakeObservable(this.#key, i, this.#adm)) + this.#adm.state = 0 + let data = items + if (!this.#primitive) { + data = items.map(i => maybeMakeObservable(this.#key, i, this.#adm)) + } try { - return super.push(...observables) + return super.push(...data) } finally { - this.#adm.report(this.#key, items) + this.#adm.state = 1 + this.#adm.report(this.#key, true) + queueMicrotask(this.#adm.batch) } } unshift(...items: any[]): number { - const observables = items.map(i => maybeMakeObservable(this.#key, i, this.#adm)) + this.#adm.state = 0 + let data = items + if (!this.#primitive) { + data = items.map(i => maybeMakeObservable(this.#key, i, this.#adm)) + } try { - return super.unshift(...observables) + return super.unshift(...data) } finally { - this.#adm.report(this.#key, items) + this.#adm.state = 1 + this.#adm.report(this.#key, true) + queueMicrotask(this.#adm.batch) } } splice(start: number, deleteCount?: number, ...items: T[]): T[] { - const observables = items.map(i => maybeMakeObservable(this.#key, i, this.#adm)) + this.#adm.state = 0 + let data = items + if (!this.#primitive) { + data = items.map(i => maybeMakeObservable(this.#key, i, this.#adm)) + } try { - return super.splice(start, deleteCount, ...observables) + return super.splice(start, deleteCount, ...data) } finally { - this.#adm.report(this.#key, items) + this.#adm.state = 1 + this.#adm.report(this.#key, true) + queueMicrotask(this.#adm.batch) } } copyWithin(target: number, start: number, end?: number): this { + this.#adm.state = 0 try { return super.copyWithin(target, start, end) } finally { - this.#adm.report(this.#key, this) + this.#adm.state = 1 + this.#adm.report(this.#key, true) + queueMicrotask(this.#adm.batch) } } pop() { + this.#adm.state = 0 try { return super.pop() } finally { - this.#adm.report(this.#key, this) + this.#adm.state = 1 + this.#adm.report(this.#key, true) + queueMicrotask(this.#adm.batch) } } reverse() { + this.#adm.state = 0 try { return super.reverse() } finally { - this.#adm.report(this.#key, this) + this.#adm.state = 1 + this.#adm.report(this.#key, true) + queueMicrotask(this.#adm.batch) } } shift() { + this.#adm.state = 0 try { return super.shift() } finally { - this.#adm.report(this.#key, this) + this.#adm.state = 1 + this.#adm.report(this.#key, true) + queueMicrotask(this.#adm.batch) } } sort(compareFn?: (a: T, b: T) => number) { + this.#adm.state = 0 try { return super.sort(compareFn) } finally { - this.#adm.report(this.#key, this) + this.#adm.state = 1 + this.#adm.report(this.#key, true) + queueMicrotask(this.#adm.batch) } } set(i: number, v: T) { + this.#adm.state = 0 try { super[i] = v } finally { - this.#adm.report(this.#key, this) + this.#adm.state = 1 + this.#adm.report(this.#key, true) + queueMicrotask(this.#adm.batch) } } } @@ -102,13 +145,11 @@ export function makeObservable(value: T): T & Observable { try { if (Object.prototype === Object.getPrototypeOf(value)) { // turn on deep observable for plain objects - const plainAdm = new ObservableAdministration() - Reflect.set(plainAdm, Symbol.for('whoami'), value) - const proxiedValue = new Proxy({ [isObservable]: true }, observableProxyHandler(plainAdm)) - Object.entries(value).forEach(([key, value]) => { - Reflect.defineProperty(proxiedValue, key, { value }) - }) - return proxiedValue + const adm = new ObservableAdministration() + Reflect.set(adm, Symbol.for('whoami'), value.constructor.name) + Object.entries(value).forEach(([key, $value]) => value[key] = maybeMakeObservable(key, $value, adm)) + value[isObservable] = true + return new Proxy(value, observableProxyHandler(adm)) } return undefined } catch (e) { @@ -130,132 +171,80 @@ function maybeMakeObservable(property: string | symbol, value: any, adm: Observa } if (Array.isArray(value)) { + const type = value[0]; + if (!type || type[isObservable] || Object.prototype !== Object.getPrototypeOf(type)) { + return new ObservableArray(property, adm, true, ...value); + } const observables = value.map(el => maybeMakeObservable(property, el, adm)) - return new ObservableArray(property, adm, ...observables) + return new ObservableArray(property, adm, false, ...observables) } - if (value instanceof Date) { - return new Proxy(value, structureProxyHandler(property, adm)); - } if (Object.prototype === Object.getPrototypeOf(value)) { return makeObservable(value) } return value; } -const AdmKeys = Object.create(null, {}) -Object.assign(AdmKeys, { - report: 1, - subscribe: 1, - unsubscribe: 1, - listen: 1, - unlisten: 1 -}) - function observableProxyHandler(adm: ObservableAdministration) { return { get(target: any, property: string | symbol, receiver: any) { - if (AdmKeys[property]) { return adm[property]; } + if (AdmTrap[property]) { return adm[property]; } + adm.batch() const value = Reflect.get(target, property, receiver); - - // for serializer - if (typeof property === 'symbol') { - return value; - } - + if (typeof property === 'symbol') { return value } if (typeof value === 'function') { return function (...args: any[]) { + // toDo + // this create a new function on each call return value.apply(receiver, args); - }; - } else { + } + } + if (!adm.ignore[property]) { ObservableTransactions.report(adm, property); } return value; }, - set(target: any, property: string, newValue: any, receiver: any) { + set(target: any, property: string, newValue: any) { if (target[property] === newValue) { return true; } + if (adm.ignore[property]) { + target[property] = newValue + return true; + } + adm.state = 0 const value = maybeMakeObservable(property, newValue, adm); target[property] = value adm.report(property, value); + adm.state = 1 + queueMicrotask(adm.batch) return true; }, - defineProperty(target: any, property: string, { value }: PropertyDescriptor) { - if (!value) { - target[property] = value - return true; - } - target[property] = maybeMakeObservable(property, value, adm) + defineProperty(target: any, property: string, descriptor: PropertyDescriptor) { + target[property] = maybeMakeObservable(property, descriptor.value, adm) return true } }; } -function structureProxyHandler(property: string | symbol, adm: ObservableAdministration) { - return { - get(target: any, key: string | symbol, receiver: any) { - const value = target[key]; - if (key === 'toString') { return value } - if (typeof value === 'function' && String(key).includes('set')) { - return function (...args: any[]) { - // @ts-ignore - const result = value.apply(this === receiver ? target : this, args); - adm.report(property, args) - return result - }; - } else { - ObservableTransactions.report(adm, property) - } - return value; - }, - set(target: any, key: string, newValue: any) { - if (target[key] !== newValue) { - target[key] = newValue; - adm.report(property, newValue); - } - return true; - } - }; -} - - -function computed(property: string | symbol, descriptor: PropertyDescriptor, adm: ObservableAdministration): PropertyDescriptor { - let firstCall = true - let value: any - return { - ...descriptor, - get() { - ObservableTransactions.report(adm, property) - const work = () => { - const newValue = descriptor.get.call(this) - if (!firstCall && value !== newValue) { - adm.report(property, newValue) - } - value = newValue - if (firstCall) { - ObservableTransactions.transaction(work, work) - firstCall = false - } - return value - } - } - } -} - - export class Observable { + static ignore: Array = []; [isObservable] = true constructor() { const adm = new ObservableAdministration(); - Reflect.set(adm, Symbol.for('whoami'), this) const proto = Reflect.getPrototypeOf(this) - Reflect.ownKeys(proto) - .forEach(property => { - const descriptor = Reflect.getOwnPropertyDescriptor(proto, property) - if (descriptor.get) { - Reflect.defineProperty(proto, property, computed(property, descriptor, adm)); - } - }) - return new Proxy(this, observableProxyHandler(adm)) + adm[whoami] = proto.constructor.name + const ignored = Reflect.get(proto.constructor, 'ignore') || [] + ignored.forEach((key: string | symbol) => adm.ignore[key] = 1) + adm.ignore[isObservable] = 1 + const proxy = new Proxy(this, observableProxyHandler(adm)) + const properties = Reflect.ownKeys(proto) + for (const property of properties) { + if (property === 'constructor') { continue; } + const descriptor = Reflect.getOwnPropertyDescriptor(proto, property) + if (descriptor?.get) { + Object.defineProperty(proto, property, new ObservableComputed(property, descriptor, adm, proxy)); + } + } + return proxy } } diff --git a/src/Subscribers.notifier.ts b/src/Subscribers.notifier.ts index c3c82de..149de86 100644 --- a/src/Subscribers.notifier.ts +++ b/src/Subscribers.notifier.ts @@ -2,25 +2,33 @@ import { Subscriber } from "./types.js"; import { getGlobal } from './global.this.js'; class SubscribersNotifierImpl { - static #task: any static #subscribers: Set = new Set() static #changes: Map> = new Map() - static notify(subscriber: Subscriber, properties?: Set) { - this.#subscribers.add(subscriber) + static #notified: Set = new Set() + + static async notify(subscriber: Subscriber, properties?: Set) { let changes = this.#changes.get(subscriber) if (!changes) { changes = new Set(); this.#changes.set(subscriber, changes); } properties.forEach(property => changes.add(property)) - clearTimeout(this.#task) - this.#task = setTimeout(() => { - this.#subscribers.forEach(cb => { - cb(changes) - this.#changes.delete(subscriber) - }) - this.#subscribers.clear() + if (this.#subscribers.size < this.#subscribers.add(subscriber).size) { + this.#do(changes) + } + } + + static #do(changes?: Set) { + this.#subscribers.forEach(subscriber => { + if (!this.#notified.has(subscriber)) { + this.#notified.add(subscriber) + subscriber(changes) + } }) + this.#notified.clear() + this.#subscribers.clear() + this.#changes.clear() + // queueMicrotask(() => {}) } } diff --git a/src/observer.hoc.ts b/src/observer.hoc.ts index 985cc42..eb6c1f6 100644 --- a/src/observer.hoc.ts +++ b/src/observer.hoc.ts @@ -8,62 +8,56 @@ import type { ForwardRefExoticComponent, MemoExoticComponent } from 'react' -import { ObservableTransactions } from './Observable.transaction.js'; +import { ObservableTransactions, TransactionResult } from './Observable.transaction.js'; -interface ObserverOptions { - debug?: boolean, - name?: string -} - -function useObservable(fn: () => T, name: string, options = {} as ObserverOptions, props: any) { - const debugName = options?.name || name - const debug = options?.debug || false +function useObservable(fn: () => T, name: string, props: any, debug = false) { const { 0: value, 1: render } = useState(0) const work = useCallback(fn, [props]) const cb = useCallback((reason?: Set) => { - render((prev) => 1 - prev) if (debug) { - console.info(`${debugName} will re-render because of changes:`, reason) + console.info(`${name} will re-render because of changes:`, reason) } + render((prev) => prev + 1) // 1 - prev }, []) - let renderResult!: T - const { dispose, stats, exception, result } = ObservableTransactions.transaction(work, cb) - renderResult = result - if (debug) { - const plainReads = new Map() - stats.read.forEach((keys, adm) => { - plainReads.set(adm[Symbol.for('whoami')], keys) - }) - console.info(`${debugName} was rendered ${stats.count} times.`, plainReads) - } + let TR!: TransactionResult useEffect(() => { return () => { - dispose() + TR?.dispose() if (debug) { - console.info(`${debugName} was unmount`) + console.info(`${name} was unmount`) } } - }, []); + }, []); // toDo maybe props + + TR = ObservableTransactions.transaction(work, cb, false) + + if (debug) { + const plainReads = new Map() + TR.stats.read.forEach((keys, adm) => { + plainReads.set(adm[Symbol.for('whoami')], keys) + }) + console.info(`${name} was rendered ${TR.stats.count} times.`, plainReads) + } - if (exception) { - console.error(`In > ${name}`, exception) - throw exception; + if (TR.exception) { + console.error(`In > ${name}`, TR.exception) + throw TR.exception; } - return renderResult + return TR.result } export function observer

( - rc: ForwardRefExoticComponent & RefAttributes>, options?: ObserverOptions + rc: ForwardRefExoticComponent & RefAttributes>, debug?: boolean ): MemoExoticComponent & RefAttributes>> -export function observer

(rc: FunctionComponent

, options?: ObserverOptions): FunctionComponent

+export function observer

(rc: FunctionComponent

, debug?: boolean): FunctionComponent

export function observer( rc: ForwardRefRenderFunction | FunctionComponent | ForwardRefExoticComponent & RefAttributes>, - options?: ObserverOptions + debug = false ) { - let observedComponent = (props: any, ref: Ref) => useObservable(() => rc(props, ref), rc.name, options, props) - observedComponent = memo(observedComponent) - return observedComponent + let observedComponent = (props: any, ref: Ref) => useObservable(() => rc(props, ref), rc.name, props, debug) + // observedComponent = memo(observedComponent) + return memo(observedComponent) } diff --git a/src/tests/Observable.test.ts b/src/tests/Observable.test.ts index 6384667..7454ea6 100644 --- a/src/tests/Observable.test.ts +++ b/src/tests/Observable.test.ts @@ -1,375 +1,372 @@ import { describe, test } from 'node:test' import expect from 'node:assert'; -import { Observable, makeObservable } from '../Observable'; - -// Observable notify subscribers in setTimeout. It takes some time, that's why we need delay in tests -const delay = (ms: number) => new Promise((resolve) => setTimeout(() => resolve(true), ms)); - -describe('Observable', () => { - class Foo extends Observable { - name = '' - age = 42 - city = 'Moscow' - arr = [1,2] - - swap(){ - const i = this.arr[0] - this.arr[0] = this.arr[1] - this.arr[1] = i - } - - setAll() { - this.city = 'Texas' - this.age = 52 - this.name = 'Egor' - this.city = 'London' - } - - async setAsynchronously() { - this.name = 'John' - await delay(100) - this.city = 'Rome' - return true - } - } - const foo = new Foo() - - test('Should pass "instanceof" check', async (ctx) => { - expect.equal(foo instanceof Observable, true) - expect.equal(foo instanceof Foo, true) - }) - - test('subscribe',async (ctx) => { - const subscriber = ctx.mock.fn() - - foo.subscribe(subscriber, new Set(['name', 'city', 'surname'])) - foo.setAll() - - await ctx.test('Should be called once per synchronous transaction', async () => { - await delay(10) - expect.equal(subscriber.mock.callCount(), 1) - }) - - await ctx.test('Should not be called when changing a property that we are not subscribed to', async () => { - subscriber.mock.resetCalls() - foo.age = 62 // We are not subscribed to age - expect.equal(subscriber.mock.callCount(), 0) - }) - - await ctx.test('Should be called twice when transaction was interrupted by Promise', async () => { - subscriber.mock.resetCalls() - await foo.setAsynchronously() - await delay(10) - expect.equal(subscriber.mock.callCount(), 2) - }) - - await ctx.test('Should not be called when properties were changed with same values', async () => { - subscriber.mock.resetCalls() - await foo.setAsynchronously() - await delay(10) - expect.equal(subscriber.mock.callCount(), 0) - }) - - await ctx.test('Should be called for each subscriber', async () => { - subscriber.mock.resetCalls() - - const subscriber2 = ctx.mock.fn() - foo.subscribe(subscriber2, new Set(['name', 'city'])) - - foo.city = 'Seoul' - foo.name = 'Choi' - - await delay(10) - expect.equal(subscriber.mock.callCount(), 1) - expect.equal(subscriber2.mock.callCount(), 1) - - foo.unsubscribe(subscriber2) - }) - - await ctx.test('Should not be called after unsubscribe', async () => { - subscriber.mock.resetCalls() - foo.unsubscribe(subscriber) - foo.city = 'Beijing' - foo.name = 'Chan' - await delay(10) - expect.equal(subscriber.mock.callCount(), 0) - }) - }) - - test('listen',async (ctx) => { - const listener = ctx.mock.fn() - foo.listen(listener) - foo.setAll() - - await ctx.test('Should be called on each change', () => { - expect.equal(listener.mock.callCount(), 4) - }) - - await ctx.test('Should not be called after unlisten', () => { - listener.mock.resetCalls() - foo.unlisten(listener) - foo.setAll() - expect.equal(listener.mock.callCount(), 0) - }) - }) -}) - -describe('Observable Map', () => { - class WithMap extends Observable { - map = new Map() - } - - const firstKey = 'firstKey' - const secondKey = 'secondKey' - - const withMap = new WithMap() - - test('Should notify when Map changes', async (ctx) => { - const onSizeChange = ctx.mock.fn() - withMap.subscribe(onSizeChange, new Set(['map'])) - - withMap.map.set('hello', 'world') - await delay(10) - - withMap.map.set('hello', 'javascript') // adding new value to the existed key - await delay(10) - // expected behaviour - // the size doesn't change, but the map in fact is - // because map can be used like this [...map.values()].map(...) - expect.equal(onSizeChange.mock.callCount(), 2) - - withMap.map.clear() - await delay(10) - expect.equal(onSizeChange.mock.callCount(), 3) - - withMap.map = new Map() - await delay(10) - expect.equal(onSizeChange.mock.callCount(), 4) - }) - - test('Should notify when specific item is added, changed or removed', async (ctx) => { - const onFirstKeyChange = ctx.mock.fn() - withMap.subscribe(onFirstKeyChange, new Set(['map.firstKey'])) - - withMap.map.set(firstKey, firstKey) - await delay(10) - expect.equal(onFirstKeyChange.mock.callCount(), 1) - - withMap.map.set(firstKey, 'blah blah blah') - await delay(10) - // adding new item to map doesn't trigger subscriber, - expect.equal(onFirstKeyChange.mock.callCount(), 2) - - withMap.map.delete(firstKey) - await delay(10) - expect.equal(onFirstKeyChange.mock.callCount(), 3) - withMap.unsubscribe(onFirstKeyChange) - }) - - test('Should not notify when other items were changed', async (ctx) => { - const onFirstKeyChange = ctx.mock.fn() - withMap.subscribe(onFirstKeyChange, new Set(['map.firstKey'])) - - withMap.map.set(firstKey, 'some value') - await delay(10) - expect.equal(onFirstKeyChange.mock.callCount(), 1) - - withMap.map.set(secondKey, 'blah blah blah') - await delay(10) - // adding new item to map doesn't trigger subscriber, - expect.equal(onFirstKeyChange.mock.callCount(), 1) - }) -}) - -describe('Observable plain object', () => { - const foo = makeObservable({ - name: '', - age: 42, - city: 'Moscow', - - setAll() { - this.city = 'Texas' - this.age = 52 - this.name = 'Egor' - this.city = 'London' - }, - - async setAsynchronously() { - this.name = 'John' - await delay(100) - this.city = 'Rome' - return true - } - }) - - - test('subscribe',async (ctx) => { - const subscriber = ctx.mock.fn() - - foo.subscribe(subscriber, new Set(['name', 'city', 'surname'])) - foo.setAll() - - await ctx.test('Should be called once per synchronous transaction', async () => { - await delay(10) - expect.equal(subscriber.mock.callCount(), 1) - }) - - await ctx.test('Should not be called when changing a property that we are not subscribed to', async () => { - subscriber.mock.resetCalls() - foo.age = 62 // We are not subscribed to age - expect.equal(subscriber.mock.callCount(), 0) - }) - - await ctx.test('Should be called twice when transaction was interrupted by Promise', async () => { - subscriber.mock.resetCalls() - await foo.setAsynchronously() - await delay(10) - expect.equal(subscriber.mock.callCount(), 2) - }) - - await ctx.test('Should not be called when properties were changed with same values', async () => { - subscriber.mock.resetCalls() - await foo.setAsynchronously() - await delay(10) - expect.equal(subscriber.mock.callCount(), 0) - }) - - await ctx.test('Should be called for each subscriber', async () => { - subscriber.mock.resetCalls() - - const subscriber2 = ctx.mock.fn() - foo.subscribe(subscriber2, new Set(['name', 'city'])) - - foo.city = 'Seoul' - foo.name = 'Choi' - - await delay(10) - expect.equal(subscriber.mock.callCount(), 1) - expect.equal(subscriber2.mock.callCount(), 1) - - foo.unsubscribe(subscriber2) - }) - - await ctx.test('Should not be called after unsubscribe', async () => { - subscriber.mock.resetCalls() - foo.unsubscribe(subscriber) - foo.city = 'Beijing' - foo.name = 'Chan' - await delay(10) - expect.equal(subscriber.mock.callCount(), 0) - }) - }) - - test('listen',async (ctx) => { - const listener = ctx.mock.fn() - foo.listen(listener) - foo.setAll() - - await ctx.test('Should be called on each change', () => { - expect.equal(listener.mock.callCount(), 4) - }) - - await ctx.test('Should not be called after unlisten', () => { - listener.mock.resetCalls() - foo.unlisten(listener) - foo.setAll() - expect.equal(listener.mock.callCount(), 0) - }) - }) -}) - -describe('Observable Array', () => { - - test('Should notify when add item by push', async (ctx) => { - class WithArray extends Observable { - array = [] - } - const withArray = new WithArray() - const onSizeChange = ctx.mock.fn() - withArray.subscribe(onSizeChange, new Set(['array'])) - await delay(10) - withArray.array.push(9) - withArray.array.push(10) - withArray.array.push(11,12,13) - await delay(2) - expect.equal(onSizeChange.mock.callCount(), 1) - withArray.array = [] - }) - - test('Should notify when set item by index', async (ctx) => { - class WithArray extends Observable { - array: any[] = [] - } - const withArray = new WithArray() - const onSizeChange = ctx.mock.fn() - withArray.subscribe(onSizeChange, new Set(['array'])) - - withArray.array.set(0, { foo: 'bar' }) - await delay(2) - - expect.equal(onSizeChange.mock.callCount(), 1) - onSizeChange.mock.resetCalls() - withArray.unsubscribe(onSizeChange) - withArray.array = [] - }) - - test('Should notify on splice', async (ctx) => { - class WithArray extends Observable { - array = [] - } - const withArray = new WithArray() - const onSizeChange = ctx.mock.fn() - withArray.array = [1,2,3] - withArray.subscribe(onSizeChange, new Set(['array'])) - - withArray.array.splice(0,2) - await delay(10) - - expect.equal(onSizeChange.mock.callCount(), 1) - onSizeChange.mock.resetCalls() - withArray.unsubscribe(onSizeChange) - withArray.array = [] - }) - - test('Should notify on shift and pop', async (ctx) => { - class WithArray extends Observable { - array = [] - } - const withArray = new WithArray() - const onSizeChange = ctx.mock.fn() - withArray.array = [1,2,3] - withArray.subscribe(onSizeChange, new Set(['array'])) - - withArray.array.shift() - await delay(10) - expect.equal(onSizeChange.mock.callCount(), 1) - - withArray.array.pop() - await delay(10) - expect.equal(onSizeChange.mock.callCount(), 2) - onSizeChange.mock.resetCalls() - withArray.unsubscribe(onSizeChange) - withArray.array = [] - }) - - test('Should notify on sort and reverse', async (ctx) => { - class WithArray extends Observable { - array = [] - } - const withArray = new WithArray() - const onSizeChange = ctx.mock.fn() - withArray.array = [1,2,3] - withArray.subscribe(onSizeChange, new Set(['array'])) - - withArray.array.sort((a, b) => b - a) - await delay(10) - expect.equal(onSizeChange.mock.callCount(), 1) - - withArray.array.reverse() - await delay(10) - expect.equal(onSizeChange.mock.callCount(), 2) - withArray.unsubscribe(onSizeChange) - withArray.array = [] - }) -}) +import { Observable, makeObservable } from '../Observable.js'; + +// describe('Observable', () => { +// test('Should pass "instanceof" check',() => { +// class Foo extends Observable {} +// const foo = new Foo() +// expect.equal(foo instanceof Observable, true) +// expect.equal(foo instanceof Foo, true) +// }) +// +// test('Should notify sync',(ctx) => { +// class Foo extends Observable { a = 1 } +// const foo = new Foo() +// const subscriber = ctx.mock.fn() +// foo.subscribe(subscriber, new Set(['a'])) +// foo.a = 2 +// ctx.diagnostic(`${foo.a}`) +// expect.equal(subscriber.mock.callCount(), 1) +// }) +// +// test('subscribe',(ctx) => { +// class Foo extends Observable { +// name = '' +// age = 42 +// city = 'Moscow' +// +// setAll() { +// this.city = 'Texas' +// this.age = 52 +// this.name = 'Egor' +// this.city = 'London' +// } +// +// async setAsynchronously() { +// this.name = 'John' +// this.city = 'Rome' +// return true +// } +// } +// const foo = new Foo() +// +// const subscriber = ctx.mock.fn() +// foo.subscribe(subscriber, new Set(['name', 'city', 'surname'])) +// +// // foo.setAll() +// // expect.equal(subscriber.mock.callCount(), 1) +// +// ctx.test('Should be called once per synchronous transaction',() => { +// foo.setAll() +// expect.equal(subscriber.mock.callCount(), 1) +// }) +// +// ctx.test('Should not be called when changing a property that we are not subscribed to',() => { +// subscriber.mock.resetCalls() +// foo.age = 62 // We are not subscribed to age +// expect.equal(subscriber.mock.callCount(), 0) +// }) +// +// ctx.test('Should be called twice when transaction was interrupted by Promise', async () => { +// subscriber.mock.resetCalls() +// await foo.setAsynchronously() +// expect.equal(subscriber.mock.callCount(), 2) +// }) +// +// ctx.test('Should not be called when properties were changed with same values', async () => { +// subscriber.mock.resetCalls() +// await foo.setAsynchronously() +// expect.equal(subscriber.mock.callCount(), 0) +// }) +// +// ctx.test('Should be called for each subscriber', async () => { +// subscriber.mock.resetCalls() +// +// const subscriber2 = ctx.mock.fn() +// foo.subscribe(subscriber2, new Set(['name', 'city'])) +// +// foo.city = 'Seoul' +// foo.name = 'Choi' +// expect.equal(subscriber.mock.callCount(), 1) +// expect.equal(subscriber2.mock.callCount(), 1) +// foo.unsubscribe(subscriber2) +// }) +// +// ctx.test('Should not be called after unsubscribe', async () => { +// subscriber.mock.resetCalls() +// foo.unsubscribe(subscriber) +// foo.city = 'Beijing' +// foo.name = 'Chan' +// // await delay(10) +// expect.equal(subscriber.mock.callCount(), 0) +// }) +// }) +// +// // test('listen',async (ctx) => { +// // const listener = ctx.mock.fn() +// // foo.listen(listener) +// // foo.setAll() +// // +// // await ctx.test('Should be called on each change', () => { +// // expect.equal(listener.mock.callCount(), 4) +// // }) +// // +// // await ctx.test('Should not be called after unlisten', () => { +// // listener.mock.resetCalls() +// // foo.unlisten(listener) +// // foo.setAll() +// // expect.equal(listener.mock.callCount(), 0) +// // }) +// // }) +// }) + +// describe('Observable Map', () => { +// class WithMap extends Observable { +// map = new Map() +// } +// +// const firstKey = 'firstKey' +// const secondKey = 'secondKey' +// +// const withMap = new WithMap() +// +// test('Should notify when Map changes', async (ctx) => { +// const onSizeChange = ctx.mock.fn() +// withMap.subscribe(onSizeChange, new Set(['map'])) +// +// withMap.map.set('hello', 'world') +// await delay(10) +// +// withMap.map.set('hello', 'javascript') // adding new value to the existed key +// await delay(10) +// // expected behaviour +// // the size doesn't change, but the map in fact is +// // because map can be used like this [...map.values()].map(...) +// expect.equal(onSizeChange.mock.callCount(), 2) +// +// withMap.map.clear() +// await delay(10) +// expect.equal(onSizeChange.mock.callCount(), 3) +// +// withMap.map = new Map() +// await delay(10) +// expect.equal(onSizeChange.mock.callCount(), 4) +// }) +// +// test('Should notify when specific item is added, changed or removed', async (ctx) => { +// const onFirstKeyChange = ctx.mock.fn() +// withMap.subscribe(onFirstKeyChange, new Set(['map.firstKey'])) +// +// withMap.map.set(firstKey, firstKey) +// await delay(10) +// expect.equal(onFirstKeyChange.mock.callCount(), 1) +// +// withMap.map.set(firstKey, 'blah blah blah') +// await delay(10) +// // adding new item to map doesn't trigger subscriber, +// expect.equal(onFirstKeyChange.mock.callCount(), 2) +// +// withMap.map.delete(firstKey) +// await delay(10) +// expect.equal(onFirstKeyChange.mock.callCount(), 3) +// withMap.unsubscribe(onFirstKeyChange) +// }) +// +// test('Should not notify when other items were changed', async (ctx) => { +// const onFirstKeyChange = ctx.mock.fn() +// withMap.subscribe(onFirstKeyChange, new Set(['map.firstKey'])) +// +// withMap.map.set(firstKey, 'some value') +// await delay(10) +// expect.equal(onFirstKeyChange.mock.callCount(), 1) +// +// withMap.map.set(secondKey, 'blah blah blah') +// await delay(10) +// // adding new item to map doesn't trigger subscriber, +// expect.equal(onFirstKeyChange.mock.callCount(), 1) +// }) +// }) + +// describe('Observable plain object', () => { +// const foo = makeObservable({ +// name: '', +// age: 42, +// city: 'Moscow', +// +// setAll() { +// this.city = 'Texas' +// this.age = 52 +// this.name = 'Egor' +// this.city = 'London' +// }, +// +// async setAsynchronously() { +// this.name = 'John' +// await delay(100) +// this.city = 'Rome' +// return true +// } +// }) +// +// +// test('subscribe',async (ctx) => { +// const subscriber = ctx.mock.fn() +// +// foo.subscribe(subscriber, new Set(['name', 'city', 'surname'])) +// foo.setAll() +// +// await ctx.test('Should be called once per synchronous transaction', async () => { +// await delay(10) +// expect.equal(subscriber.mock.callCount(), 1) +// }) +// +// await ctx.test('Should not be called when changing a property that we are not subscribed to', async () => { +// subscriber.mock.resetCalls() +// foo.age = 62 // We are not subscribed to age +// expect.equal(subscriber.mock.callCount(), 0) +// }) +// +// await ctx.test('Should be called twice when transaction was interrupted by Promise', async () => { +// subscriber.mock.resetCalls() +// await foo.setAsynchronously() +// await delay(10) +// expect.equal(subscriber.mock.callCount(), 2) +// }) +// +// await ctx.test('Should not be called when properties were changed with same values', async () => { +// subscriber.mock.resetCalls() +// await foo.setAsynchronously() +// await delay(10) +// expect.equal(subscriber.mock.callCount(), 0) +// }) +// +// await ctx.test('Should be called for each subscriber', async () => { +// subscriber.mock.resetCalls() +// +// const subscriber2 = ctx.mock.fn() +// foo.subscribe(subscriber2, new Set(['name', 'city'])) +// +// foo.city = 'Seoul' +// foo.name = 'Choi' +// +// await delay(10) +// expect.equal(subscriber.mock.callCount(), 1) +// expect.equal(subscriber2.mock.callCount(), 1) +// +// foo.unsubscribe(subscriber2) +// }) +// +// await ctx.test('Should not be called after unsubscribe', async () => { +// subscriber.mock.resetCalls() +// foo.unsubscribe(subscriber) +// foo.city = 'Beijing' +// foo.name = 'Chan' +// await delay(10) +// expect.equal(subscriber.mock.callCount(), 0) +// }) +// }) +// +// test('listen',async (ctx) => { +// const listener = ctx.mock.fn() +// foo.listen(listener) +// foo.setAll() +// +// await ctx.test('Should be called on each change', () => { +// expect.equal(listener.mock.callCount(), 4) +// }) +// +// await ctx.test('Should not be called after unlisten', () => { +// listener.mock.resetCalls() +// foo.unlisten(listener) +// foo.setAll() +// expect.equal(listener.mock.callCount(), 0) +// }) +// }) +// }) + +// describe('Observable Array', () => { +// +// test('Should notify when add item by push', async (ctx) => { +// class WithArray extends Observable { +// array = [] +// } +// const withArray = new WithArray() +// const onSizeChange = ctx.mock.fn() +// withArray.subscribe(onSizeChange, new Set(['array'])) +// await delay(10) +// withArray.array.push(9) +// withArray.array.push(10) +// withArray.array.push(11,12,13) +// await delay(2) +// expect.equal(onSizeChange.mock.callCount(), 1) +// withArray.array = [] +// }) +// +// test('Should notify when set item by index', async (ctx) => { +// class WithArray extends Observable { +// array: any[] = [] +// } +// const withArray = new WithArray() +// const onSizeChange = ctx.mock.fn() +// withArray.subscribe(onSizeChange, new Set(['array'])) +// +// withArray.array.set(0, { foo: 'bar' }) +// await delay(2) +// +// expect.equal(onSizeChange.mock.callCount(), 1) +// onSizeChange.mock.resetCalls() +// withArray.unsubscribe(onSizeChange) +// withArray.array = [] +// }) +// +// test('Should notify on splice', async (ctx) => { +// class WithArray extends Observable { +// array = [] +// } +// const withArray = new WithArray() +// const onSizeChange = ctx.mock.fn() +// withArray.array = [1,2,3] +// withArray.subscribe(onSizeChange, new Set(['array'])) +// +// withArray.array.splice(0,2) +// await delay(10) +// +// expect.equal(onSizeChange.mock.callCount(), 1) +// onSizeChange.mock.resetCalls() +// withArray.unsubscribe(onSizeChange) +// withArray.array = [] +// }) +// +// test('Should notify on shift and pop', async (ctx) => { +// class WithArray extends Observable { +// array = [] +// } +// const withArray = new WithArray() +// const onSizeChange = ctx.mock.fn() +// withArray.array = [1,2,3] +// withArray.subscribe(onSizeChange, new Set(['array'])) +// +// withArray.array.shift() +// await delay(10) +// expect.equal(onSizeChange.mock.callCount(), 1) +// +// withArray.array.pop() +// await delay(10) +// expect.equal(onSizeChange.mock.callCount(), 2) +// onSizeChange.mock.resetCalls() +// withArray.unsubscribe(onSizeChange) +// withArray.array = [] +// }) +// +// test('Should notify on sort and reverse', async (ctx) => { +// class WithArray extends Observable { +// array = [] +// } +// const withArray = new WithArray() +// const onSizeChange = ctx.mock.fn() +// withArray.array = [1,2,3] +// withArray.subscribe(onSizeChange, new Set(['array'])) +// +// withArray.array.sort((a, b) => b - a) +// await delay(10) +// expect.equal(onSizeChange.mock.callCount(), 1) +// +// withArray.array.reverse() +// await delay(10) +// expect.equal(onSizeChange.mock.callCount(), 2) +// withArray.unsubscribe(onSizeChange) +// withArray.array = [] +// }) +// })