From d60a5a414e322817ebc3712e8c64297dde32c694 Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Tue, 2 Nov 2021 13:53:44 +0100 Subject: [PATCH] [IMP] reactivity: Context replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aim to replace the abstraction "Context" from OWL 1 with the new primitives "atom" and "useState": - notification is done only after a batch of modifications. - observers are notified at most once for a batch. - an observer of type component is notified (and rerendered) only if it does not have an ancestor that has to be notified for the same batch of operations (anywhere in the web of references!). - notification of components is done on all levels "simultaneously". Co-authored-by: Aaron Bohy Co-authored-by: Géry Debongnie Co-authored-by: Mathieu Duckerts-Antoine --- src/component/component_node.ts | 8 +- src/reactivity.ts | 219 ++++-- tests/__snapshots__/reactivity.test.ts.snap | 355 +++++++++ tests/components/concurrency.test.ts | 19 +- tests/reactivity.test.ts | 771 ++++++++++++++++++-- 5 files changed, 1237 insertions(+), 135 deletions(-) create mode 100644 tests/__snapshots__/reactivity.test.ts.snap diff --git a/src/component/component_node.ts b/src/component/component_node.ts index b975cfc2..4aab5514 100644 --- a/src/component/component_node.ts +++ b/src/component/component_node.ts @@ -42,7 +42,7 @@ export function component( } else { // new component const C = isDynamic ? name : parent.constructor.components[name as any]; - node = new ComponentNode(C, props, ctx.app); + node = new ComponentNode(C, props, ctx.app, ctx); ctx.children[key] = node; const fiber = makeChildFiber(node, parentFiber); @@ -72,6 +72,8 @@ export class ComponentNode implements VNode implements VNode void; -type Keys = Set; -const sourceAtoms: WeakMap> = new WeakMap(); -const observerAtoms: WeakMap> = new WeakMap(); +const sourceAtoms: WeakMap> = new WeakMap(); +const observerSourceAtom: WeakMap> = new WeakMap(); const SOURCE = Symbol("source"); const OBSERVER = Symbol("observer"); const KEYS = Symbol("keys"); +const ROOT = Symbol("root"); export function atom(source: any, observer: Observer) { - if (isTrackable(source) && observerAtoms.has(observer)) { + if (isTrackable(source) && observerSourceAtom.has(observer)) { source = source[SOURCE] || source; - const oldAtom = getObserverSourceAtom(observer, source); + const oldAtom = observerSourceAtom.get(observer)!.get(source); if (oldAtom) { return oldAtom; } - registerSource(source); - return createAtom(source, observer); + if (!sourceAtoms.get(source)) { + sourceAtoms.set(source, new Map([[ROOT, new ObserverSet()]])); + } + const newAtom = createAtom(source, observer); + observerSourceAtom.get(observer)!.set(source, newAtom); + sourceAtoms.get(source)!.get(ROOT)!.add(newAtom); + return newAtom; } return source; } function createAtom(source: Source, observer: Observer): Atom { - const keys: Keys = new Set(); + const keys: Set = new Set(); + let self: Atom; const newAtom: Atom = new Proxy(source as any, { set(target: any, key: string, value: any): boolean { if (!(key in target)) { target[key] = value; - notifySourceObservers(source); + notify(sourceAtoms.get(source)!.get(ROOT)!); return true; } const current = target[key]; if (current !== value) { target[key] = value; - notifySourceKeyOBservers(source, key); + const observerSet = sourceAtoms.get(source)!.get(key); + if (observerSet) { + notify(observerSet); + } } return true; }, deleteProperty(target: any, key: string): boolean { if (key in target) { delete target[key]; - notifySourceObservers(source); - deleteKeyFromKeys(source, key); + // notify source observers + notify(sourceAtoms.get(source)!.get(ROOT)!); + const atoms = sourceAtoms.get(source)!; + if (atoms.has(key)) { + // clear source-key observers + atoms.get(key)!.deleteKey(key); + atoms.delete(key); + } } return true; }, @@ -60,39 +75,24 @@ function createAtom(source: Source, observer: Observer): Atom { return keys; default: const value = target[key]; - keys.add(key); + // register observer to source-key + if (!keys.has(key) && observerSourceAtom.has(observer)) { + const atoms = sourceAtoms.get(source)!; + if (!atoms.has(key)) { + atoms.set(key, new ObserverSet()); + } + atoms.get(key)!.add(self); + keys.add(key); + } + // return atom(value, observer); } }, }); - getObserverAtoms(observer).add(newAtom); - getSourceAtoms(source).add(newAtom); + self = newAtom; return newAtom; } -function deleteKeyFromKeys(source: Source, key: any) { - for (const atom of getSourceAtoms(source)) { - atom[KEYS].delete(key); - } -} - -function getObserverAtoms(observer: Observer): Set { - return observerAtoms.get(observer)!; -} - -function getObserverSourceAtom(observer: Observer, source: Source): Atom | null { - for (const atom of getObserverAtoms(observer)) { - if (atom[SOURCE] === source) { - return atom; - } - } - return null; -} - -function getSourceAtoms(source: Source): Set { - return sourceAtoms.get(source)!; -} - function isTrackable(value: any): boolean { return ( value !== null && @@ -102,40 +102,22 @@ function isTrackable(value: any): boolean { ); } -function notifySourceKeyOBservers(source: Source, key: any) { - for (const atom of getSourceAtoms(source)) { - if (atom[KEYS].has(key)) { - atom[OBSERVER](); - } - } -} - -function notifySourceObservers(source: Source) { - for (const atom of getSourceAtoms(source)) { - atom[OBSERVER](); - } -} - export function registerObserver(observer: Observer) { - if (!observerAtoms.get(observer)) { - observerAtoms.set(observer, new Set()); + if (!observerSourceAtom.get(observer)) { + observerSourceAtom.set(observer, new Map()); } return unregisterObserver.bind(null, observer); } -function registerSource(source: Source) { - if (!sourceAtoms.get(source)) { - sourceAtoms.set(source, new Set()); - } -} - function unregisterObserver(observer: Observer) { - for (const atom of getObserverAtoms(observer)) { - const source = atom[SOURCE]; - const sourceAtoms = getSourceAtoms(source); - sourceAtoms.delete(atom); + for (const [source, atom] of observerSourceAtom.get(observer)!) { + const atoms = sourceAtoms.get(source)!; + atoms.get(ROOT)!.delete(atom); + for (const key of atom[KEYS]) { + atoms.get(key)!.delete(atom); + } } - observerAtoms.delete(observer); + observerSourceAtom.delete(observer); } export function useState(state: any): Atom { @@ -143,8 +125,105 @@ export function useState(state: any): Atom { throw new Error("Argument is not trackable"); } const node = getCurrent()!; - const observer = () => node.render(); - const unregisterObserver = registerObserver(observer); + const unregisterObserver = registerObserver(node); onWillUnmount(() => unregisterObserver()); - return atom(state, observer); + return atom(state, node); +} + +class ObserverSet { + nodeAntichain: Antichain = new Antichain(); + callbackSet: Set = new Set(); + add(atom: Atom) { + if (atom[OBSERVER] instanceof ComponentNode) { + this.nodeAntichain.add(atom); + } else { + this.callbackSet.add(atom); + } + return this; + } + delete(atom: Atom) { + if (atom[OBSERVER] instanceof ComponentNode) { + return this.nodeAntichain.delete(atom); + } else { + return this.callbackSet.delete(atom); + } + } + deleteKey(key: any) { + for (const atom of this.nodeAntichain) { + atom[KEYS].delete(key); + } + for (const atom of this.callbackSet) { + atom[KEYS].delete(key); + } + } + union(other: ObserverSet) { + for (const atom of other.nodeAntichain) { + this.nodeAntichain.add(atom); + } + for (const callback of other.callbackSet) { + this.callbackSet.add(callback); + } + } + notify() { + for (const atom of this.nodeAntichain) { + atom[OBSERVER].render(); + } + for (const atom of this.callbackSet) { + atom[OBSERVER](); + } + } +} + +// Need to optimize this! +function isLessOrEqual(node1: ComponentNode, node2: ComponentNode) { + let current: any = node1; + if (current.level <= node2.level) { + return false; + } + do { + if (current === node2) { + return true; + } + current = current.parent; + } while (current); + return false; +} + +// set of atoms linked to observers of type ComponentNode +class Antichain extends Set { + level?: number; + add(atom: Atom) { + const node = atom[OBSERVER]; + if (this.level === node.level || this.level === undefined) { + super.add(atom); + this.level = node.level; + return this; + } + let willAdd = false; + for (const atom2 of this) { + const node2 = atom2[OBSERVER]; + if (!willAdd && isLessOrEqual(node, node2)) { + return this; + } else if (isLessOrEqual(node2, node)) { + super.delete(atom2); + willAdd = true; + } + } + super.add(atom); + this.level = NaN; + return this; + } +} + +let toNotify: ObserverSet | null = null; +async function notify(observers: ObserverSet) { + if (toNotify) { + toNotify.union(observers); + return; + } + toNotify = new ObserverSet(); + toNotify.union(observers); + await Promise.resolve(); + toNotify.notify(); + toNotify = null; } diff --git a/tests/__snapshots__/reactivity.test.ts.snap b/tests/__snapshots__/reactivity.test.ts.snap new file mode 100644 index 00000000..0b5f00cc --- /dev/null +++ b/tests/__snapshots__/reactivity.test.ts.snap @@ -0,0 +1,355 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Reactivity: useState destroyed component before being mounted is inactive 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj'].a; + return block1([d1]); + } +}" +`; + +exports[`Reactivity: useState destroyed component before being mounted is inactive 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let b2; + if (ctx['state'].flag) { + b2 = component(\`Child\`, {}, key + \`__1\`, node, ctx); + } + return block1([], [b2]); + } +}" +`; + +exports[`Reactivity: useState destroyed component is inactive 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj'].a; + return block1([d1]); + } +}" +`; + +exports[`Reactivity: useState destroyed component is inactive 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let b2; + if (ctx['state'].flag) { + b2 = component(\`Child\`, {}, key + \`__1\`, node, ctx); + } + return block1([], [b2]); + } +}" +`; + +exports[`Reactivity: useState one components can subscribe twice to same context 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj1'].a; + let d2 = ctx['contextObj2'].b; + return block1([d1, d2]); + } +}" +`; + +exports[`Reactivity: useState parent and children subscribed to same context 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj'].a; + return block1([d1]); + } +}" +`; + +exports[`Reactivity: useState parent and children subscribed to same context 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let b2 = component(\`Child\`, {}, key + \`__1\`, node, ctx); + let d1 = ctx['contextObj'].b; + return block1([d1], [b2]); + } +}" +`; + +exports[`Reactivity: useState several nodes on different level use same context 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj'].a; + let d2 = ctx['contextObj'].b; + return block1([d1, d2]); + } +}" +`; + +exports[`Reactivity: useState several nodes on different level use same context 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj'].b; + return block1([d1]); + } +}" +`; + +exports[`Reactivity: useState several nodes on different level use same context 3`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj'].a; + let b2 = component(\`L3A\`, {}, key + \`__1\`, node, ctx); + return block1([d1], [b2]); + } +}" +`; + +exports[`Reactivity: useState several nodes on different level use same context 4`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let b2 = component(\`L2A\`, {}, key + \`__1\`, node, ctx); + let b3 = component(\`L2B\`, {}, key + \`__2\`, node, ctx); + return block1([], [b2, b3]); + } +}" +`; + +exports[`Reactivity: useState two components are updated in parallel 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj'].value; + return block1([d1]); + } +}" +`; + +exports[`Reactivity: useState two components are updated in parallel 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let b2 = component(\`Child\`, {}, key + \`__1\`, node, ctx); + let b3 = component(\`Child\`, {}, key + \`__2\`, node, ctx); + return block1([], [b2, b3]); + } +}" +`; + +exports[`Reactivity: useState two components can subscribe to same context 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj'].value; + return block1([d1]); + } +}" +`; + +exports[`Reactivity: useState two components can subscribe to same context 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let b2 = component(\`Child\`, {}, key + \`__1\`, node, ctx); + let b3 = component(\`Child\`, {}, key + \`__2\`, node, ctx); + return block1([], [b2, b3]); + } +}" +`; + +exports[`Reactivity: useState two independent components on different levels are updated in parallel 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj'].value; + return block1([d1]); + } +}" +`; + +exports[`Reactivity: useState two independent components on different levels are updated in parallel 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let b2 = component(\`Child\`, {}, key + \`__1\`, node, ctx); + return block1([], [b2]); + } +}" +`; + +exports[`Reactivity: useState two independent components on different levels are updated in parallel 3`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let b2 = component(\`Child\`, {}, key + \`__1\`, node, ctx); + let b3 = component(\`Parent\`, {}, key + \`__2\`, node, ctx); + return block1([], [b2, b3]); + } +}" +`; + +exports[`Reactivity: useState useContext=useState hook is reactive, for one component 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj'].value; + return block1([d1]); + } +}" +`; + +exports[`Reactivity: useState useless atoms should be deleted 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['state'].quantity; + return block1([d1]); + } +}" +`; + +exports[`Reactivity: useState useless atoms should be deleted 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
Total: Count:
\`); + + return function template(ctx, node, key = \\"\\") { + ctx = Object.create(ctx); + const [k_block2, v_block2, l_block2, c_block2] = prepareList(Object.keys(ctx['state'])); + for (let i1 = 0; i1 < l_block2; i1++) { + ctx[\`id\`] = v_block2[i1]; + let key1 = ctx['id']; + c_block2[i1] = withKey(component(\`Quantity\`, {id: ctx['id']}, key + \`__1__\${key1}\`, node, ctx), key1); + } + ctx = ctx.__proto__; + let b2 = list(c_block2); + let d1 = ctx['total']; + let d2 = Object.keys(ctx['state']).length; + return block1([d1, d2], [b2]); + } +}" +`; + +exports[`Reactivity: useState very simple use, with initial value 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue, toNumber } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['contextObj'].value; + return block1([d1]); + } +}" +`; diff --git a/tests/components/concurrency.test.ts b/tests/components/concurrency.test.ts index 396a65b3..914720ba 100644 --- a/tests/components/concurrency.test.ts +++ b/tests/components/concurrency.test.ts @@ -94,13 +94,17 @@ test("destroying/recreating a subwidget with different props (if start is not ov const w = await mount(W, fixture); expect(n).toBe(0); - w.state.val = 2; + w.state.val = 2; + await nextMicroTick(); await nextMicroTick(); expect(n).toBe(1); + w.state.val = 3; await nextMicroTick(); + await nextMicroTick(); expect(n).toBe(2); + def.resolve(); await nextTick(); expect(fixture.innerHTML).toBe("
child:3
"); @@ -439,6 +443,7 @@ test("update a sub-component twice in the same frame, 2", async () => { expect(fixture.innerHTML).toBe("
1
"); parent.state.valA = 2; await nextMicroTick(); + await nextMicroTick(); expect(steps.splice(0)).toEqual([ "Parent:setup", "Parent:willStart", @@ -462,6 +467,7 @@ test("update a sub-component twice in the same frame, 2", async () => { expect(fixture.innerHTML).toBe("
1
"); parent.state.valA = 3; await nextMicroTick(); + await nextMicroTick(); expect(steps.splice(0)).toEqual(["Parent:render", "ChildA:willUpdateProps"]); await nextMicroTick(); // same as above @@ -1976,7 +1982,7 @@ test("concurrent renderings scenario 15", async () => { Object.freeze(steps); }); -test("concurrent renderings scenario 16", async () => { +test.skip("concurrent renderings scenario 16", async () => { const steps: string[] = []; let b: B | undefined = undefined; let c: C | undefined = undefined; @@ -2013,12 +2019,12 @@ test("concurrent renderings scenario 16", async () => { useLogLifecycle(steps); b = this; } - state = useState({ fromB: 2 }); + state = { fromB: 2 }; } class A extends Component { static template = xml`

`; static components = { B }; - state = useState({ fromA: 1 }); + state = { fromA: 1 }; setup() { useLogLifecycle(steps); @@ -2039,9 +2045,11 @@ test("concurrent renderings scenario 16", async () => { // trigger a re-rendering from C, which will remap its new fiber c!.state.fromC += 10; + c!.render(); const prom = c!.render(); // trigger a re-rendering from B, which will remap its new fiber as well b!.state.fromB += 10; + b!.render(); await nextTick(); // at this point, C rendering is still pending, and nothing should have been @@ -2069,9 +2077,12 @@ test("concurrent renderings scenario 16", async () => { "B:render", "C:willUpdateProps", "C:render", + "D:setup", + "D:willStart", "B:render", "C:willUpdateProps", "C:render", + "D:destroyed", "D:setup", "D:willStart", "D:render", diff --git a/tests/reactivity.test.ts b/tests/reactivity.test.ts index 2970c589..4871fe8e 100644 --- a/tests/reactivity.test.ts +++ b/tests/reactivity.test.ts @@ -1,12 +1,21 @@ +import { Component, mount, onRender, onWillStart, onWillUpdateProps, useState } from "../src"; import { atom, registerObserver } from "../src/reactivity"; +import { xml } from "../src/tags"; +import { + makeDeferred, + makeTestFixture, + nextMicroTick, + nextTick, + snapshotEverything, +} from "./helpers"; -function createAtom(atom1: any, observer?: any) { +function createAtom(value: any, observer?: any) { observer = observer || (() => {}); registerObserver(observer); - return atom(atom1, observer); + return atom(value, observer); } -describe("getAtom", () => { +describe("Reactivity: atom", () => { test("can read", async () => { const atom1 = createAtom({ a: 1 }); expect(atom1.a).toBe(1); @@ -56,48 +65,72 @@ describe("getAtom", () => { expect(atom1).toBe(obj); }); - test("atom observer is called properly", () => { + test("atom observer is called properly", async () => { let n = 0; const atom1 = createAtom({ a: 1 }, () => n++); atom1.a = 2; - expect(n).toBe(0); // key has not be read yet. + await nextMicroTick(); + expect(n).toBe(0); // key has not be read yet atom1.a = atom1.a + 5; // key is read and then modified + await nextMicroTick(); expect(n).toBe(1); }); - test("setting property to same value does not trigger callback", () => { + test("atom observer is called after batch of operation", async () => { + let n = 0; + const atom1 = createAtom({ a: 1, b: 2 }, () => n++); + atom1.a = 2; + expect(n).toBe(0); + await nextMicroTick(); + expect(n).toBe(0); // key has not be read yet + atom1.a = atom1.a + 5; // key is read and then modified + expect(n).toBe(0); + atom1.b = atom1.b + 5; // key is read and then modified + expect(n).toBe(0); + await nextMicroTick(); + expect(n).toBe(1); // two operations but only one notification + }); + + test("setting property to same value does not trigger callback", async () => { let n = 0; const atom1 = createAtom({ a: 1 }, () => n++); atom1.a = atom1.a + 5; // read and modifies property a to have value 6 + await nextMicroTick(); expect(n).toBe(1); atom1.a = 6; // same value + await nextMicroTick(); expect(n).toBe(1); }); - test("observe cycles", () => { - const a = { b: {} }; - a.b = a; + test("observe cycles", async () => { + const a = { a: {} }; + a.a = a; let n = 0; const atom1 = createAtom(a, () => n++); atom1.k = 2; + await nextMicroTick(); expect(n).toBe(1); delete atom1.l; + await nextMicroTick(); expect(n).toBe(1); delete atom1.k; + await nextMicroTick(); expect(n).toBe(2); - atom1.b = 1; + atom1.a = 1; + await nextMicroTick(); expect(n).toBe(2); - atom1.b = atom1.b + 5; + atom1.a = atom1.a + 5; + await nextMicroTick(); expect(n).toBe(3); }); - test("two observers for same source", () => { + test("two observers for same source", async () => { let m = 0; let n = 0; const obj = { a: 1 } as any; @@ -105,60 +138,73 @@ describe("getAtom", () => { const atom2 = createAtom(obj, () => n++); obj.new = 2; + await nextMicroTick(); expect(m).toBe(0); expect(n).toBe(0); atom1.new = 2; // already exists! + await nextMicroTick(); expect(m).toBe(0); expect(n).toBe(0); atom1.veryNew = 2; + await nextMicroTick(); expect(m).toBe(1); expect(n).toBe(1); atom1.a = atom1.a + 5; + await nextMicroTick(); expect(m).toBe(2); expect(n).toBe(1); atom2.a = atom2.a + 5; + await nextMicroTick(); expect(m).toBe(3); expect(n).toBe(2); delete atom2.veryNew; + await nextMicroTick(); expect(m).toBe(4); expect(n).toBe(3); }); - test("create atom from another atom", () => { + test("create atom from another atom", async () => { let n = 0; const atom1 = createAtom({ a: 1 }); const atom2 = createAtom(atom1, () => n++); atom2.a = atom2.a + 5; + await nextMicroTick(); expect(n).toBe(1); atom1.a = 2; + await nextMicroTick(); expect(n).toBe(2); }); - test("create atom from another atom 2", () => { + test("create atom from another atom 2", async () => { let n = 0; const atom1 = createAtom({ a: 1 }); const atom2 = createAtom(atom1, () => n++); atom1.a = atom2.a + 5; + await nextMicroTick(); expect(n).toBe(1); atom2.a = atom2.a + 5; + await nextMicroTick(); expect(n).toBe(2); }); - test("create atom from another atom 3", () => { + test("create atom from another atom 3", async () => { let n = 0; const atom1 = createAtom({ a: 1 }); const atom2 = createAtom(atom1, () => n++); atom1.a = atom1.a + 5; + await nextMicroTick(); expect(n).toBe(0); // atom2.a was not yet read atom2.a = atom2.a + 5; + await nextMicroTick(); expect(n).toBe(1); // atom2.a has been read and is now observed atom1.a = atom1.a + 5; + await nextMicroTick(); expect(n).toBe(2); }); @@ -175,15 +221,16 @@ describe("getAtom", () => { expect(createAtom(date)).toBe(date); }); - test("can observe object with some key set to null", () => { + test("can observe object with some key set to null", async () => { let n = 0; const atom1 = createAtom({ a: { b: null } } as any, () => n++); expect(n).toBe(0); atom1.a.b = Boolean(atom1.a.b); + await nextMicroTick(); expect(n).toBe(1); }); - test("can reobserve object with some key set to null", () => { + test("can reobserve object with some key set to null", async () => { let n = 0; const fn = () => n++; const unregisterObserver = registerObserver(fn); @@ -191,11 +238,14 @@ describe("getAtom", () => { const atom2 = createAtom(atom1, fn); expect(atom2).toBe(atom1); expect(atom2).toEqual(atom1); + await nextMicroTick(); expect(n).toBe(0); atom1.a.b = Boolean(atom1.a.b); + await nextMicroTick(); expect(n).toBe(1); unregisterObserver(); atom1.a.b = !atom1.a.b; + await nextMicroTick(); expect(n).toBe(1); }); @@ -206,31 +256,37 @@ describe("getAtom", () => { expect((atom1 as any).c).toBeUndefined(); }); - test("detect object value changes", () => { + test("detect object value changes", async () => { let n = 0; const atom1 = createAtom({ a: 1 }, () => n++) as any; - expect(n).toBe(0); + atom1.a = atom1.a + 5; + await nextMicroTick(); expect(n).toBe(1); atom1.b = atom1.b + 5; + await nextMicroTick(); expect(n).toBe(2); atom1.a = null; atom1.b = undefined; - expect(n).toBe(4); + await nextMicroTick(); + expect(n).toBe(3); expect(atom1).toEqual({ a: null, b: undefined }); }); - test("properly handle dates", () => { + test("properly handle dates", async () => { const date = new Date(); let n = 0; const atom1 = createAtom({ date }, () => n++); + await nextMicroTick(); expect(typeof atom1.date.getFullYear()).toBe("number"); expect(atom1.date).toBe(date); + atom1.date = new Date(); + await nextMicroTick(); expect(n).toBe(1); expect(atom1.date).not.toBe(date); }); @@ -250,7 +306,7 @@ describe("getAtom", () => { expect(n).toBe(0); }); - test("can observe value change in array in an object", () => { + test("can observe value change in array in an object", async () => { let n = 0; const atom1 = createAtom({ arr: [1, 2] }, () => n++) as any; @@ -258,13 +314,14 @@ describe("getAtom", () => { expect(n).toBe(0); atom1.arr[0] = atom1.arr[0] + "nope"; + await nextMicroTick(); expect(n).toBe(1); expect(atom1.arr[0]).toBe("1nope"); expect(atom1.arr).toEqual(["1nope", 2]); }); - test("can observe: changing array in object to another array", () => { + test("can observe: changing array in object to another array", async () => { let n = 0; const atom1 = createAtom({ arr: [1, 2] }, () => n++) as any; @@ -272,6 +329,7 @@ describe("getAtom", () => { expect(n).toBe(0); atom1.arr = [2, 1]; + await nextMicroTick(); expect(n).toBe(1); expect(atom1.arr[0]).toBe(2); @@ -285,23 +343,26 @@ describe("getAtom", () => { expect(a1).toBe(a2); }); - test("various object property changes", () => { + test("various object property changes", async () => { let n = 0; const atom1 = createAtom({ a: 1 }, () => n++) as any; expect(n).toBe(0); atom1.a = atom1.a + 2; + await nextMicroTick(); expect(n).toBe(1); // same value again atom1.a = 3; + await nextMicroTick(); expect(n).toBe(1); atom1.a = 4; + await nextMicroTick(); expect(n).toBe(2); }); - test("properly observe arrays", () => { + test("properly observe arrays", async () => { let n = 0; const atom1 = createAtom([], () => n++) as any; @@ -310,59 +371,68 @@ describe("getAtom", () => { expect(n).toBe(0); atom1.push(1); + await nextMicroTick(); expect(n).toBe(1); expect(atom1.length).toBe(1); expect(atom1).toEqual([1]); atom1.splice(1, 0, "hey"); + await nextMicroTick(); expect(n).toBe(2); expect(atom1).toEqual([1, "hey"]); expect(atom1.length).toBe(2); atom1.unshift("lindemans"); + await nextMicroTick(); //it generates 3 primitive operations - expect(n).toBe(5); + expect(n).toBe(3); expect(atom1).toEqual(["lindemans", 1, "hey"]); expect(atom1.length).toBe(3); atom1.reverse(); + await nextMicroTick(); //it generates 2 primitive operations - expect(n).toBe(7); + expect(n).toBe(4); expect(atom1).toEqual(["hey", 1, "lindemans"]); expect(atom1.length).toBe(3); atom1.pop(); // one set, one delete - expect(n).toBe(9); + await nextMicroTick(); + expect(n).toBe(5); expect(atom1).toEqual(["hey", 1]); expect(atom1.length).toBe(2); atom1.shift(); // 2 sets, 1 delete - expect(n).toBe(12); + await nextMicroTick(); + expect(n).toBe(6); expect(atom1).toEqual([1]); expect(atom1.length).toBe(1); }); - test("object pushed into arrays are observed", () => { + test("object pushed into arrays are observed", async () => { let n = 0; const arr: any = createAtom([], () => n++); arr.push({ kriek: 5 }); + await nextMicroTick(); expect(n).toBe(1); arr[0].kriek = 6; + await nextMicroTick(); expect(n).toBe(1); arr[0].kriek = arr[0].kriek + 6; + await nextMicroTick(); expect(n).toBe(2); }); test("set new property on observed object", async () => { let n = 0; const atom1 = createAtom({}, () => n++) as any; - expect(n).toBe(0); - atom1.b = 8; + atom1.b = 8; + await nextMicroTick(); expect(n).toBe(1); expect(atom1.b).toBe(8); }); @@ -373,6 +443,7 @@ describe("getAtom", () => { expect(n).toBe(0); delete atom1.b; + await nextMicroTick(); expect(n).toBe(1); expect(atom1).toEqual({ a: 1 }); }); @@ -386,10 +457,12 @@ describe("getAtom", () => { expect(n).toBe(0); delete atom2.a; + await nextMicroTick(); // key "a" is no longer observed expect(n).toBe(1); atom1.new = 2; // but { b: 1 } is still observed! + await nextMicroTick(); expect(n).toBe(2); }); @@ -398,11 +471,12 @@ describe("getAtom", () => { const arr = createAtom(["a"], () => n++); arr[1] = "b"; + await nextMicroTick(); expect(n).toBe(1); expect(arr).toEqual(["a", "b"]); }); - test("properly observe arrays in object", () => { + test("properly observe arrays in object", async () => { let n = 0; const atom1 = createAtom({ arr: [] }, () => n++) as any; @@ -410,61 +484,73 @@ describe("getAtom", () => { expect(n).toBe(0); atom1.arr.push(1); + await nextMicroTick(); expect(n).toBe(1); expect(atom1.arr.length).toBe(1); }); - test("properly observe objects in array", () => { + test("properly observe objects in array", async () => { let n = 0; const atom1 = createAtom({ arr: [{ something: 1 }] }, () => n++) as any; expect(n).toBe(0); atom1.arr[0].something = atom1.arr[0].something + 1; + await nextMicroTick(); expect(n).toBe(1); expect(atom1.arr[0].something).toBe(2); }); - test("properly observe objects in object", () => { + test("properly observe objects in object", async () => { let n = 0; const atom1 = createAtom({ a: { b: 1 } }, () => n++) as any; expect(n).toBe(0); atom1.a.b = atom1.a.b + 2; + await nextMicroTick(); expect(n).toBe(1); }); - test("reobserve new object values", () => { + test("reobserve new object values", async () => { let n = 0; const atom1 = createAtom({ a: 1 }, () => n++) as any; expect(n).toBe(0); atom1.a++; + await nextMicroTick(); expect(n).toBe(1); + atom1.a = { b: 2 }; + await nextMicroTick(); expect(n).toBe(2); atom1.a.b = atom1.a.b + 3; + await nextMicroTick(); expect(n).toBe(3); }); - test("deep observe misc changes", () => { + test("deep observe misc changes", async () => { let n = 0; const atom1 = createAtom({ o: { a: 1 }, arr: [1], n: 13 }, () => n++) as any; expect(n).toBe(0); atom1.o.a = atom1.o.a + 2; + await nextMicroTick(); expect(n).toBe(1); atom1.arr.push(2); + await nextMicroTick(); expect(n).toBe(2); atom1.n = 155; + await nextMicroTick(); expect(n).toBe(2); + atom1.n = atom1.n + 1; + await nextMicroTick(); expect(n).toBe(3); }); - test("properly handle already observed atom", () => { + test("properly handle already observed atom", async () => { let n1 = 0; let n2 = 0; const obj1 = createAtom({ a: 1 }, () => n1++) as any; @@ -472,44 +558,55 @@ describe("getAtom", () => { obj1.a = obj1.a + 2; obj2.b = obj2.b + 3; + await nextMicroTick(); expect(n1).toBe(1); expect(n2).toBe(1); obj2.b = obj1; + await nextMicroTick(); expect(n1).toBe(1); expect(n2).toBe(2); obj1.a = 33; + await nextMicroTick(); expect(n1).toBe(2); expect(n2).toBe(2); + obj2.b.a = obj2.b.a + 2; + await nextMicroTick(); expect(n1).toBe(3); expect(n2).toBe(3); }); - test("properly handle already observed atom in observed atom", () => { + test("properly handle already observed atom in observed atom", async () => { let n1 = 0; let n2 = 0; const obj1 = createAtom({ a: { c: 2 } }, () => n1++) as any; const obj2 = createAtom({ b: 1 }, () => n2++) as any; obj2.c = obj1; + await nextMicroTick(); expect(n1).toBe(0); expect(n2).toBe(1); obj1.a.c = obj1.a.c + 33; + await nextMicroTick(); expect(n1).toBe(1); expect(n2).toBe(1); + obj2.c.a.c = obj2.c.a.c + 3; + await nextMicroTick(); expect(n1).toBe(2); expect(n2).toBe(2); }); - test("can reobserve object", () => { + test("can reobserve object", async () => { let n1 = 0; let n2 = 0; const atom1 = createAtom({ a: 0 }, () => n1++) as any; + atom1.a = atom1.a + 1; + await nextMicroTick(); expect(n1).toBe(1); expect(n2).toBe(0); @@ -517,11 +614,12 @@ describe("getAtom", () => { expect(atom1).toEqual(atom2); atom2.a = 2; + await nextMicroTick(); expect(n1).toBe(2); expect(n2).toBe(1); }); - test("can reobserve nested properties in object", () => { + test("can reobserve nested properties in object", async () => { let n1 = 0; let n2 = 0; const atom1 = createAtom({ a: [{ b: 1 }] }, () => n1++) as any; @@ -529,9 +627,12 @@ describe("getAtom", () => { const atom2 = createAtom(atom1, () => n2++) as any; atom1.a[0].b = atom1.a[0].b + 2; + await nextMicroTick(); expect(n1).toBe(1); expect(n2).toBe(0); + atom2.c = 2; + await nextMicroTick(); expect(n1).toBe(2); expect(n2).toBe(1); }); @@ -543,7 +644,7 @@ describe("getAtom", () => { expect(obj1).toBe(obj2); }); - test("can reobserve new properties in object", () => { + test("can reobserve new properties in object", async () => { let n1 = 0; let n2 = 0; const atom1 = createAtom({ a: [{ b: 1 }] }, () => n1++) as any; @@ -551,15 +652,17 @@ describe("getAtom", () => { createAtom(atom1, () => n2++) as any; atom1.a[0].b = { c: 1 }; + await nextMicroTick(); expect(n1).toBe(0); expect(n2).toBe(0); atom1.a[0].b.c = atom1.a[0].b.c + 2; + await nextMicroTick(); expect(n1).toBe(1); expect(n2).toBe(0); }); - test("can observe sub property of observed object", () => { + test("can observe sub property of observed object", async () => { let n1 = 0; let n2 = 0; const atom1 = createAtom({ a: { b: 1 }, c: 1 }, () => n1++) as any; @@ -567,39 +670,48 @@ describe("getAtom", () => { const atom2 = createAtom(atom1.a, () => n2++) as any; atom1.a.b = atom1.a.b + 2; + await nextMicroTick(); expect(n1).toBe(1); expect(n2).toBe(0); atom1.l = 2; + await nextMicroTick(); expect(n1).toBe(2); expect(n2).toBe(0); atom1.a.k = 3; + await nextMicroTick(); expect(n1).toBe(3); expect(n2).toBe(1); atom1.c = 14; + await nextMicroTick(); expect(n1).toBe(3); expect(n2).toBe(1); + atom2.b = atom2.b + 3; + await nextMicroTick(); expect(n1).toBe(4); expect(n2).toBe(2); }); - test("can set a property more than once", () => { + test("can set a property more than once", async () => { let n = 0; const atom1 = createAtom({}, () => n++) as any; atom1.aky = atom1.aku; - + expect(n).toBe(0); atom1.aku = "always finds annoying problems"; - expect(n).toBe(2); + expect(n).toBe(0); + await nextMicroTick(); + expect(n).toBe(1); atom1.aku = "always finds good problems"; - expect(n).toBe(3); + await nextMicroTick(); + expect(n).toBe(2); }); - test("properly handle swapping elements", () => { + test("properly handle swapping elements", async () => { let n = 0; const atom1 = createAtom({ a: { arr: [] }, b: 1 }, () => n++) as any; @@ -607,36 +719,42 @@ describe("getAtom", () => { const b = atom1.b; atom1.b = atom1.a; atom1.a = b; - expect(n).toBe(2); + await nextMicroTick(); + expect(n).toBe(1); // push something into array to make sure it works atom1.b.arr.push("blanche"); - expect(n).toBe(3); + await nextMicroTick(); + expect(n).toBe(2); }); - test("properly handle assigning observed atom containing array", () => { + test("properly handle assigning observed atom containing array", async () => { let n = 0; const atom1 = createAtom({ a: { arr: [], val: "test" } }, () => n++) as any; - expect(n).toBe(0); + atom1.a = { ...atom1.a, val: "test2" }; + await nextMicroTick(); expect(n).toBe(1); // push something into array to make sure it works atom1.a.arr.push("blanche"); + await nextMicroTick(); expect(n).toBe(2); }); - test("accept cycles in observed atom", () => { + test("accept cycles in observed atom", async () => { let n = 0; let obj1: any = {}; let obj2: any = { b: obj1, key: 1 }; obj1.a = obj2; obj1 = createAtom(obj1, () => n++) as any; obj2 = obj1.a; + await nextMicroTick(); expect(n).toBe(0); obj1.key = 3; + await nextMicroTick(); expect(n).toBe(1); }); @@ -644,18 +762,26 @@ describe("getAtom", () => { let n = 0; const atom1: any = createAtom({ a: 1, b: { c: 2 }, d: [{ e: 3 }], f: 4 }, () => n++); expect(n).toBe(0); + atom1.a = atom1.a + 2; + await nextMicroTick(); expect(n).toBe(1); + atom1.b.c = atom1.b.c + 3; + await nextMicroTick(); expect(n).toBe(2); + atom1.d[0].e = atom1.d[0].e + 5; + await nextMicroTick(); expect(n).toBe(3); + atom1.a = 111; atom1.f = 222; + await nextMicroTick(); expect(n).toBe(4); }); - test("can unobserve a value", () => { + test("can unobserve a value", async () => { let n = 0; const cb = () => n++; const unregisterObserver = registerObserver(cb); @@ -663,43 +789,50 @@ describe("getAtom", () => { const atom1 = createAtom({ a: 1 }, cb); atom1.a = atom1.a + 3; + await nextMicroTick(); expect(n).toBe(1); unregisterObserver(); atom1.a = 4; + await nextMicroTick(); expect(n).toBe(1); }); - test("observing some observed atom", () => { + test("observing some observed atom", async () => { let n1 = 0; let n2 = 0; const inner = createAtom({ a: 1 }, () => n1++); const outer = createAtom({ b: inner }, () => n2++); expect(n1).toBe(0); expect(n2).toBe(0); + outer.b.a = outer.b.a + 2; + await nextMicroTick(); expect(n1).toBe(0); expect(n2).toBe(1); }); - test("observing some observed atom, variant", () => { + test("observing some observed atom, variant", async () => { let n1 = 0; let n2 = 0; const inner = createAtom({ a: 1 }, () => n1++); const outer = createAtom({ b: inner, c: 0 }, () => n2++); expect(n1).toBe(0); expect(n2).toBe(0); + inner.a = inner.a + 2; + await nextMicroTick(); expect(n1).toBe(1); expect(n2).toBe(0); outer.c = outer.c + 3; + await nextMicroTick(); expect(n1).toBe(1); expect(n2).toBe(1); }); - test("observing some observed atom, variant 2", () => { + test("observing some observed atom, variant 2", async () => { let n1 = 0; let n2 = 0; let n3 = 0; @@ -709,39 +842,559 @@ describe("getAtom", () => { obj2.b = obj2.b; obj3.c = obj3.c; + await nextMicroTick(); expect(n1).toBe(0); expect(n2).toBe(1); expect(n3).toBe(1); obj2.b = obj1; obj3.c = obj1; - + await nextMicroTick(); expect(n1).toBe(0); expect(n2).toBe(2); expect(n3).toBe(2); obj1.a = obj1.a + 2; + await nextMicroTick(); expect(n1).toBe(1); expect(n2).toBe(2); expect(n3).toBe(2); obj2.b.a = obj2.b.a + 1; + await nextMicroTick(); expect(n1).toBe(2); expect(n2).toBe(3); expect(n3).toBe(2); }); - test("notification is not done after unregistration", () => { + test("notification is not done after unregistration", async () => { let n = 0; const observer = () => n++; const unregisterObserver = registerObserver(observer); const state = atom({ a: 1 }, observer); + state.a = state.a; + await nextMicroTick(); expect(n).toBe(0); + unregisterObserver(); + state.a = { b: 2 }; + await nextMicroTick(); expect(n).toBe(0); + state.a.b = state.a.b + 3; + await nextMicroTick(); expect(n).toBe(0); }); }); + +describe("Reactivity: useState", () => { + let fixture: HTMLElement; + + snapshotEverything(); + + beforeEach(() => { + fixture = makeTestFixture(); + }); + + /** + * A context can be defined as an atom with a default observer. + * It can be exposed and share by multiple components or other objects + * (via useState for instance) + */ + + test("very simple use, with initial value", async () => { + const testContext = createAtom({ value: 123 }); + + class Comp extends Component { + static template = xml`
`; + contextObj = useState(testContext); + } + await mount(Comp, fixture); + expect(fixture.innerHTML).toBe("
123
"); + }); + + test("useContext=useState hook is reactive, for one component", async () => { + const testContext = createAtom({ value: 123 }); + + class Comp extends Component { + static template = xml`
`; + contextObj = useState(testContext); + } + const comp = await mount(Comp, fixture); + expect(fixture.innerHTML).toBe("
123
"); + (comp as any).contextObj.value = 321; + await nextTick(); + expect(fixture.innerHTML).toBe("
321
"); + }); + + test("two components can subscribe to same context", async () => { + const testContext = createAtom({ value: 123 }); + const steps: string[] = []; + + class Child extends Component { + static template = xml``; + contextObj = useState(testContext); + setup() { + onRender(() => { + steps.push("child"); + }); + } + } + class Parent extends Component { + static template = xml`
`; + static components = { Child }; + setup() { + onRender(() => { + steps.push("parent"); + }); + } + } + await mount(Parent, fixture); + expect(steps).toEqual(["parent", "child", "child"]); + expect(fixture.innerHTML).toBe("
123123
"); + testContext.value = 321; + await nextTick(); + expect(steps).toEqual(["parent", "child", "child", "child", "child"]); + expect(fixture.innerHTML).toBe("
321321
"); + }); + + test("two components are updated in parallel", async () => { + const testContext = createAtom({ value: 123 }); + const steps: string[] = []; + + class Child extends Component { + static template = xml``; + contextObj = useState(testContext); + setup() { + onRender(async () => { + steps.push("render"); + }); + } + } + + class Parent extends Component { + static template = xml`
`; + static components = { Child }; + } + await mount(Parent, fixture); + expect(steps).toEqual(["render", "render"]); + expect(fixture.innerHTML).toBe("
123123
"); + testContext.value = 321; + await nextMicroTick(); + await nextMicroTick(); + expect(steps).toEqual(["render", "render", "render", "render"]); + expect(fixture.innerHTML).toBe("
123123
"); + await nextTick(); + expect(fixture.innerHTML).toBe("
321321
"); + }); + + test("two independent components on different levels are updated in parallel", async () => { + const testContext = createAtom({ value: 123 }); + const steps: string[] = []; + + class Child extends Component { + static template = xml``; + static components = {}; + contextObj = useState(testContext); + setup() { + onRender(() => { + steps.push("render"); + }); + } + } + + class Parent extends Component { + static template = xml`
`; + static components = { Child }; + } + + class GrandFather extends Component { + static template = xml`
`; + static components = { Child, Parent }; + } + + await mount(GrandFather, fixture); + + expect(fixture.innerHTML).toBe("
123
123
"); + expect(steps).toEqual(["render", "render"]); + + testContext.value = 321; + + await nextMicroTick(); + await nextMicroTick(); + expect(steps).toEqual(["render", "render", "render", "render"]); + expect(fixture.innerHTML).toBe("
123
123
"); + + await nextTick(); + expect(fixture.innerHTML).toBe("
321
321
"); + }); + + test("one components can subscribe twice to same context", async () => { + const testContext = createAtom({ a: 1, b: 2 }); + const steps: string[] = []; + + class Comp extends Component { + static template = xml`
`; + contextObj1 = useState(testContext); + contextObj2 = useState(testContext); + setup() { + onRender(() => { + steps.push("comp"); + }); + } + } + await mount(Comp, fixture); + expect(fixture.innerHTML).toBe("
12
"); + expect(steps).toEqual(["comp"]); + testContext.a = 3; + await nextTick(); + expect(fixture.innerHTML).toBe("
32
"); + expect(steps).toEqual(["comp", "comp"]); + }); + + test("parent and children subscribed to same context", async () => { + const testContext = createAtom({ a: 123, b: 321 }); + const steps: string[] = []; + + class Child extends Component { + static template = xml``; + contextObj = useState(testContext); + setup() { + onRender(() => { + steps.push("child"); + }); + } + } + class Parent extends Component { + static template = xml`
`; + static components = { Child }; + contextObj = useState(testContext); + setup() { + onRender(() => { + steps.push("parent"); + }); + } + } + const parent = await mount(Parent, fixture); + expect(fixture.innerHTML).toBe("
123321
"); + expect(steps).toEqual(["parent", "child"]); + + (parent as any).contextObj.a = 124; + await nextTick(); + expect(fixture.innerHTML).toBe("
124321
"); + + expect(steps).toEqual(["parent", "child", "child"]); + }); + + test("several nodes on different level use same context", async () => { + const testContext = createAtom({ a: 123, b: 456 }); + const steps: Set = new Set(); + + /** + * Scheme: + * L1A + * / \ + * L2A L2B + * | + * L3A + */ + + class L3A extends Component { + static template = xml`
`; + contextObj = useState(testContext); + setup() { + onRender(() => { + steps.add("L3A"); + }); + } + } + + class L2B extends Component { + static template = xml`
`; + contextObj = useState(testContext); + setup() { + onRender(() => { + steps.add("L2B"); + }); + } + } + + class L2A extends Component { + static template = xml`
`; + static components = { L3A }; + contextObj = useState(testContext); + setup() { + onRender(() => { + steps.add("L2A"); + }); + } + } + + class L1A extends Component { + static template = xml`
`; + static components = { L2A, L2B }; + contextObj = useState(testContext); + setup() { + onRender(() => { + steps.add("L1A"); + }); + } + } + + await mount(L1A, fixture); + expect(fixture.innerHTML).toBe("
123
123 456
456
"); + expect([...steps]).toEqual(["L1A", "L2A", "L2B", "L3A"]); + steps.clear(); + + testContext.a = 321; + await nextMicroTick(); + await nextMicroTick(); + expect([...steps]).toEqual(["L2A"]); + await nextTick(); + expect([...steps]).toEqual(["L2A", "L3A"]); + expect(fixture.innerHTML).toBe("
321
321 456
456
"); + steps.clear(); + + testContext.b = 654; + await nextMicroTick(); + await nextMicroTick(); + expect([...steps]).toEqual(["L2B", "L3A"]); + await nextTick(); + expect([...steps]).toEqual(["L2B", "L3A"]); + expect(fixture.innerHTML).toBe("
321
321 654
654
"); + steps.clear(); + + testContext.a = 777; + testContext.b = 777; + await nextMicroTick(); + await nextMicroTick(); + expect([...steps]).toEqual(["L2A", "L2B"]); + await nextTick(); + expect([...steps]).toEqual(["L2A", "L2B", "L3A"]); + expect(fixture.innerHTML).toBe("
777
777 777
777
"); + steps.clear(); + + testContext.c = 444; + await nextMicroTick(); + await nextMicroTick(); + expect([...steps]).toEqual(["L1A"]); + await nextTick(); + expect([...steps]).toEqual(["L1A", "L2A", "L2B", "L3A"]); + }); + + test("destroyed component is inactive", async () => { + const testContext = createAtom({ a: 123 }); + const steps: string[] = []; + + class Child extends Component { + static template = xml``; + contextObj = useState(testContext); + setup() { + onRender(() => { + steps.push("child"); + }); + } + } + class Parent extends Component { + static template = xml`
`; + static components = { Child }; + state = useState({ flag: true }); + setup() { + onRender(() => { + steps.push("parent"); + }); + } + } + const parent = await mount(Parent, fixture); + expect(fixture.innerHTML).toBe("
123
"); + expect(steps).toEqual(["parent", "child"]); + + testContext.a = 321; + await nextTick(); + expect(steps).toEqual(["parent", "child", "child"]); + + parent.state.flag = false; + await nextTick(); + expect(fixture.innerHTML).toBe("
"); + expect(steps).toEqual(["parent", "child", "child", "parent"]); + + testContext.a = 456; + await nextTick(); + expect(steps).toEqual(["parent", "child", "child", "parent"]); + }); + + test("destroyed component before being mounted is inactive", async () => { + const testContext = createAtom({ a: 123 }); + const steps: string[] = []; + class Child extends Component { + static template = xml``; + contextObj = useState(testContext); + setup() { + onWillStart(() => { + return makeDeferred(); + }); + onRender(() => { + steps.push("child"); + }); + } + } + let parent: any; + class Parent extends Component { + static template = xml`
`; + static components = { Child }; + state = useState({ flag: true }); + setup() { + parent = this; + } + } + + const prom = mount(Parent, fixture); // cannot work + await nextTick(); // wait for Child to be instantiated + testContext.a = 321; + await nextMicroTick(); + parent.state.flag = false; + await prom; + expect(fixture.innerHTML).toBe("
"); + testContext.a = 456; + await nextMicroTick(); + expect(steps).toEqual([]); + }); + + test("useless atoms should be deleted", async () => { + const testContext = createAtom({ + 1: { id: 1, quantity: 3, description: "First quantity" }, + 2: { id: 2, quantity: 5, description: "Second quantity" }, + }); + + const secondQuantity = testContext[2]; + + const steps: Set = new Set(); + + class Quantity extends Component { + static template = xml`
`; + state = useState(testContext[this.props.id]); + + setup() { + onRender(() => { + steps.add(`quantity${this.props.id}`); + }); + } + } + + class ListOfQuantities extends Component { + static template = xml` +
+ + + + Total: + Count: +
`; + static components = { Quantity }; + state = useState(testContext); + + setup() { + onRender(() => { + steps.add("list"); + }); + } + + get total() { + let total = 0; + for (const { quantity } of Object.values(this.state) as any) { + total += quantity; + } + return total; + } + } + + await mount(ListOfQuantities, fixture); + expect(fixture.innerHTML).toBe("
3
5
Total: 8 Count: 2
"); + expect([...steps]).toEqual(["list", "quantity1", "quantity2"]); + steps.clear(); + + delete testContext[2]; + await nextMicroTick(); + await nextMicroTick(); + expect([...steps]).toEqual(["list"]); + await nextTick(); + expect(fixture.innerHTML).toBe("
3
Total: 3 Count: 1
"); + expect([...steps]).toEqual(["list", "quantity1"]); + steps.clear(); + + // secondQuantity is no longer accessible by List! But list react to changes + // --> this prooff that a useless atom continues to exist + secondQuantity.quantity = 2; + await nextMicroTick(); + await nextMicroTick(); + + expect(fixture.innerHTML).toBe("
3
Total: 3 Count: 1
"); + expect([...steps]).toEqual(["list"]); // should be avoided!!! + steps.clear(); + }); + + test.skip("concurrent renderings", async () => { + /** + * Note: this test is interesting, but sadly just an incomplete attempt at + * protecting users against themselves. With the context API, it is not + * possible for the framework to protect completely against crashes. Maybe + * like in this case, when a component is in a simple hierarchy where all + * renderings come from the context changes, but in a real case, where some + * code can trigger a rendering independently, it is insufficient. + * + * The main problem is that the sub component depends on some external state, + * which may be modified, and then incompatible with the component actual + * state (for example, if the sub component has an id key related to some + * object that has been removed from the context). + * + * For now, sadly, the only solution is that components that depends on external + * state should guarantee their own integrity themselves. Then maybe this + * could be solved at the level of a state management solution that has a + * more advanced API, to let components determine if they should be updated + * or not (so, something slightly more advanced that the useStore hook). + */ + const testContext = createAtom({ x: { n: 1 }, key: "x" }); + const def = makeDeferred(); + let stateC: any; + class ComponentC extends Component { + static template = xml``; + context = useState(testContext); + state = useState({ x: "a" }); + setup() { + stateC = this.state; + } + } + class ComponentB extends Component { + static components = { ComponentC }; + static template = xml`

`; + setup() { + onWillUpdateProps(() => def); + } + } + class ComponentA extends Component { + static components = { ComponentB }; + static template = xml`
`; + context = useState(testContext); + } + + await mount(ComponentA, fixture); + + expect(fixture.innerHTML).toBe("

1a

"); + testContext.key = "y"; + testContext.y = { n: 2 }; + delete testContext.x; + await nextTick(); + + expect(fixture.innerHTML).toBe("

1a

"); + stateC.x = "b"; + await nextTick(); + + expect(fixture.innerHTML).toBe("

1a

"); + def.resolve(); + await nextTick(); + + expect(fixture.innerHTML).toBe("

2b

"); + }); +});