import { state, effect, stop, toRaw, OperationTypes, DebuggerEvent, markNonReactive } from '../src/index' import { ITERATE_KEY } from '../src/effect' describe('observer/effect', () => { it('should run the passed function once (wrapped by a effect)', () => { const fnSpy = jest.fn(() => {}) effect(fnSpy) expect(fnSpy).toHaveBeenCalledTimes(1) }) it('should observe basic properties', () => { let dummy const counter = state({ num: 0 }) effect(() => (dummy = counter.num)) expect(dummy).toBe(0) counter.num = 7 expect(dummy).toBe(7) }) it('should observe multiple properties', () => { let dummy const counter = state({ num1: 0, num2: 0 }) effect(() => (dummy = counter.num1 + counter.num1 + counter.num2)) expect(dummy).toBe(0) counter.num1 = counter.num2 = 7 expect(dummy).toBe(21) }) it('should handle multiple effects', () => { let dummy1, dummy2 const counter = state({ num: 0 }) effect(() => (dummy1 = counter.num)) effect(() => (dummy2 = counter.num)) expect(dummy1).toBe(0) expect(dummy2).toBe(0) counter.num++ expect(dummy1).toBe(1) expect(dummy2).toBe(1) }) it('should observe nested properties', () => { let dummy const counter = state({ nested: { num: 0 } }) effect(() => (dummy = counter.nested.num)) expect(dummy).toBe(0) counter.nested.num = 8 expect(dummy).toBe(8) }) it('should observe delete operations', () => { let dummy const obj = state({ prop: 'value' }) effect(() => (dummy = obj.prop)) expect(dummy).toBe('value') delete obj.prop expect(dummy).toBe(undefined) }) it('should observe has operations', () => { let dummy const obj: any = state({ prop: 'value' }) effect(() => (dummy = 'prop' in obj)) expect(dummy).toBe(true) delete obj.prop expect(dummy).toBe(false) obj.prop = 12 expect(dummy).toBe(true) }) it('should observe properties on the prototype chain', () => { let dummy const counter = state({ num: 0 }) const parentCounter = state({ num: 2 }) Object.setPrototypeOf(counter, parentCounter) effect(() => (dummy = counter.num)) expect(dummy).toBe(0) delete counter.num expect(dummy).toBe(2) parentCounter.num = 4 expect(dummy).toBe(4) counter.num = 3 expect(dummy).toBe(3) }) it('should observe has operations on the prototype chain', () => { let dummy const counter = state({ num: 0 }) const parentCounter = state({ num: 2 }) Object.setPrototypeOf(counter, parentCounter) effect(() => (dummy = 'num' in counter)) expect(dummy).toBe(true) delete counter.num expect(dummy).toBe(true) delete parentCounter.num expect(dummy).toBe(false) counter.num = 3 expect(dummy).toBe(true) }) it('should observe inherited property accessors', () => { let dummy, parentDummy, hiddenValue: any const obj: any = state({}) const parent = state({ set prop(value) { hiddenValue = value }, get prop() { return hiddenValue } }) Object.setPrototypeOf(obj, parent) effect(() => (dummy = obj.prop)) effect(() => (parentDummy = parent.prop)) expect(dummy).toBe(undefined) expect(parentDummy).toBe(undefined) obj.prop = 4 expect(dummy).toBe(4) // this doesn't work, should it? // expect(parentDummy).toBe(4) parent.prop = 2 expect(dummy).toBe(2) expect(parentDummy).toBe(2) }) it('should observe function call chains', () => { let dummy const counter = state({ num: 0 }) effect(() => (dummy = getNum())) function getNum() { return counter.num } expect(dummy).toBe(0) counter.num = 2 expect(dummy).toBe(2) }) it('should observe iteration', () => { let dummy const list = state(['Hello']) effect(() => (dummy = list.join(' '))) expect(dummy).toBe('Hello') list.push('World!') expect(dummy).toBe('Hello World!') list.shift() expect(dummy).toBe('World!') }) it('should observe implicit array length changes', () => { let dummy const list = state(['Hello']) effect(() => (dummy = list.join(' '))) expect(dummy).toBe('Hello') list[1] = 'World!' expect(dummy).toBe('Hello World!') list[3] = 'Hello!' expect(dummy).toBe('Hello World! Hello!') }) it('should observe sparse array mutations', () => { let dummy const list: any[] = state([]) list[1] = 'World!' effect(() => (dummy = list.join(' '))) expect(dummy).toBe(' World!') list[0] = 'Hello' expect(dummy).toBe('Hello World!') list.pop() expect(dummy).toBe('Hello') }) it('should observe enumeration', () => { let dummy = 0 const numbers: any = state({ num1: 3 }) effect(() => { dummy = 0 for (let key in numbers) { dummy += numbers[key] } }) expect(dummy).toBe(3) numbers.num2 = 4 expect(dummy).toBe(7) delete numbers.num1 expect(dummy).toBe(4) }) it('should observe symbol keyed properties', () => { const key = Symbol('symbol keyed prop') let dummy, hasDummy const obj = state({ [key]: 'value' }) effect(() => (dummy = obj[key])) effect(() => (hasDummy = key in obj)) expect(dummy).toBe('value') expect(hasDummy).toBe(true) obj[key] = 'newValue' expect(dummy).toBe('newValue') delete obj[key] expect(dummy).toBe(undefined) expect(hasDummy).toBe(false) }) it('should not observe well-known symbol keyed properties', () => { const key = Symbol.isConcatSpreadable let dummy const array: any = state([]) effect(() => (dummy = array[key])) expect(array[key]).toBe(undefined) expect(dummy).toBe(undefined) array[key] = true expect(array[key]).toBe(true) expect(dummy).toBe(undefined) }) it('should observe function valued properties', () => { const oldFunc = () => {} const newFunc = () => {} let dummy const obj = state({ func: oldFunc }) effect(() => (dummy = obj.func)) expect(dummy).toBe(oldFunc) obj.func = newFunc expect(dummy).toBe(newFunc) }) it('should not observe set operations without a value change', () => { let hasDummy, getDummy const obj = state({ prop: 'value' }) const getSpy = jest.fn(() => (getDummy = obj.prop)) const hasSpy = jest.fn(() => (hasDummy = 'prop' in obj)) effect(getSpy) effect(hasSpy) expect(getDummy).toBe('value') expect(hasDummy).toBe(true) obj.prop = 'value' expect(getSpy).toHaveBeenCalledTimes(1) expect(hasSpy).toHaveBeenCalledTimes(1) expect(getDummy).toBe('value') expect(hasDummy).toBe(true) }) it('should not observe raw mutations', () => { let dummy const obj: any = state() effect(() => (dummy = toRaw(obj).prop)) expect(dummy).toBe(undefined) obj.prop = 'value' expect(dummy).toBe(undefined) }) it('should not be triggered by raw mutations', () => { let dummy const obj: any = state() effect(() => (dummy = obj.prop)) expect(dummy).toBe(undefined) toRaw(obj).prop = 'value' expect(dummy).toBe(undefined) }) it('should not be triggered by inherited raw setters', () => { let dummy, parentDummy, hiddenValue: any const obj: any = state({}) const parent = state({ set prop(value) { hiddenValue = value }, get prop() { return hiddenValue } }) Object.setPrototypeOf(obj, parent) effect(() => (dummy = obj.prop)) effect(() => (parentDummy = parent.prop)) expect(dummy).toBe(undefined) expect(parentDummy).toBe(undefined) toRaw(obj).prop = 4 expect(dummy).toBe(undefined) expect(parentDummy).toBe(undefined) }) it('should avoid implicit infinite recursive loops with itself', () => { const counter = state({ num: 0 }) const counterSpy = jest.fn(() => counter.num++) effect(counterSpy) expect(counter.num).toBe(1) expect(counterSpy).toHaveBeenCalledTimes(1) counter.num = 4 expect(counter.num).toBe(5) expect(counterSpy).toHaveBeenCalledTimes(2) }) it('should allow explicitly recursive raw function loops', () => { const counter = state({ num: 0 }) const numSpy = jest.fn(() => { counter.num++ if (counter.num < 10) { numSpy() } }) effect(numSpy) expect(counter.num).toEqual(10) expect(numSpy).toHaveBeenCalledTimes(10) }) it('should avoid infinite loops with other effects', () => { const nums = state({ num1: 0, num2: 1 }) const spy1 = jest.fn(() => (nums.num1 = nums.num2)) const spy2 = jest.fn(() => (nums.num2 = nums.num1)) effect(spy1) effect(spy2) expect(nums.num1).toBe(1) expect(nums.num2).toBe(1) expect(spy1).toHaveBeenCalledTimes(1) expect(spy2).toHaveBeenCalledTimes(1) nums.num2 = 4 expect(nums.num1).toBe(4) expect(nums.num2).toBe(4) expect(spy1).toHaveBeenCalledTimes(2) expect(spy2).toHaveBeenCalledTimes(2) nums.num1 = 10 expect(nums.num1).toBe(10) expect(nums.num2).toBe(10) expect(spy1).toHaveBeenCalledTimes(3) expect(spy2).toHaveBeenCalledTimes(3) }) it('should return a new reactive version of the function', () => { function greet() { return 'Hello World' } const effect1 = effect(greet) const effect2 = effect(greet) expect(typeof effect1).toBe('function') expect(typeof effect2).toBe('function') expect(effect1).not.toBe(greet) expect(effect1).not.toBe(effect2) }) it('should discover new branches while running automatically', () => { let dummy const obj = state({ prop: 'value', run: false }) const conditionalSpy = jest.fn(() => { dummy = obj.run ? obj.prop : 'other' }) effect(conditionalSpy) expect(dummy).toBe('other') expect(conditionalSpy).toHaveBeenCalledTimes(1) obj.prop = 'Hi' expect(dummy).toBe('other') expect(conditionalSpy).toHaveBeenCalledTimes(1) obj.run = true expect(dummy).toBe('Hi') expect(conditionalSpy).toHaveBeenCalledTimes(2) obj.prop = 'World' expect(dummy).toBe('World') expect(conditionalSpy).toHaveBeenCalledTimes(3) }) it('should discover new branches when running manually', () => { let dummy let run = false const obj = state({ prop: 'value' }) const runner = effect(() => { dummy = run ? obj.prop : 'other' }) expect(dummy).toBe('other') runner() expect(dummy).toBe('other') run = true runner() expect(dummy).toBe('value') obj.prop = 'World' expect(dummy).toBe('World') }) it('should not be triggered by mutating a property, which is used in an inactive branch', () => { let dummy const obj = state({ prop: 'value', run: true }) const conditionalSpy = jest.fn(() => { dummy = obj.run ? obj.prop : 'other' }) effect(conditionalSpy) expect(dummy).toBe('value') expect(conditionalSpy).toHaveBeenCalledTimes(1) obj.run = false expect(dummy).toBe('other') expect(conditionalSpy).toHaveBeenCalledTimes(2) obj.prop = 'value2' expect(dummy).toBe('other') expect(conditionalSpy).toHaveBeenCalledTimes(2) }) it('should not double wrap if the passed function is a effect', () => { const runner = effect(() => {}) const otherRunner = effect(runner) expect(runner).not.toBe(otherRunner) expect(runner.raw).toBe(otherRunner.raw) }) it('should not run multiple times for a single mutation', () => { let dummy const obj: any = state() const fnSpy = jest.fn(() => { for (const key in obj) { dummy = obj[key] } dummy = obj.prop }) effect(fnSpy) expect(fnSpy).toHaveBeenCalledTimes(1) obj.prop = 16 expect(dummy).toBe(16) expect(fnSpy).toHaveBeenCalledTimes(2) }) it('should allow nested effects', () => { const nums = state({ num1: 0, num2: 1, num3: 2 }) const dummy: any = {} const childSpy = jest.fn(() => (dummy.num1 = nums.num1)) const childeffect = effect(childSpy) const parentSpy = jest.fn(() => { dummy.num2 = nums.num2 childeffect() dummy.num3 = nums.num3 }) effect(parentSpy) expect(dummy).toEqual({ num1: 0, num2: 1, num3: 2 }) expect(parentSpy).toHaveBeenCalledTimes(1) expect(childSpy).toHaveBeenCalledTimes(2) // this should only call the childeffect nums.num1 = 4 expect(dummy).toEqual({ num1: 4, num2: 1, num3: 2 }) expect(parentSpy).toHaveBeenCalledTimes(1) expect(childSpy).toHaveBeenCalledTimes(3) // this calls the parenteffect, which calls the childeffect once nums.num2 = 10 expect(dummy).toEqual({ num1: 4, num2: 10, num3: 2 }) expect(parentSpy).toHaveBeenCalledTimes(2) expect(childSpy).toHaveBeenCalledTimes(4) // this calls the parenteffect, which calls the childeffect once nums.num3 = 7 expect(dummy).toEqual({ num1: 4, num2: 10, num3: 7 }) expect(parentSpy).toHaveBeenCalledTimes(3) expect(childSpy).toHaveBeenCalledTimes(5) }) it('should observe class method invocations', () => { class Model { count: number constructor() { this.count = 0 } inc() { this.count++ } } const model = state(new Model()) let dummy effect(() => { dummy = model.count }) expect(dummy).toBe(0) model.inc() expect(dummy).toBe(1) }) it('scheduler', () => { let runner: any, dummy const scheduler = jest.fn(_runner => { runner = _runner }) const obj = state({ foo: 1 }) effect( () => { dummy = obj.foo }, { scheduler } ) expect(scheduler).not.toHaveBeenCalled() expect(dummy).toBe(1) // should be called on first trigger obj.foo++ expect(scheduler).toHaveBeenCalledTimes(1) // should not run yet expect(dummy).toBe(1) // manually run runner() // should have run expect(dummy).toBe(2) }) it('events: onTrack', () => { let events: any[] = [] let dummy const onTrack = jest.fn((e: DebuggerEvent) => { events.push(e) }) const obj = state({ foo: 1, bar: 2 }) const runner = effect( () => { dummy = obj.foo dummy = 'bar' in obj dummy = Object.keys(obj) }, { onTrack } ) expect(dummy).toEqual(['foo', 'bar']) expect(onTrack).toHaveBeenCalledTimes(3) expect(events).toEqual([ { effect: runner, target: toRaw(obj), type: OperationTypes.GET, key: 'foo' }, { effect: runner, target: toRaw(obj), type: OperationTypes.HAS, key: 'bar' }, { effect: runner, target: toRaw(obj), type: OperationTypes.ITERATE, key: ITERATE_KEY } ]) }) it('events: onTrigger', () => { let events: any[] = [] let dummy const onTrigger = jest.fn((e: DebuggerEvent) => { events.push(e) }) const obj = state({ foo: 1 }) const runner = effect( () => { dummy = obj.foo }, { onTrigger } ) obj.foo++ expect(dummy).toBe(2) expect(onTrigger).toHaveBeenCalledTimes(1) expect(events[0]).toEqual({ effect: runner, target: toRaw(obj), type: OperationTypes.SET, key: 'foo', oldValue: 1, newValue: 2 }) delete obj.foo expect(dummy).toBeUndefined() expect(onTrigger).toHaveBeenCalledTimes(2) expect(events[1]).toEqual({ effect: runner, target: toRaw(obj), type: OperationTypes.DELETE, key: 'foo', oldValue: 2 }) }) it('stop', () => { let dummy const obj = state({ prop: 1 }) const runner = effect(() => { dummy = obj.prop }) obj.prop = 2 expect(dummy).toBe(2) stop(runner) obj.prop = 3 expect(dummy).toBe(2) // stopped effect should still be manually callable runner() expect(dummy).toBe(3) }) it('markNonReactive', () => { const obj = state({ foo: markNonReactive({ prop: 0 }) }) let dummy effect(() => { dummy = obj.foo.prop }) expect(dummy).toBe(0) obj.foo.prop++ expect(dummy).toBe(0) obj.foo = { prop: 1 } expect(dummy).toBe(1) }) })