From 8a714a6c39cc490ce9318f23ef7e6fe6d033a1f9 Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 20 Sep 2018 16:18:22 -0400 Subject: [PATCH] test: better collection coverage + tests for immutable --- .../__tests__/collections/Map.spec.ts | 29 + .../__tests__/collections/Set.spec.ts | 525 +++++++++--------- .../__tests__/collections/WeakMap.spec.ts | 161 +++--- .../__tests__/collections/WeakSet.spec.ts | 141 ++--- packages/observer/__tests__/immutable.spec.ts | 340 +++++++++++- packages/observer/src/autorun.ts | 4 +- packages/observer/src/baseHandlers.ts | 14 +- packages/observer/src/collectionHandlers.ts | 232 ++++---- packages/observer/src/index.ts | 4 +- 9 files changed, 953 insertions(+), 497 deletions(-) diff --git a/packages/observer/__tests__/collections/Map.spec.ts b/packages/observer/__tests__/collections/Map.spec.ts index c9d2de24..20f97874 100644 --- a/packages/observer/__tests__/collections/Map.spec.ts +++ b/packages/observer/__tests__/collections/Map.spec.ts @@ -206,5 +206,34 @@ describe('observer/collections', () => { map.delete('key') expect(dummy).toBe(undefined) }) + + it('should not pollute original Map with Proxies', () => { + const map = new Map() + const observed = observable(map) + const value = observable({}) + observed.set('key', value) + expect(map.get('key')).not.toBe(value) + expect(map.get('key')).toBe(unwrap(value)) + }) + + it('should return observable versions of contained values', () => { + const observed = observable(new Map()) + const value = {} + observed.set('key', value) + const wrapped = observed.get('key') + expect(isObservable(wrapped)).toBe(true) + expect(unwrap(wrapped)).toBe(value) + }) + + it('should observed nested data', () => { + const observed = observable(new Map()) + observed.set('key', { a: 1 }) + let dummy + autorun(() => { + dummy = observed.get('key').a + }) + observed.get('key').a = 2 + expect(dummy).toBe(2) + }) }) }) diff --git a/packages/observer/__tests__/collections/Set.spec.ts b/packages/observer/__tests__/collections/Set.spec.ts index 5c6b3f9e..159e5e01 100644 --- a/packages/observer/__tests__/collections/Set.spec.ts +++ b/packages/observer/__tests__/collections/Set.spec.ts @@ -1,279 +1,290 @@ import { observable, autorun, isObservable, unwrap } from '../../src' -describe('Set', () => { - it('instanceof', () => { - const original = new Set() - const observed = observable(original) - expect(isObservable(observed)).toBe(true) - expect(original instanceof Set).toBe(true) - expect(observed instanceof Set).toBe(true) - }) - - it('should observe mutations', () => { - let dummy - const set = observable(new Set()) - autorun(() => (dummy = set.has('value'))) - - expect(dummy).toBe(false) - set.add('value') - expect(dummy).toBe(true) - set.delete('value') - expect(dummy).toBe(false) - }) - - it('should observe for of iteration', () => { - let dummy - const set = observable(new Set()) - autorun(() => { - dummy = 0 - for (let num of set) { - dummy += num - } +describe('observer/collections', () => { + describe('Set', () => { + it('instanceof', () => { + const original = new Set() + const observed = observable(original) + expect(isObservable(observed)).toBe(true) + expect(original instanceof Set).toBe(true) + expect(observed instanceof Set).toBe(true) }) - expect(dummy).toBe(0) - set.add(2) - set.add(1) - expect(dummy).toBe(3) - set.delete(2) - expect(dummy).toBe(1) - set.clear() - expect(dummy).toBe(0) - }) + it('should observe mutations', () => { + let dummy + const set = observable(new Set()) + autorun(() => (dummy = set.has('value'))) - it('should observe forEach iteration', () => { - let dummy: any - const set = observable(new Set()) - autorun(() => { - dummy = 0 - set.forEach(num => (dummy += num)) + expect(dummy).toBe(false) + set.add('value') + expect(dummy).toBe(true) + set.delete('value') + expect(dummy).toBe(false) }) - expect(dummy).toBe(0) - set.add(2) - set.add(1) - expect(dummy).toBe(3) - set.delete(2) - expect(dummy).toBe(1) - set.clear() - expect(dummy).toBe(0) - }) - - it('should observe values iteration', () => { - let dummy - const set = observable(new Set()) - autorun(() => { - dummy = 0 - for (let num of set.values()) { - dummy += num - } - }) - - expect(dummy).toBe(0) - set.add(2) - set.add(1) - expect(dummy).toBe(3) - set.delete(2) - expect(dummy).toBe(1) - set.clear() - expect(dummy).toBe(0) - }) - - it('should observe keys iteration', () => { - let dummy - const set = observable(new Set()) - autorun(() => { - dummy = 0 - for (let num of set.keys()) { - dummy += num - } - }) - - expect(dummy).toBe(0) - set.add(2) - set.add(1) - expect(dummy).toBe(3) - set.delete(2) - expect(dummy).toBe(1) - set.clear() - expect(dummy).toBe(0) - }) - - it('should observe entries iteration', () => { - let dummy - const set = observable(new Set()) - autorun(() => { - dummy = 0 - // eslint-disable-next-line no-unused-vars - for (let [key, num] of set.entries()) { - key - dummy += num - } - }) - - expect(dummy).toBe(0) - set.add(2) - set.add(1) - expect(dummy).toBe(3) - set.delete(2) - expect(dummy).toBe(1) - set.clear() - expect(dummy).toBe(0) - }) - - it('should be triggered by clearing', () => { - let dummy - const set = observable(new Set()) - autorun(() => (dummy = set.has('key'))) - - expect(dummy).toBe(false) - set.add('key') - expect(dummy).toBe(true) - set.clear() - expect(dummy).toBe(false) - }) - - it('should not observe custom property mutations', () => { - let dummy - const set: any = observable(new Set()) - autorun(() => (dummy = set.customProp)) - - expect(dummy).toBe(undefined) - set.customProp = 'Hello World' - expect(dummy).toBe(undefined) - }) - - it('should observe size mutations', () => { - let dummy - const set = observable(new Set()) - autorun(() => (dummy = set.size)) - - expect(dummy).toBe(0) - set.add('value') - set.add('value2') - expect(dummy).toBe(2) - set.delete('value') - expect(dummy).toBe(1) - set.clear() - expect(dummy).toBe(0) - }) - - it('should not observe non value changing mutations', () => { - let dummy - const set = observable(new Set()) - const setSpy = jest.fn(() => (dummy = set.has('value'))) - autorun(setSpy) - - expect(dummy).toBe(false) - expect(setSpy).toHaveBeenCalledTimes(1) - set.add('value') - expect(dummy).toBe(true) - expect(setSpy).toHaveBeenCalledTimes(2) - set.add('value') - expect(dummy).toBe(true) - expect(setSpy).toHaveBeenCalledTimes(2) - set.delete('value') - expect(dummy).toBe(false) - expect(setSpy).toHaveBeenCalledTimes(3) - set.delete('value') - expect(dummy).toBe(false) - expect(setSpy).toHaveBeenCalledTimes(3) - set.clear() - expect(dummy).toBe(false) - expect(setSpy).toHaveBeenCalledTimes(3) - }) - - it('should not observe raw data', () => { - let dummy - const set = observable(new Set()) - autorun(() => (dummy = unwrap(set).has('value'))) - - expect(dummy).toBe(false) - set.add('value') - expect(dummy).toBe(false) - }) - - it('should not observe raw iterations', () => { - let dummy = 0 - const set = observable(new Set()) - autorun(() => { - dummy = 0 - for (let [num] of unwrap(set).entries()) { - dummy += num - } - for (let num of unwrap(set).keys()) { - dummy += num - } - for (let num of unwrap(set).values()) { - dummy += num - } - unwrap(set).forEach(num => { - dummy += num + it('should observe for of iteration', () => { + let dummy + const set = observable(new Set()) + autorun(() => { + dummy = 0 + for (let num of set) { + dummy += num + } }) - for (let num of unwrap(set)) { - dummy += num - } + + expect(dummy).toBe(0) + set.add(2) + set.add(1) + expect(dummy).toBe(3) + set.delete(2) + expect(dummy).toBe(1) + set.clear() + expect(dummy).toBe(0) }) - expect(dummy).toBe(0) - set.add(2) - set.add(3) - expect(dummy).toBe(0) - set.delete(2) - expect(dummy).toBe(0) - }) + it('should observe forEach iteration', () => { + let dummy: any + const set = observable(new Set()) + autorun(() => { + dummy = 0 + set.forEach(num => (dummy += num)) + }) - it('should not be triggered by raw mutations', () => { - let dummy - const set = observable(new Set()) - autorun(() => (dummy = set.has('value'))) + expect(dummy).toBe(0) + set.add(2) + set.add(1) + expect(dummy).toBe(3) + set.delete(2) + expect(dummy).toBe(1) + set.clear() + expect(dummy).toBe(0) + }) - expect(dummy).toBe(false) - unwrap(set).add('value') - expect(dummy).toBe(false) - dummy = true - unwrap(set).delete('value') - expect(dummy).toBe(true) - unwrap(set).clear() - expect(dummy).toBe(true) - }) + it('should observe values iteration', () => { + let dummy + const set = observable(new Set()) + autorun(() => { + dummy = 0 + for (let num of set.values()) { + dummy += num + } + }) - it('should not observe raw size mutations', () => { - let dummy - const set = observable(new Set()) - autorun(() => (dummy = unwrap(set).size)) + expect(dummy).toBe(0) + set.add(2) + set.add(1) + expect(dummy).toBe(3) + set.delete(2) + expect(dummy).toBe(1) + set.clear() + expect(dummy).toBe(0) + }) - expect(dummy).toBe(0) - set.add('value') - expect(dummy).toBe(0) - }) + it('should observe keys iteration', () => { + let dummy + const set = observable(new Set()) + autorun(() => { + dummy = 0 + for (let num of set.keys()) { + dummy += num + } + }) - it('should not be triggered by raw size mutations', () => { - let dummy - const set = observable(new Set()) - autorun(() => (dummy = set.size)) + expect(dummy).toBe(0) + set.add(2) + set.add(1) + expect(dummy).toBe(3) + set.delete(2) + expect(dummy).toBe(1) + set.clear() + expect(dummy).toBe(0) + }) - expect(dummy).toBe(0) - unwrap(set).add('value') - expect(dummy).toBe(0) - }) + it('should observe entries iteration', () => { + let dummy + const set = observable(new Set()) + autorun(() => { + dummy = 0 + // eslint-disable-next-line no-unused-vars + for (let [key, num] of set.entries()) { + key + dummy += num + } + }) - it('should support objects as key', () => { - let dummy - const key = {} - const set = observable(new Set()) - const setSpy = jest.fn(() => (dummy = set.has(key))) - autorun(setSpy) + expect(dummy).toBe(0) + set.add(2) + set.add(1) + expect(dummy).toBe(3) + set.delete(2) + expect(dummy).toBe(1) + set.clear() + expect(dummy).toBe(0) + }) - expect(dummy).toBe(false) - expect(setSpy).toHaveBeenCalledTimes(1) + it('should be triggered by clearing', () => { + let dummy + const set = observable(new Set()) + autorun(() => (dummy = set.has('key'))) - set.add({}) - expect(dummy).toBe(false) - expect(setSpy).toHaveBeenCalledTimes(1) + expect(dummy).toBe(false) + set.add('key') + expect(dummy).toBe(true) + set.clear() + expect(dummy).toBe(false) + }) - set.add(key) - expect(dummy).toBe(true) - expect(setSpy).toHaveBeenCalledTimes(2) + it('should not observe custom property mutations', () => { + let dummy + const set: any = observable(new Set()) + autorun(() => (dummy = set.customProp)) + + expect(dummy).toBe(undefined) + set.customProp = 'Hello World' + expect(dummy).toBe(undefined) + }) + + it('should observe size mutations', () => { + let dummy + const set = observable(new Set()) + autorun(() => (dummy = set.size)) + + expect(dummy).toBe(0) + set.add('value') + set.add('value2') + expect(dummy).toBe(2) + set.delete('value') + expect(dummy).toBe(1) + set.clear() + expect(dummy).toBe(0) + }) + + it('should not observe non value changing mutations', () => { + let dummy + const set = observable(new Set()) + const setSpy = jest.fn(() => (dummy = set.has('value'))) + autorun(setSpy) + + expect(dummy).toBe(false) + expect(setSpy).toHaveBeenCalledTimes(1) + set.add('value') + expect(dummy).toBe(true) + expect(setSpy).toHaveBeenCalledTimes(2) + set.add('value') + expect(dummy).toBe(true) + expect(setSpy).toHaveBeenCalledTimes(2) + set.delete('value') + expect(dummy).toBe(false) + expect(setSpy).toHaveBeenCalledTimes(3) + set.delete('value') + expect(dummy).toBe(false) + expect(setSpy).toHaveBeenCalledTimes(3) + set.clear() + expect(dummy).toBe(false) + expect(setSpy).toHaveBeenCalledTimes(3) + }) + + it('should not observe raw data', () => { + let dummy + const set = observable(new Set()) + autorun(() => (dummy = unwrap(set).has('value'))) + + expect(dummy).toBe(false) + set.add('value') + expect(dummy).toBe(false) + }) + + it('should not observe raw iterations', () => { + let dummy = 0 + const set = observable(new Set()) + autorun(() => { + dummy = 0 + for (let [num] of unwrap(set).entries()) { + dummy += num + } + for (let num of unwrap(set).keys()) { + dummy += num + } + for (let num of unwrap(set).values()) { + dummy += num + } + unwrap(set).forEach(num => { + dummy += num + }) + for (let num of unwrap(set)) { + dummy += num + } + }) + + expect(dummy).toBe(0) + set.add(2) + set.add(3) + expect(dummy).toBe(0) + set.delete(2) + expect(dummy).toBe(0) + }) + + it('should not be triggered by raw mutations', () => { + let dummy + const set = observable(new Set()) + autorun(() => (dummy = set.has('value'))) + + expect(dummy).toBe(false) + unwrap(set).add('value') + expect(dummy).toBe(false) + dummy = true + unwrap(set).delete('value') + expect(dummy).toBe(true) + unwrap(set).clear() + expect(dummy).toBe(true) + }) + + it('should not observe raw size mutations', () => { + let dummy + const set = observable(new Set()) + autorun(() => (dummy = unwrap(set).size)) + + expect(dummy).toBe(0) + set.add('value') + expect(dummy).toBe(0) + }) + + it('should not be triggered by raw size mutations', () => { + let dummy + const set = observable(new Set()) + autorun(() => (dummy = set.size)) + + expect(dummy).toBe(0) + unwrap(set).add('value') + expect(dummy).toBe(0) + }) + + it('should support objects as key', () => { + let dummy + const key = {} + const set = observable(new Set()) + const setSpy = jest.fn(() => (dummy = set.has(key))) + autorun(setSpy) + + expect(dummy).toBe(false) + expect(setSpy).toHaveBeenCalledTimes(1) + + set.add({}) + expect(dummy).toBe(false) + expect(setSpy).toHaveBeenCalledTimes(1) + + set.add(key) + expect(dummy).toBe(true) + expect(setSpy).toHaveBeenCalledTimes(2) + }) + + it('should not pollute original Set with Proxies', () => { + const set = new Set() + const observed = observable(set) + const value = observable({}) + observed.add(value) + expect(observed.has(value)).toBe(true) + expect(set.has(value)).toBe(false) + }) }) }) diff --git a/packages/observer/__tests__/collections/WeakMap.spec.ts b/packages/observer/__tests__/collections/WeakMap.spec.ts index db97dd0b..f20a1961 100644 --- a/packages/observer/__tests__/collections/WeakMap.spec.ts +++ b/packages/observer/__tests__/collections/WeakMap.spec.ts @@ -1,77 +1,108 @@ import { observable, autorun, unwrap, isObservable } from '../../src' -describe('observer/collections/WeakMap', () => { - test('instanceof', () => { - const original = new WeakMap() - const observed = observable(original) - expect(isObservable(observed)).toBe(true) - expect(original instanceof WeakMap).toBe(true) - expect(observed instanceof WeakMap).toBe(true) - }) - - it('should observe mutations', () => { - let dummy - const key = {} - const map = observable(new WeakMap()) - autorun(() => { - dummy = map.get(key) +describe('observer/collections', () => { + describe('WeakMap', () => { + test('instanceof', () => { + const original = new WeakMap() + const observed = observable(original) + expect(isObservable(observed)).toBe(true) + expect(original instanceof WeakMap).toBe(true) + expect(observed instanceof WeakMap).toBe(true) }) - expect(dummy).toBe(undefined) - map.set(key, 'value') - expect(dummy).toBe('value') - map.set(key, 'value2') - expect(dummy).toBe('value2') - map.delete(key) - expect(dummy).toBe(undefined) - }) + it('should observe mutations', () => { + let dummy + const key = {} + const map = observable(new WeakMap()) + autorun(() => { + dummy = map.get(key) + }) - it('should not observe custom property mutations', () => { - let dummy - const map: any = observable(new Map()) - autorun(() => (dummy = map.customProp)) + expect(dummy).toBe(undefined) + map.set(key, 'value') + expect(dummy).toBe('value') + map.set(key, 'value2') + expect(dummy).toBe('value2') + map.delete(key) + expect(dummy).toBe(undefined) + }) - expect(dummy).toBe(undefined) - map.customProp = 'Hello World' - expect(dummy).toBe(undefined) - }) + it('should not observe custom property mutations', () => { + let dummy + const map: any = observable(new WeakMap()) + autorun(() => (dummy = map.customProp)) - it('should not observe non value changing mutations', () => { - let dummy - const key = {} - const map = observable(new Map()) - const mapSpy = jest.fn(() => (dummy = map.get(key))) - autorun(mapSpy) + expect(dummy).toBe(undefined) + map.customProp = 'Hello World' + expect(dummy).toBe(undefined) + }) - expect(dummy).toBe(undefined) - expect(mapSpy).toHaveBeenCalledTimes(1) - map.set(key, 'value') - expect(dummy).toBe('value') - expect(mapSpy).toHaveBeenCalledTimes(2) - map.set(key, 'value') - expect(dummy).toBe('value') - expect(mapSpy).toHaveBeenCalledTimes(2) - map.delete(key) - expect(dummy).toBe(undefined) - expect(mapSpy).toHaveBeenCalledTimes(3) - map.delete(key) - expect(dummy).toBe(undefined) - expect(mapSpy).toHaveBeenCalledTimes(3) - map.clear() - expect(dummy).toBe(undefined) - expect(mapSpy).toHaveBeenCalledTimes(3) - }) + it('should not observe non value changing mutations', () => { + let dummy + const key = {} + const map = observable(new WeakMap()) + const mapSpy = jest.fn(() => (dummy = map.get(key))) + autorun(mapSpy) - it('should not observe raw data', () => { - let dummy - const key = {} - const map = observable(new Map()) - autorun(() => (dummy = unwrap(map).get('key'))) + expect(dummy).toBe(undefined) + expect(mapSpy).toHaveBeenCalledTimes(1) + map.set(key, 'value') + expect(dummy).toBe('value') + expect(mapSpy).toHaveBeenCalledTimes(2) + map.set(key, 'value') + expect(dummy).toBe('value') + expect(mapSpy).toHaveBeenCalledTimes(2) + map.delete(key) + expect(dummy).toBe(undefined) + expect(mapSpy).toHaveBeenCalledTimes(3) + map.delete(key) + expect(dummy).toBe(undefined) + expect(mapSpy).toHaveBeenCalledTimes(3) + }) - expect(dummy).toBe(undefined) - map.set(key, 'Hello') - expect(dummy).toBe(undefined) - map.delete(key) - expect(dummy).toBe(undefined) + it('should not observe raw data', () => { + let dummy + const key = {} + const map = observable(new WeakMap()) + autorun(() => (dummy = unwrap(map).get(key))) + + expect(dummy).toBe(undefined) + map.set(key, 'Hello') + expect(dummy).toBe(undefined) + map.delete(key) + expect(dummy).toBe(undefined) + }) + + it('should not pollute original Map with Proxies', () => { + const map = new WeakMap() + const observed = observable(map) + const key = {} + const value = observable({}) + observed.set(key, value) + expect(map.get(key)).not.toBe(value) + expect(map.get(key)).toBe(unwrap(value)) + }) + + it('should return observable versions of contained values', () => { + const observed = observable(new WeakMap()) + const key = {} + const value = {} + observed.set(key, value) + const wrapped = observed.get(key) + expect(isObservable(wrapped)).toBe(true) + expect(unwrap(wrapped)).toBe(value) + }) + + it('should observed nested data', () => { + const observed = observable(new Map()) + const key = {} + observed.set(key, { a: 1 }) + let dummy + autorun(() => { + dummy = observed.get(key).a + }) + observed.get(key).a = 2 + expect(dummy).toBe(2) + }) }) }) diff --git a/packages/observer/__tests__/collections/WeakSet.spec.ts b/packages/observer/__tests__/collections/WeakSet.spec.ts index 1efd9b3b..23b05342 100644 --- a/packages/observer/__tests__/collections/WeakSet.spec.ts +++ b/packages/observer/__tests__/collections/WeakSet.spec.ts @@ -1,79 +1,90 @@ import { observable, isObservable, autorun, unwrap } from '../../src' -describe('WeakSet', () => { - it('instanceof', () => { - const original = new Set() - const observed = observable(original) - expect(isObservable(observed)).toBe(true) - expect(original instanceof Set).toBe(true) - expect(observed instanceof Set).toBe(true) - }) +describe('observer/collections', () => { + describe('WeakSet', () => { + it('instanceof', () => { + const original = new Set() + const observed = observable(original) + expect(isObservable(observed)).toBe(true) + expect(original instanceof Set).toBe(true) + expect(observed instanceof Set).toBe(true) + }) - it('should observe mutations', () => { - let dummy - const value = {} - const set = observable(new WeakSet()) - autorun(() => (dummy = set.has(value))) + it('should observe mutations', () => { + let dummy + const value = {} + const set = observable(new WeakSet()) + autorun(() => (dummy = set.has(value))) - expect(dummy).toBe(false) - set.add(value) - expect(dummy).toBe(true) - set.delete(value) - expect(dummy).toBe(false) - }) + expect(dummy).toBe(false) + set.add(value) + expect(dummy).toBe(true) + set.delete(value) + expect(dummy).toBe(false) + }) - it('should not observe custom property mutations', () => { - let dummy - const set: any = observable(new WeakSet()) - autorun(() => (dummy = set.customProp)) + it('should not observe custom property mutations', () => { + let dummy + const set: any = observable(new WeakSet()) + autorun(() => (dummy = set.customProp)) - expect(dummy).toBe(undefined) - set.customProp = 'Hello World' - expect(dummy).toBe(undefined) - }) + expect(dummy).toBe(undefined) + set.customProp = 'Hello World' + expect(dummy).toBe(undefined) + }) - it('should not observe non value changing mutations', () => { - let dummy - const value = {} - const set = observable(new WeakSet()) - const setSpy = jest.fn(() => (dummy = set.has(value))) - autorun(setSpy) + it('should not observe non value changing mutations', () => { + let dummy + const value = {} + const set = observable(new WeakSet()) + const setSpy = jest.fn(() => (dummy = set.has(value))) + autorun(setSpy) - expect(dummy).toBe(false) - expect(setSpy).toHaveBeenCalledTimes(1) - set.add(value) - expect(dummy).toBe(true) - expect(setSpy).toHaveBeenCalledTimes(2) - set.add(value) - expect(dummy).toBe(true) - expect(setSpy).toHaveBeenCalledTimes(2) - set.delete(value) - expect(dummy).toBe(false) - expect(setSpy).toHaveBeenCalledTimes(3) - set.delete(value) - expect(dummy).toBe(false) - expect(setSpy).toHaveBeenCalledTimes(3) - }) + expect(dummy).toBe(false) + expect(setSpy).toHaveBeenCalledTimes(1) + set.add(value) + expect(dummy).toBe(true) + expect(setSpy).toHaveBeenCalledTimes(2) + set.add(value) + expect(dummy).toBe(true) + expect(setSpy).toHaveBeenCalledTimes(2) + set.delete(value) + expect(dummy).toBe(false) + expect(setSpy).toHaveBeenCalledTimes(3) + set.delete(value) + expect(dummy).toBe(false) + expect(setSpy).toHaveBeenCalledTimes(3) + }) - it('should not observe raw data', () => { - const value = {} - let dummy - const set = observable(new WeakSet()) - autorun(() => (dummy = unwrap(set).has(value))) + it('should not observe raw data', () => { + const value = {} + let dummy + const set = observable(new WeakSet()) + autorun(() => (dummy = unwrap(set).has(value))) - expect(dummy).toBe(false) - set.add(value) - expect(dummy).toBe(false) - }) + expect(dummy).toBe(false) + set.add(value) + expect(dummy).toBe(false) + }) - it('should not be triggered by raw mutations', () => { - const value = {} - let dummy - const set = observable(new WeakSet()) - autorun(() => (dummy = set.has(value))) + it('should not be triggered by raw mutations', () => { + const value = {} + let dummy + const set = observable(new WeakSet()) + autorun(() => (dummy = set.has(value))) - expect(dummy).toBe(false) - unwrap(set).add(value) - expect(dummy).toBe(false) + expect(dummy).toBe(false) + unwrap(set).add(value) + expect(dummy).toBe(false) + }) + + it('should not pollute original Set with Proxies', () => { + const set = new WeakSet() + const observed = observable(set) + const value = observable({}) + observed.add(value) + expect(observed.has(value)).toBe(true) + expect(set.has(value)).toBe(false) + }) }) }) diff --git a/packages/observer/__tests__/immutable.spec.ts b/packages/observer/__tests__/immutable.spec.ts index 64d6e2aa..4ce9dbf1 100644 --- a/packages/observer/__tests__/immutable.spec.ts +++ b/packages/observer/__tests__/immutable.spec.ts @@ -1 +1,339 @@ -describe('observer/immutable', () => {}) +import { + observable, + immutable, + unwrap, + isObservable, + isImmutable, + markNonReactive, + markImmutable, + lock, + unlock, + autorun +} from '../src' + +describe('observer/immutable', () => { + let warn: any + + beforeEach(() => { + warn = jest.spyOn(console, 'warn') + warn.mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + }) + + describe('Object', () => { + it('should make nested values immutable', () => { + const original = { foo: 1, bar: { baz: 2 } } + const observed = immutable(original) + expect(observed).not.toBe(original) + expect(isObservable(observed)).toBe(true) + expect(isImmutable(observed)).toBe(true) + expect(isObservable(original)).toBe(false) + expect(isImmutable(original)).toBe(false) + expect(isObservable(observed.bar)).toBe(true) + expect(isImmutable(observed.bar)).toBe(true) + expect(isObservable(original.bar)).toBe(false) + expect(isImmutable(original.bar)).toBe(false) + // get + expect(observed.foo).toBe(1) + // has + expect('foo' in observed).toBe(true) + // ownKeys + expect(Object.keys(observed)).toEqual(['foo', 'bar']) + }) + + it('should not allow mutation', () => { + const observed = immutable({ foo: 1, bar: { baz: 2 } }) + observed.foo = 2 + expect(observed.foo).toBe(1) + expect(warn).toHaveBeenCalledTimes(1) + observed.bar.baz = 3 + expect(observed.bar.baz).toBe(2) + expect(warn).toHaveBeenCalledTimes(2) + }) + + it('should allow mutation when unlocked', () => { + const observed = immutable({ foo: 1, bar: { baz: 2 } }) + unlock() + observed.foo = 2 + observed.bar.baz = 3 + lock() + expect(observed.foo).toBe(2) + expect(observed.bar.baz).toBe(3) + expect(warn).not.toHaveBeenCalled() + }) + + it('should not trigger autoruns when locked', () => { + const observed = immutable({ a: 1 }) + let dummy + autorun(() => { + dummy = observed.a + }) + expect(dummy).toBe(1) + observed.a = 2 + expect(observed.a).toBe(1) + expect(dummy).toBe(1) + }) + + it('should trigger autoruns when unlocked', () => { + const observed = immutable({ a: 1 }) + let dummy + autorun(() => { + dummy = observed.a + }) + expect(dummy).toBe(1) + unlock() + observed.a = 2 + lock() + expect(observed.a).toBe(2) + expect(dummy).toBe(2) + }) + }) + + describe('Array', () => { + it('should make nested values immutable', () => { + const original: any[] = [{ foo: 1 }] + const observed = immutable(original) + expect(observed).not.toBe(original) + expect(isObservable(observed)).toBe(true) + expect(isImmutable(observed)).toBe(true) + expect(isObservable(original)).toBe(false) + expect(isImmutable(original)).toBe(false) + expect(isObservable(observed[0])).toBe(true) + expect(isImmutable(observed[0])).toBe(true) + expect(isObservable(original[0])).toBe(false) + expect(isImmutable(original[0])).toBe(false) + // get + expect(observed[0].foo).toBe(1) + // has + expect(0 in observed).toBe(true) + // ownKeys + expect(Object.keys(observed)).toEqual(['0']) + }) + + it('should not allow mutation', () => { + const observed: any = immutable([{ foo: 1 }]) + observed[0] = 1 + expect(observed[0]).not.toBe(1) + expect(warn).toHaveBeenCalledTimes(1) + observed[0].foo = 2 + expect(observed[0].foo).toBe(1) + expect(warn).toHaveBeenCalledTimes(2) + + // should block length mutation + observed.length = 0 + expect(observed.length).toBe(1) + expect(observed[0].foo).toBe(1) + expect(warn).toHaveBeenCalledTimes(3) + + // mutation methods invoke set/length internally and thus are blocked as well + observed.push(2) + expect(observed.length).toBe(1) + // push triggers two warnings on [1] and .length + expect(warn).toHaveBeenCalledTimes(5) + }) + + it('should allow mutation when unlocked', () => { + const observed: any[] = immutable([{ foo: 1, bar: { baz: 2 } }]) + unlock() + observed[1] = 2 + observed.push(3) + observed[0].foo = 2 + observed[0].bar.baz = 3 + lock() + expect(observed.length).toBe(3) + expect(observed[1]).toBe(2) + expect(observed[2]).toBe(3) + expect(observed[0].foo).toBe(2) + expect(observed[0].bar.baz).toBe(3) + expect(warn).not.toHaveBeenCalled() + }) + + it('should not trigger autoruns when locked', () => { + const observed = immutable([{ a: 1 }]) + let dummy + autorun(() => { + dummy = observed[0].a + }) + expect(dummy).toBe(1) + observed[0].a = 2 + expect(observed[0].a).toBe(1) + expect(dummy).toBe(1) + observed[0] = { a: 2 } + expect(observed[0].a).toBe(1) + expect(dummy).toBe(1) + }) + + it('should trigger autoruns when unlocked', () => { + const observed = immutable([{ a: 1 }]) + let dummy + autorun(() => { + dummy = observed[0].a + }) + expect(dummy).toBe(1) + + unlock() + + observed[0].a = 2 + expect(observed[0].a).toBe(2) + expect(dummy).toBe(2) + + observed[0] = { a: 3 } + expect(observed[0].a).toBe(3) + expect(dummy).toBe(3) + + observed.unshift({ a: 4 }) + expect(observed[0].a).toBe(4) + expect(dummy).toBe(4) + lock() + }) + }) + ;[Map, WeakMap].forEach((Collection: any) => { + describe(Collection.name, () => { + test('should make nested values immutable', () => { + const key1 = {} + const key2 = {} + const original = new Collection([[key1, {}], [key2, {}]]) + const observed = immutable(original) + expect(observed).not.toBe(original) + expect(isObservable(observed)).toBe(true) + expect(isImmutable(observed)).toBe(true) + expect(isObservable(original)).toBe(false) + expect(isImmutable(original)).toBe(false) + expect(isObservable(observed.get(key1))).toBe(true) + expect(isImmutable(observed.get(key1))).toBe(true) + expect(isObservable(original.get(key1))).toBe(false) + expect(isImmutable(original.get(key1))).toBe(false) + }) + + test('should not allow mutation & not trigger autorun', () => { + const map = immutable(new Collection()) + const key = {} + let dummy + autorun(() => { + dummy = map.get(key) + }) + expect(dummy).toBeUndefined() + map.set(key, 1) + expect(dummy).toBeUndefined() + expect(map.has(key)).toBe(false) + expect(warn).toHaveBeenCalledTimes(1) + }) + + test('should allow mutation & trigger autorun when unlocked', () => { + const map = immutable(new Collection()) + const key = {} + let dummy + autorun(() => { + dummy = map.get(key) + }) + expect(dummy).toBeUndefined() + unlock() + map.set(key, 1) + lock() + expect(dummy).toBe(1) + expect(map.get(key)).toBe(1) + expect(warn).not.toHaveBeenCalled() + }) + }) + }) + ;[Set, WeakSet].forEach((Collection: any) => { + describe(Collection.name, () => { + test('should make nested values immutable', () => { + const key1 = {} + const key2 = {} + const original = new Collection([key1, key2]) + const observed = immutable(original) + expect(observed).not.toBe(original) + expect(isObservable(observed)).toBe(true) + expect(isImmutable(observed)).toBe(true) + expect(isObservable(original)).toBe(false) + expect(isImmutable(original)).toBe(false) + expect(observed.has(observable(key1))).toBe(true) + expect(original.has(observable(key1))).toBe(false) + }) + + test('should not allow mutation & not trigger autorun', () => { + const set = immutable(new Collection()) + const key = {} + let dummy + autorun(() => { + dummy = set.has(key) + }) + expect(dummy).toBe(false) + set.add(key) + expect(dummy).toBe(false) + expect(set.has(key)).toBe(false) + expect(warn).toHaveBeenCalledTimes(1) + }) + + test('should allow mutation & trigger autorun when unlocked', () => { + const set = immutable(new Collection()) + const key = {} + let dummy + autorun(() => { + dummy = set.has(key) + }) + expect(dummy).toBe(false) + unlock() + set.add(key) + lock() + expect(dummy).toBe(true) + expect(set.has(key)).toBe(true) + expect(warn).not.toHaveBeenCalled() + }) + }) + }) + + test('calling observable on an immutable should return immutable', () => { + const a = immutable() + const b = observable(a) + expect(isImmutable(b)).toBe(true) + // should point to same original + expect(unwrap(a)).toBe(unwrap(b)) + }) + + test('calling immutable on an observable should return immutable', () => { + const a = observable() + const b = immutable(a) + expect(isImmutable(b)).toBe(true) + // should point to same original + expect(unwrap(a)).toBe(unwrap(b)) + }) + + test('observing already observed value should return same Proxy', () => { + const original = { foo: 1 } + const observed = immutable(original) + const observed2 = immutable(observed) + expect(observed2).toBe(observed) + }) + + test('observing the same value multiple times should return same Proxy', () => { + const original = { foo: 1 } + const observed = immutable(original) + const observed2 = immutable(original) + expect(observed2).toBe(observed) + }) + + test('markNonReactive', () => { + const obj = immutable({ + foo: { a: 1 }, + bar: markNonReactive({ b: 2 }) + }) + expect(isObservable(obj.foo)).toBe(true) + expect(isObservable(obj.bar)).toBe(false) + }) + + test('markImmutable', () => { + const obj = observable({ + foo: { a: 1 }, + bar: markImmutable({ b: 2 }) + }) + expect(isObservable(obj.foo)).toBe(true) + expect(isObservable(obj.bar)).toBe(true) + expect(isImmutable(obj.foo)).toBe(false) + expect(isImmutable(obj.bar)).toBe(true) + }) +}) diff --git a/packages/observer/src/autorun.ts b/packages/observer/src/autorun.ts index b4a1cb6c..c0c9aa82 100644 --- a/packages/observer/src/autorun.ts +++ b/packages/observer/src/autorun.ts @@ -116,7 +116,9 @@ export function trigger( }) } else { // schedule runs for SET | ADD | DELETE - addRunners(runners, depsMap.get(key as string | symbol)) + if (key !== void 0) { + addRunners(runners, depsMap.get(key as string | symbol)) + } // also run for iteration key on ADD | DELETE if (type === OperationTypes.ADD || type === OperationTypes.DELETE) { const iterationKey = Array.isArray(target) ? 'length' : ITERATE_KEY diff --git a/packages/observer/src/baseHandlers.ts b/packages/observer/src/baseHandlers.ts index 15240ac7..dc0010c0 100644 --- a/packages/observer/src/baseHandlers.ts +++ b/packages/observer/src/baseHandlers.ts @@ -15,14 +15,14 @@ function get( target: any, key: string | symbol, receiver: any, - toObsevable: (t: any) => any + toObservable: (t: any) => any ) { const res = Reflect.get(target, key, receiver) if (typeof key === 'symbol' && builtInSymbols.has(key)) { return res } track(target, OperationTypes.GET, key) - return res !== null && typeof res === 'object' ? toObsevable(res) : res + return res !== null && typeof res === 'object' ? toObservable(res) : res } function set( @@ -96,7 +96,10 @@ export const immutableHandlers: ProxyHandler = { set(target: any, key: string | symbol, value: any, receiver: any): boolean { if (LOCKED) { if (__DEV__) { - console.warn(`Set operation failed: target is immutable.`, target) + console.warn( + `Set operation on key "${key as any}" failed: target is immutable.`, + target + ) } return true } else { @@ -107,7 +110,10 @@ export const immutableHandlers: ProxyHandler = { deleteProperty(target: any, key: string | symbol): boolean { if (LOCKED) { if (__DEV__) { - console.warn(`Delete operation failed: target is immutable.`, target) + console.warn( + `Delete operation on key "${key as any}" failed: target is immutable.`, + target + ) } return true } else { diff --git a/packages/observer/src/collectionHandlers.ts b/packages/observer/src/collectionHandlers.ts index 113ec8a3..bf545266 100644 --- a/packages/observer/src/collectionHandlers.ts +++ b/packages/observer/src/collectionHandlers.ts @@ -1,18 +1,24 @@ -import { unwrap } from './index' +import { unwrap, observable, immutable } from './index' import { track, trigger } from './autorun' import { OperationTypes } from './operations' +import { LOCKED } from './lock' -function makeInstrumentedMethod(method: string | symbol, type: OperationTypes) { - return function(...args: any[]) { - const target = unwrap(this) - const proto: any = Reflect.getPrototypeOf(target) - track(target, type, args[0]) - return proto[method].apply(target, args) - } +function get(target: any, key: any, toObservable: (t: any) => any): any { + target = unwrap(target) + key = unwrap(key) + const proto: any = Reflect.getPrototypeOf(target) + track(target, OperationTypes.GET, key) + const res = proto.get.call(target, key) + return res !== null && typeof res === 'object' ? toObservable(res) : res } -const get = makeInstrumentedMethod('get', OperationTypes.GET) -const has = makeInstrumentedMethod('has', OperationTypes.HAS) +function has(key: any): boolean { + const target = unwrap(this) + key = unwrap(key) + const proto: any = Reflect.getPrototypeOf(target) + track(target, OperationTypes.HAS, key) + return proto.has.call(target, key) +} function size(target: any) { target = unwrap(target) @@ -21,115 +27,135 @@ function size(target: any) { return Reflect.get(proto, 'size', target) } -function makeWarning(type: OperationTypes) { - return function() { +function add(value: any) { + value = unwrap(value) + const target = unwrap(this) + const proto: any = Reflect.getPrototypeOf(this) + const hadKey = proto.has.call(target, value) + const result = proto.add.call(target, value) + if (!hadKey) { if (__DEV__) { - console.warn( - `${type} operation failed: target is immutable.`, - unwrap(this) - ) + trigger(target, OperationTypes.ADD, value, { value }) + } else { + trigger(target, OperationTypes.ADD, value) + } + } + return result +} + +function set(key: any, value: any) { + value = unwrap(value) + const target = unwrap(this) + const proto: any = Reflect.getPrototypeOf(this) + const hadKey = proto.has.call(target, key) + const oldValue = proto.get.call(target, key) + const result = proto.set.call(target, key, value) + if (value !== oldValue) { + if (__DEV__) { + const extraInfo = { oldValue, newValue: value } + if (!hadKey) { + trigger(target, OperationTypes.ADD, key, extraInfo) + } else { + trigger(target, OperationTypes.SET, key, extraInfo) + } + } else { + if (!hadKey) { + trigger(target, OperationTypes.ADD, key) + } else { + trigger(target, OperationTypes.SET, key) + } + } + } + return result +} + +function deleteEntry(key: any) { + const target = unwrap(this) + const proto: any = Reflect.getPrototypeOf(this) + const hadKey = proto.has.call(target, key) + const oldValue = proto.get ? proto.get.call(target, key) : undefined + // forward the operation before queueing reactions + const result = proto.delete.call(target, key) + if (hadKey) { + if (__DEV__) { + trigger(target, OperationTypes.DELETE, key, { oldValue }) + } else { + trigger(target, OperationTypes.DELETE, key) + } + } + return result +} + +function clear() { + const target = unwrap(this) + const proto: any = Reflect.getPrototypeOf(this) + const hadItems = target.size !== 0 + const oldTarget = target instanceof Map ? new Map(target) : new Set(target) + // forward the operation before queueing reactions + const result = proto.clear.call(target) + if (hadItems) { + if (__DEV__) { + trigger(target, OperationTypes.CLEAR, void 0, { oldTarget }) + } else { + trigger(target, OperationTypes.CLEAR) + } + } + return result +} + +function makeImmutableMethod(method: Function, type: OperationTypes): Function { + return function(...args: any[]) { + if (LOCKED) { + if (__DEV__) { + const key = args[0] ? `on key "${args[0]}"` : `` + console.warn( + `${type} operation ${key}failed: target is immutable.`, + unwrap(this) + ) + } + return type === OperationTypes.DELETE ? false : this + } else { + return method.apply(this, args) } } } const mutableInstrumentations: any = { - get, - has, - + get(key: any) { + return get(this, key, observable) + }, get size() { return size(this) }, - - add(key: any) { - const target = unwrap(this) - const proto: any = Reflect.getPrototypeOf(this) - const hadKey = proto.has.call(target, key) - const result = proto.add.apply(target, arguments) - if (!hadKey) { - if (__DEV__) { - trigger(target, OperationTypes.ADD, key, { value: key }) - } else { - trigger(target, OperationTypes.ADD, key) - } - } - return result - }, - - set(key: any, value: any) { - const target = unwrap(this) - const proto: any = Reflect.getPrototypeOf(this) - const hadKey = proto.has.call(target, key) - const oldValue = proto.get.call(target, key) - const result = proto.set.apply(target, arguments) - if (value !== oldValue) { - if (__DEV__) { - const extraInfo = { oldValue, newValue: value } - if (!hadKey) { - trigger(target, OperationTypes.ADD, key, extraInfo) - } else { - trigger(target, OperationTypes.SET, key, extraInfo) - } - } else { - if (!hadKey) { - trigger(target, OperationTypes.ADD, key) - } else { - trigger(target, OperationTypes.SET, key) - } - } - } - return result - }, - - delete(key: any) { - const target = unwrap(this) - const proto: any = Reflect.getPrototypeOf(this) - const hadKey = proto.has.call(target, key) - const oldValue = proto.get ? proto.get.call(target, key) : undefined - // forward the operation before queueing reactions - const result = proto.delete.apply(target, arguments) - if (hadKey) { - if (__DEV__) { - trigger(target, OperationTypes.DELETE, key, { oldValue }) - } else { - trigger(target, OperationTypes.DELETE, key) - } - } - return result - }, - - clear() { - const target = unwrap(this) - const proto: any = Reflect.getPrototypeOf(this) - const hadItems = target.size !== 0 - const oldTarget = target instanceof Map ? new Map(target) : new Set(target) - // forward the operation before queueing reactions - const result = proto.clear.apply(target, arguments) - if (hadItems) { - if (__DEV__) { - trigger(target, OperationTypes.CLEAR, void 0, { oldTarget }) - } else { - trigger(target, OperationTypes.CLEAR) - } - } - return result - } + has, + add, + set, + delete: deleteEntry, + clear } const immutableInstrumentations: any = { - get, - has, + get(key: any) { + return get(this, key, immutable) + }, get size() { return size(this) }, - add: makeWarning(OperationTypes.ADD), - set: makeWarning(OperationTypes.SET), - delete: makeWarning(OperationTypes.DELETE), - clear: makeWarning(OperationTypes.CLEAR) + has, + add: makeImmutableMethod(add, OperationTypes.ADD), + set: makeImmutableMethod(set, OperationTypes.SET), + delete: makeImmutableMethod(deleteEntry, OperationTypes.DELETE), + clear: makeImmutableMethod(clear, OperationTypes.CLEAR) } -;['forEach', 'keys', 'values', 'entries', Symbol.iterator].forEach(key => { - mutableInstrumentations[key] = immutableInstrumentations[ - key - ] = makeInstrumentedMethod(key, OperationTypes.ITERATE) +;['forEach', 'keys', 'values', 'entries', Symbol.iterator].forEach(method => { + mutableInstrumentations[method] = immutableInstrumentations[ + method + ] = function(...args: any[]) { + const target = unwrap(this) + const proto: any = Reflect.getPrototypeOf(target) + track(target, OperationTypes.ITERATE) + return proto[method].apply(target, args) + } }) function getInstrumented( diff --git a/packages/observer/src/index.ts b/packages/observer/src/index.ts index 37959539..ca181a56 100644 --- a/packages/observer/src/index.ts +++ b/packages/observer/src/index.ts @@ -105,7 +105,9 @@ function createObservable( observed = new Proxy(target, handlers) toProxy.set(target, observed) toRaw.set(observed, target) - targetMap.set(target, new Map()) + if (!targetMap.has(target)) { + targetMap.set(target, new Map()) + } return observed }