From 03511ce9ea95e1eea4941182c9f3ce8b48e64649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Thu, 11 Apr 2019 13:08:00 +0200 Subject: [PATCH] ref: reorganize store (based on different principles) --- src/store.ts | 242 +++++++++++++------------ src/utils.ts | 13 -- tests/store.test.ts | 423 ++++++++++++-------------------------------- tests/utils.test.ts | 12 -- 4 files changed, 241 insertions(+), 449 deletions(-) diff --git a/src/store.ts b/src/store.ts index 77ff6ebf..3c65fbe2 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,11 +1,9 @@ import { EventBus } from "./event_bus"; import { Component } from "./component"; -import { shallowEqual } from "./utils"; //------------------------------------------------------------------------------ // Store Definition //------------------------------------------------------------------------------ - interface StoreConfig { env?: any; state?: any; @@ -16,9 +14,9 @@ interface StoreConfig { interface StoreOption { debug?: boolean; } + export class Store extends EventBus { state: any; - mstate: any; actions: any; mutations: any; _isMutating: boolean = false; @@ -26,23 +24,17 @@ export class Store extends EventBus { history: any[] = []; debug: boolean; env: any; + observer: Observer; constructor(config: StoreConfig, options: StoreOption = {}) { super(); this.debug = options.debug || false; - this.state = Object.assign({}, config.state); - const self = this; - this.mstate = magify({ - raw: this.state, - key: "state", - parent: this, - onDirty: function() { - self._isDirty = true; - } - }); + this.state = config.state || {}; this.actions = config.actions; this.mutations = config.mutations; this.env = config.env; + this.observer = makeObserver(); + this.observer.observe(this.state); if (this.debug) { this.history.push({ state: this.state }); @@ -75,8 +67,10 @@ export class Store extends EventBus { throw new Error(`[Error] mutation ${type} is undefined`); } this._isMutating = true; + const currentRev = this.observer.__rev__; + // observer.enableMutating() - this.mutations[type].call(null, this.mstate, payload); + this.mutations[type].call(null, this.state, payload); if (this.debug) { this.history.push({ state: this.state, @@ -87,17 +81,122 @@ export class Store extends EventBus { await Promise.resolve(); if (this._isMutating) { this._isMutating = false; - if (this._isDirty) { - this._isDirty = false; + // this.observer.disableMutations(); + if (currentRev !== this.observer.__rev__) { this.trigger("update", this.state); } } } } +//------------------------------------------------------------------------------ +// Observer +//------------------------------------------------------------------------------ +interface Observer { + __rev__: number; + observe: (val: any) => void; + set: (target: any, key: number | string, value: any) => void; +} + +export function makeObserver(): Observer { + const observer: Observer = { + __rev__: 0, + observe: observe, + set: set + }; + + function set(target: any, key: number | string, value: any) { + if (Array.isArray(target)) { + } else { + addProp(target, key as string, value); + } + target.__rev__++; + observer.__rev__++; + } + + function addProp( + obj: T, + key: string, + value: any + ) { + Object.defineProperty(obj, key, { + get() { + return value; + }, + set(newVal) { + // if (!isMutating) [ + // throw new Error(); + // ] + if (newVal !== value) { + value = newVal; + // observe(newVal); + obj.__rev__!++; + observer.__rev__++; + } + } + }); + observe(value); + } + + function observeObj(obj: T) { + const keys = Object.keys(obj); + obj.__rev__ = 0; + Object.defineProperty(obj, "__rev__", { enumerable: false }); + for (let key of keys) { + addProp(obj, key, obj[key]); + } + } + + const ArrayProto = Array.prototype; + const ModifiedArrayProto = Object.create(ArrayProto); + + ModifiedArrayProto.push = function(...args) { + observer.__rev__++; + this.__rev__++; + return ArrayProto.push.call(this, ...args); + }; + + ModifiedArrayProto.pop = function(...args) { + observer.__rev__++; + this.__rev__++; + return ArrayProto.pop.call(this, ...args); + }; + function observeArr(arr: Array) { + (arr).__rev__ = 0; + Object.defineProperty(arr, "__rev__", { enumerable: false }); + (arr).__proto__ = ModifiedArrayProto; + // for (let i = 0; i < arr.length; i++) { + // observe(arr[i]); + // } + } + + function observe(value: any) { + if (typeof value !== "object") { + return; + } + if (Array.isArray(value)) { + observeArr(value); + } else { + observeObj(value); + } + } + + return observer; +} + //------------------------------------------------------------------------------ // Connect function //------------------------------------------------------------------------------ + +function setStoreProps(__widget__: any, storeProps: any) { + __widget__.currentStoreProps = storeProps; + __widget__.currentStoreRevs = {}; + __widget__.currentStoreRev = storeProps.__rev__; + for (let key in storeProps) { + __widget__.currentStoreRevs[key] = storeProps[key].__rev__; + } +} + export function connect(mapStateToProps) { return function(Comp) { return class extends Comp { @@ -107,15 +206,29 @@ export function connect(mapStateToProps) { const storeProps = mapStateToProps(env.store.state, ownProps); const mergedProps = Object.assign(props || {}, storeProps); super(parent, mergedProps); - this.__widget__.currentStoreProps = storeProps; + setStoreProps(this.__widget__, storeProps); this.__widget__.ownProps = ownProps; } mounted() { this.env.store.on("update", this, () => { const ownProps = this.__widget__.ownProps; const storeProps = mapStateToProps(this.env.store.state, ownProps); - if (!shallowEqual(storeProps, this.__widget__.currentStoreProps)) { - this.__widget__.currentStoreProps = storeProps; + let didChange = false; + if (this.__widget__.currentStoreRev !== storeProps.__rev__) { + setStoreProps(this.__widget__, storeProps); + didChange = true; + } else { + const revs = this.__widget__.currentStoreRevs; + for (let key in storeProps) { + const val = storeProps[key]; + if (val.__rev__ !== revs[key]) { + didChange = true; + revs[key] = val.__rev__; + this.__widget__.currentStoreProps[key] = val; + } + } + } + if (didChange) { this.updateProps(ownProps, false); } }); @@ -143,94 +256,3 @@ export function connect(mapStateToProps) { }; }; } - -//------------------------------------------------------------------------------ -// Magic Stuff -//------------------------------------------------------------------------------ - -function _magifyArray({ raw, key, parent, onDirty }) { - let magic = { raw, key, parent, magic: true }; - Object.assign(magic, { - push: function(item) { - onDirty(); - parent.raw[key] = [...magic.raw, item]; - magic.raw = parent.raw[key]; - const index = magic.raw.length - 1; - let prop = magify({ raw: item, key: index, parent: magic, onDirty }); - Object.defineProperty(magic, index, { - set(newVal) { - onDirty(); - parent.raw[key] = [...magic.raw]; - parent.raw[key][index] = newVal; - magic.raw = parent.raw[key]; - prop = magify({ raw: newVal, key: index, parent: magic, onDirty }); - }, - get() { - return prop; - } - }); - } - }); - raw.forEach((value, index) => { - let prop = magify({ - raw: value, - key: index, - parent: magic, - onDirty - }); - Object.defineProperty(magic, index, { - set(newVal) { - onDirty(); - parent.raw[key] = [...magic.raw]; - parent.raw[key][index] = newVal; - magic.raw = parent.raw[key]; - prop = magify({ raw: newVal, key: index, parent: magic, onDirty }); - }, - get() { - return prop; - } - }); - }); - return magic; -} - -function magify({ raw, key, parent, onDirty }) { - if (!parent.magic) { - parent = { - raw: parent, - magic: true, - parent: null - }; - } - if (raw.magic) { - return raw; - } - if (typeof raw !== "object") { - return raw; - } - if (Array.isArray(raw)) { - return _magifyArray({ raw, key, parent, onDirty }); - } else { - let magic = { raw, key, parent, magic: true }; - Object.keys(raw).forEach(propKey => { - let prop = magify({ - raw: raw[propKey], - key: propKey, - parent: magic, - onDirty - }); - Object.defineProperty(magic, propKey, { - set(newVal) { - onDirty(); - parent.raw[key] = { ...magic.raw, [propKey]: newVal }; - magic.raw = parent.raw[key]; - prop = magify({ raw: newVal, key: propKey, parent: magic, onDirty }); - }, - get() { - return prop; - } - }); - }); - return magic; - } -} diff --git a/src/utils.ts b/src/utils.ts index 4f686290..89ea0d55 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -113,19 +113,6 @@ export function findInTree>( return null; } -export function shallowEqual(objA, objB) { - if (objA === objB) { - return true; - } - const keysA = Object.keys(objA); - for (let key of keysA) { - if (!(key in objB) || objA[key] !== objB[key]) { - return false; - } - } - return true; -} - export function patch(C: any, patchName: string, patch: any) { const proto = C.prototype; if (!proto.__patches) { diff --git a/tests/store.test.ts b/tests/store.test.ts index 2dd653db..f0c16abf 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -1,11 +1,11 @@ -import { Store, connect } from "../src/store"; -import { Component, Env } from "../src/component"; +import { Store, makeObserver, connect } from "../src/store"; import { + nextTick, nextMicroTick, - makeTestWEnv, makeTestFixture, - nextTick + makeTestWEnv } from "./helpers"; +import { Env, Component } from "../src/component"; describe("basic use", () => { test("commit a mutation", () => { @@ -106,110 +106,104 @@ describe("basic use", () => { }); }); +describe("observer", () => { + test("properly observe objects", () => { + const observer = makeObserver(); + const obj: any = {}; + + observer.observe(obj); + expect(obj.__rev__).toBe(0); + expect(observer.__rev__).toBe(0); + + const ob2: any = { a: 1 }; + observer.observe(ob2); + expect(ob2.__rev__).toBe(0); + ob2.a = 2; + expect(observer.__rev__).toBe(1); + expect(ob2.__rev__).toBe(1); + + ob2.b = 3; + expect(observer.__rev__).toBe(1); + expect(ob2.__rev__).toBe(1); + + observer.set(ob2, "b", 4); + expect(observer.__rev__).toBe(2); + expect(ob2.__rev__).toBe(2); + }); + + test("various object property changes", () => { + const observer = makeObserver(); + const obj: any = { a: 1 }; + observer.observe(obj); + expect(obj.__rev__).toBe(0); + obj.a = 2; + expect(observer.__rev__).toBe(1); + expect(obj.__rev__).toBe(1); + + // same value again + obj.a = 2; + expect(observer.__rev__).toBe(1); + expect(obj.__rev__).toBe(1); + + obj.a = 3; + expect(observer.__rev__).toBe(2); + expect(obj.__rev__).toBe(2); + }); + + test("properly observe arrays", () => { + const observer = makeObserver(); + const arr: any = []; + observer.observe(arr); + expect(arr.__rev__).toBe(0); + expect(observer.__rev__).toBe(0); + expect(arr.length).toBe(0); + + arr.push(1); + expect(arr.__rev__).toBe(1); + expect(observer.__rev__).toBe(1); + expect(arr.length).toBe(1); + }); + + test("properly observe arrays in object", () => { + const observer = makeObserver(); + const state: any = { arr: [] }; + observer.observe(state); + expect(state.arr.__rev__).toBe(0); + expect(observer.__rev__).toBe(0); + expect(state.arr.length).toBe(0); + + state.arr.push(1); + expect(state.arr.__rev__).toBe(1); + expect(observer.__rev__).toBe(1); + expect(state.arr.length).toBe(1); + }); +}); + describe("advanced state properties", () => { - test("state in the store is reference equal after empty mutation", async () => { + test("state in the store is reference equal after mutation", async () => { + const state = {}; const mutations = { donothing() {} }; - const store = new Store({ state: {}, mutations }); - const state = store.state; + const store = new Store({ state, mutations }); + expect(store.state).toBe(state); store.commit("donothing"); expect(store.state).toBe(state); }); - test("state in the store is not reference equal after changing one number value", async () => { + test("can use array properties in mutations", () => { + expect.assertions(3); + const state = { a: [1, 2, 3] }; const mutations = { - dosomething(state) { - expect(state.rochefort).toBe(8); - state.rochefort += 2; - expect(state.rochefort).toBe(10); - } - }; - const store = new Store({ state: { rochefort: 8 }, mutations }); - const state = store.state; - expect(state.rochefort).toBe(8); - store.commit("dosomething"); - expect(store.state.rochefort).toBe(10); - expect(store.state).not.toBe(state); - }); - - test("can push an item in a list with an existing item", async () => { - const mutations = { - addRochefort(state) { - state.rocheforts.push({ taste: 87 }); - } - }; - const store = new Store({ - state: { rocheforts: [{ taste: 84 }] }, - mutations - }); - const state = store.state; - store.commit("addRochefort"); - expect(store.state.rocheforts).toEqual([{ taste: 84 }, { taste: 87 }]); - expect(store.state).toBe(state); - }); - - test("state is reference equal after pushing in a list", async () => { - const mutations = { - addRochefort(state) { - state.rocheforts.push(10); - } - }; - const store = new Store({ state: { rocheforts: [] }, mutations }); - const state = store.state; - store.commit("addRochefort"); - expect(store.state.rocheforts).toEqual([10]); - expect(store.state).toBe(state); - }); - - test("nested state in the store behaves properly", async () => { - const state = { jupiler: { maes: 1 } }; - const mutations = { - dostuff(state, inc) { - state.jupiler.maes += inc; + m(state) { + expect(state.a.length).toBe(3); + const l = state.a.push(53); + expect(l).toBe(4); } }; const store = new Store({ state, mutations }); - expect(store.state.jupiler.maes).toBe(1); - const stateA = store.state; - const jupiler = store.state.jupiler; - store.commit("dostuff", 10); - expect(store.state.jupiler.maes).toBe(11); - expect(store.state).toBe(stateA); - expect(store.state.jupiler).not.toBe(jupiler); - }); - - test("sibling properties are not affected", async () => { - const state = { jupiler: { maes: 1 }, stella: 3 }; - const mutations = { - dostuff(state, inc) { - state.stella += inc; - } - }; - const store = new Store({ state, mutations }); - const jupiler = store.state.jupiler; - const stateA = store.state; - store.commit("dostuff", 10); - expect(store.state.stella).toBe(13); - expect(store.state).not.toBe(stateA); - expect(store.state.jupiler).toBe(jupiler); - }); - - test("interaction between mutations on sibling properties", async () => { - const state = { jupiler: { maes: 1 }, stella: 3 }; - const mutations = { - dostuffA(state, inc) { - state.stella += inc; - }, - dostuffB(state, inc) { - state.jupiler.maes += inc; - } - }; - const store = new Store({ state, mutations }); - store.commit("dostuffA", 10); - const jupiler = store.state.jupiler; - store.commit("dostuffB", 10); - expect(store.state.jupiler).not.toBe(jupiler); + store.commit("m"); + expect(store.state.a).toEqual([1, 2, 3, 53]); }); test("can use object assign in store", async () => { @@ -234,217 +228,9 @@ describe("advanced state properties", () => { }; const state = { counter: 0 }; const store = new Store({ state, mutations }); - const curState = store.state; expect(store.state.counter).toBe(0); store.commit("inc", {}); expect(store.state.counter).toBe(1); - expect(store.state).not.toBe(curState); - }); - - test("aku reactive store state 2", async () => { - const mutations = { - inc(state) { - state.convo.counter++; - } - }; - const state = { convo: { counter: 0 } }; - const store = new Store({ state, mutations }); - const curState = store.state; - const convo = state.convo; - expect(store.state.convo.counter).toBe(0); - store.commit("inc", {}); - expect(store.state.convo.counter).toBe(1); - expect(store.state.convo).not.toBe(convo); - expect(store.state).toBe(curState); - }); - - test("aku reactive store state 3", async () => { - const mutations = { - inc1(state) { - state.threads[1].counter++; - }, - inc2(state) { - state.threads[2].counter++; - } - }; - const state = { - threads: { - 1: { counter: 0 }, - 2: { counter: 0 } - } - }; - const store = new Store({ state, mutations }); - const curState = store.state; - const threads = state.threads; - const thread1 = state.threads[1]; - const thread2 = state.threads[2]; - expect(store.state.threads[1].counter).toBe(0); - expect(store.state.threads[2].counter).toBe(0); - store.commit("inc1", {}); - expect(store.state.threads[1].counter).toBe(1); - expect(store.state.threads[2].counter).toBe(0); - expect(store.state.threads[1]).not.toBe(thread1); - expect(store.state.threads[2]).toBe(thread2); - expect(store.state.threads).toBe(threads); - expect(store.state).toBe(curState); - const newThread1 = curState.threads[1]; - store.commit("inc2", {}); - expect(store.state.threads[1].counter).toBe(1); - expect(store.state.threads[2].counter).toBe(1); - expect(store.state.threads[1]).toBe(newThread1); - expect(store.state.threads[2]).not.toBe(thread2); - expect(store.state.threads).toBe(threads); - expect(store.state).toBe(curState); - }); - - test("aku reactive store state 4", async () => { - const mutations = { - incT(state) { - state.threads[1].counter++; - }, - incM(state) { - state.messages[1].counter++; - } - }; - const state = { - threads: { - 1: { counter: 0 } - }, - messages: { - 1: { counter: 0 } - } - }; - const store = new Store({ state, mutations }); - const curState = store.state; - const threads = state.threads; - const messages = state.messages; - const thread = state.threads[1]; - const message = state.messages[1]; - expect(store.state.threads[1].counter).toBe(0); - expect(store.state.messages[1].counter).toBe(0); - store.commit("incT", {}); - expect(store.state.threads[1].counter).toBe(1); - expect(store.state.messages[1].counter).toBe(0); - expect(store.state.threads[1]).not.toBe(thread); - expect(store.state.messages[1]).toBe(message); - expect(store.state.threads).toBe(threads); - expect(store.state.messages).toBe(messages); - expect(store.state).toBe(curState); - const newThread = curState.threads[1]; - store.commit("incM", {}); - expect(store.state.threads[1].counter).toBe(1); - expect(store.state.messages[1].counter).toBe(1); - expect(store.state.threads[1]).toBe(newThread); - expect(store.state.messages[1]).not.toBe(message); - expect(store.state.threads).toBe(threads); - expect(store.state.messages).toBe(messages); - expect(store.state).toBe(curState); - }); - - test("aku reactive store state 5", async () => { - const mutations = { - inc(state) { - state.counter++; - }, - incT(state) { - state.threads[1].counter++; - } - }; - const state = { - threads: { - 1: { counter: 0 } - }, - counter: 0 - }; - const store = new Store({ state, mutations }); - const curState = store.state; - const threads = state.threads; - const thread = state.threads[1]; - expect(store.state.counter).toBe(0); - expect(store.state.threads[1].counter).toBe(0); - store.commit("inc", {}); - expect(store.state.counter).toBe(1); - expect(store.state.threads[1].counter).toBe(0); - expect(store.state.threads[1]).toBe(thread); - expect(store.state.threads).toBe(threads); - expect(store.state).not.toBe(curState); - const newCurState = store.state; - store.commit("incT", {}); - expect(store.state.counter).toBe(1); - expect(store.state.threads[1].counter).toBe(1); - expect(store.state.threads[1]).not.toBe(thread); - expect(store.state.threads).toBe(threads); - expect(store.state).toBe(newCurState); - }); - - test("aku reactive store state 6", async () => { - const mutations = { - inc1(state) { - state.threads[1].c1++; - }, - inc2(state) { - state.threads[1].c2++; - } - }; - const state = { - threads: { - 1: { c1: 0, c2: 0 } - } - }; - const store = new Store({ state, mutations }); - const curState = store.state; - const threads = state.threads; - const thread = state.threads[1]; - expect(store.state.threads[1].c1).toBe(0); - expect(store.state.threads[1].c2).toBe(0); - store.commit("inc1", {}); - expect(store.state.threads[1].c1).toBe(1); - expect(store.state.threads[1].c2).toBe(0); - expect(store.state.threads[1]).not.toBe(thread); - expect(store.state.threads).toBe(threads); - expect(store.state).toBe(curState); - const newThread = store.state.threads[1]; - store.commit("inc2", {}); - expect(store.state.threads[1].c1).toBe(1); - expect(store.state.threads[1].c2).toBe(1); - expect(store.state.threads[1]).not.toBe(newThread); - expect(store.state.threads).toBe(threads); - expect(store.state).toBe(curState); - }); - - test("aku reactive store state 7", async () => { - const mutations = { - register(state) { - state.threads[1].messages.push(1); - } - }; - const state = { - threads: { - 1: { - messages: [] - } - }, - messages: { - 1: {} - } - }; - const store = new Store({ state, mutations }); - const curState = store.state; - const threads = state.threads; - const messages = state.messages; - const thread = state.threads[1]; - const threadMessages = state.threads[1].messages; - const message = state.messages[1]; - expect(store.state.threads[1].messages.length).toBe(0); - store.commit("register", {}); - expect(store.state.threads[1].messages.length).toBe(1); - expect(store.state.threads[1].messages[0]).toBe(1); - expect(store.state.threads[1].messages).not.toBe(threadMessages); - expect(store.state.threads[1]).toBe(thread); - expect(store.state.threads).toBe(threads); - expect(store.state.messages[1]).toBe(message); - expect(store.state.messages).toBe(messages); - expect(store.state).toBe(curState); }); }); @@ -475,7 +261,11 @@ describe("updates triggered by the store", () => { inc(state, delta) { state.n += delta; }, - noop() {} + noop() {}, + noop2(state) { + const val = state.n; + state.n = val; + } }; const store = new Store({ state, mutations }); store.on("update", null, () => updateCounter++); @@ -483,9 +273,14 @@ describe("updates triggered by the store", () => { store.commit("noop"); await nextMicroTick(); expect(updateCounter).toBe(0); + store.commit("inc", 50); await nextMicroTick(); expect(updateCounter).toBe(1); + + store.commit("noop2"); + await nextMicroTick(); + expect(updateCounter).toBe(1); }); }); @@ -554,9 +349,9 @@ describe("connecting a component to store", () => { class Parent extends Component { inlineTemplate = ` -
- -
`; +
+ +
`; widgets = { ConnectedChild }; constructor(env: Env) { @@ -596,10 +391,10 @@ describe("connecting a component to store", () => { class TodoList extends Component { inlineTemplate = `
- - - -
`; + + + + `; widgets = { ConnectedTodo }; } @@ -631,8 +426,8 @@ describe("connecting a component to store", () => { class App extends Component { inlineTemplate = `
- -
`; + + `; widgets = { ConnectedBeer }; state = { beerId: 1 }; } diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 980be636..b15ba167 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -5,7 +5,6 @@ import { memoize, debounce, findInTree, - shallowEqual, patch, unpatch } from "../src/utils"; @@ -95,17 +94,6 @@ describe("findInTree", () => { }); }); -describe("shallowEqual", () => { - test("simple comparisons", () => { - const obj1 = {}; - expect(shallowEqual(obj1, obj1)).toBe(true); - expect(shallowEqual({}, {})).toBe(true); - expect(shallowEqual({ a: 1 }, {})).toBe(false); - expect(shallowEqual({ a: 1 }, { a: 1 })).toBe(true); - expect(shallowEqual({ a: 1 }, ["a"])).toBe(false); - }); -}); - describe("patch/unpatch", () => { test("can monkey patch a class", () => { class Test {