From 760c690afdfaf0153f3e2f774d6f24597bd1cabe Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 11 Aug 2019 21:44:01 -0700 Subject: [PATCH] Preparations for v0.10.0 release --- documentation/state.md | 13 +++ package-lock.json | 20 ++-- package.json | 6 +- src/dom/index.ts | 79 ++++++------- src/index.ts | 2 +- src/signal.ts | 258 ++++++++++++++++++++++++++++++----------- src/state.ts | 42 +++++-- test/dom/show.spec.jsx | 2 +- test/state.spec.js | 54 ++++++++- 9 files changed, 339 insertions(+), 137 deletions(-) diff --git a/documentation/state.md b/documentation/state.md index c910c8459..5e218b7ed 100644 --- a/documentation/state.md +++ b/documentation/state.md @@ -119,6 +119,19 @@ setState( // } ``` +## Modifiers +This library also provides of state setter modifiers which can optionally be included to provide different behavior when setting state. + +### `force(changes)` +### `force(...path, changes)` +### `force([...path, changes], [...path, changes])` + +By default state only updates on value change. To get typical signal like behavior on a change you can force update using the force modifier. + +```js +setState(force({name: 'John'})); +``` + ### `reconcile(...path, value)` This can be used to do deep diffs by applying the changes from a new State value. This is useful when pulling in immutable data trees from stores to ensure the least amount of mutations to your state. It can also be used to replace the all keys on the base state object if no path is provided as it does both positive and negative diff. diff --git a/package-lock.json b/package-lock.json index 6a6d4b7b8..97bd895a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "solid-js", - "version": "0.9.8", + "version": "0.10.0-beta.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4064,9 +4064,9 @@ } }, "lit-dom-expressions": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/lit-dom-expressions/-/lit-dom-expressions-0.11.2.tgz", - "integrity": "sha512-VTWOUuioP+0n/MYO1+qbkOAjsHhZb+4oXANJrhaAXHsKaA3KQHHxDbkeEBPZh9DIBYD5yw9EduVI44N9M6IqRw==", + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/lit-dom-expressions/-/lit-dom-expressions-0.11.3.tgz", + "integrity": "sha512-wTtFMI85wxcTDYK1wJJi+5e5NW3k4tzMW96s86XrYRPijADHCsHPL6QiweAhoS2lRUcSSxRiNvxBvbFfMWUang==", "dev": true }, "load-json-file": { @@ -5062,9 +5062,9 @@ } }, "rollup": { - "version": "1.19.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.19.3.tgz", - "integrity": "sha512-+6VtYadkQEp6OTSa6ms1eAE/CYW+kD9rCd3fq4E2T3VaVqwTcY4vq0zBcB4nhQANnId+SwSpgCn4RFfOUAsWjQ==", + "version": "1.19.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.19.4.tgz", + "integrity": "sha512-G24w409GNj7i/Yam2cQla6qV2k6Nug8bD2DZg9v63QX/cH/dEdbNJg8H4lUm5M1bRpPKRUC465Rm9H51JTKOfQ==", "dev": true, "requires": { "@types/estree": "0.0.39", @@ -5073,9 +5073,9 @@ }, "dependencies": { "@types/node": { - "version": "12.6.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.6.9.tgz", - "integrity": "sha512-+YB9FtyxXGyD54p8rXwWaN1EWEyar5L58GlGWgtH2I9rGmLGBQcw63+0jw+ujqVavNuO47S1ByAjm9zdHMnskw==", + "version": "12.7.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.1.tgz", + "integrity": "sha512-aK9jxMypeSrhiYofWWBf/T7O+KwaiAHzM4sveCdWPn71lzUSMimRnKzhXDKfKwV1kWoBo2P1aGgaIYGLf9/ljw==", "dev": true }, "acorn": { diff --git a/package.json b/package.json index 68610fe8a..abaae0f24 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "solid-js", "description": "A declarative JavaScript library for building user interfaces.", - "version": "0.9.8", + "version": "0.10.0-beta.0", "author": "Ryan Carniato", "license": "MIT", "repository": { @@ -31,10 +31,10 @@ "dom-expressions": "0.11.1", "hyper-dom-expressions": "~0.11.1", "jest": "~24.8.0", - "lit-dom-expressions": "~0.11.2", + "lit-dom-expressions": "~0.11.3", "npm-run-all": "^4.1.5", "rimraf": "^2.6.3", - "rollup": "^1.19.3", + "rollup": "^1.19.4", "rollup-plugin-babel": "^4.3.3", "rollup-plugin-node-resolve": "^5.2.0", "typescript": "^3.5.3" diff --git a/src/dom/index.ts b/src/dom/index.ts index 7e12e14a8..e2c30bb79 100644 --- a/src/dom/index.ts +++ b/src/dom/index.ts @@ -7,9 +7,12 @@ import { onCleanup, sample, map, + afterEffects, useContext } from "../index"; +const equalFn = (a: T, b: T) => a === b; + export function render(code: () => any, element: Node): () => void { let disposer: () => void; createRoot(dispose => { @@ -25,10 +28,12 @@ export function For(props: { transform?: (mapped: () => U[], source: () => T[]) => () => U[]; children: (item: T, index: number) => U; }) { - const mapped = createMemo(map( - props.children, - "fallback" in props ? () => props.fallback : undefined - )(() => props.each)); + const mapped = createMemo( + map( + props.children, + "fallback" in props ? () => props.fallback : undefined + )(() => props.each) + ); return props.transform ? props.transform(mapped, () => props.each) : mapped; } @@ -41,24 +46,12 @@ export function Show(props: { ) => () => T | undefined; children: T; }) { - let dispose: () => void, cached: T | undefined, prev: Boolean; - onCleanup(() => dispose && dispose()); const useFallback = "fallback" in props, - mapped = createMemo(() => { - const v = props.when; - if (v === prev) return cached; - prev = v; - dispose && dispose(); - return createRoot(disposer => { - dispose = disposer; - return (cached = v - ? props.children - : useFallback - ? props.fallback - : undefined); - }); - }); - return props.transform ? props.transform(mapped, () => props.when) : mapped; + condition = createMemo(() => props.when, undefined, equalFn), + mapped = createMemo(() => + condition() ? props.children : useFallback ? props.fallback : undefined + ); + return props.transform ? props.transform(mapped, condition) : mapped; } export function Switch(props: { @@ -66,31 +59,24 @@ export function Switch(props: { transform?: (mapped: () => T, source: () => number) => () => T; children: any; }) { - let conditions = props.children, - dispose: () => void, - cached: T | undefined, - prev: number; + let conditions = props.children; Array.isArray(conditions) || (conditions = [conditions]); - onCleanup(() => dispose && dispose()); const useFallback = "fallback" in props, - evalConditions = () => { - for (let i = 0; i < conditions.length; i++) { - if (conditions[i].when) return i; - } - return -1; - }, + evalConditions = createMemo( + () => { + for (let i = 0; i < conditions.length; i++) { + if (conditions[i].when) return i; + } + return -1; + }, + undefined, + equalFn + ), mapped = createMemo(() => { const index = evalConditions(); - if (index === prev) return cached; - prev = index; - dispose && dispose(); - return createRoot(disposer => { - dispose = disposer; - return (cached = - index < 0 - ? useFallback && props.fallback - : conditions[index].children); - }); + return index < 0 + ? useFallback && props.fallback + : conditions[index].children; }); return props.transform ? props.transform(mapped, evalConditions) : mapped; } @@ -110,6 +96,7 @@ export function Suspense(props: { { value: props.delayMs, children: () => { + let dispose: () => void; const c = useContext(SuspenseContext), rendered = sample(() => props.children), marker = document.createTextNode(""), @@ -124,8 +111,14 @@ export function Suspense(props: { return createMemo(() => { const value = c.suspended(); if (c.initializing) c.initializing = false; + dispose && dispose(); if (!value) return [marker, rendered]; - setTimeout(() => insert(doc.body, rendered)); + afterEffects(() => + createRoot(disposer => { + dispose = disposer; + insert(doc.body, rendered) + }) + ); return [marker, props.fallback]; }); } diff --git a/src/index.ts b/src/index.ts index bf26dc042..50e40fb4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ export { getContextOwner } from "./signal"; -export { createState, unwrap } from "./state"; +export { createState, unwrap, force } from "./state"; export { reconcile } from "./reconcile"; diff --git a/src/signal.ts b/src/signal.ts index ba2a9708f..fdabeec03 100644 --- a/src/signal.ts +++ b/src/signal.ts @@ -1,4 +1,5 @@ // Modified version of S.js[https://github.com/adamhaile/S] by Adam Haile +// Comparator memos from VSJolund fork https://github.com/VSjolund/vs-bind // Public interface export function createSignal( @@ -52,20 +53,33 @@ export function createDependentEffect( }); } -export function createMemo(fn: (v: T | undefined) => T, value?: T): () => T { - var { node, value: _value } = makeComputationNode(fn, value, false); +export function createMemo( + fn: (v: T | undefined) => T, + value?: T, + comparator?: (a: T, b: T) => boolean +): () => T { + var { node, value: _value } = makeComputationNode( + fn, + value, + false, + comparator + ); return node === null ? () => _value : () => { if (Listener !== null) { - if (node!.age === RootClock.time) { - if (node!.state === RUNNING) throw new Error("circular dependency"); - else updateNode(node!); // checks for state === STALE internally, so don't need to check here + const state = node!.state; + if ((state & 7) !== 0) { + liftComputation(node!); + } + if (node!.age === RootClock.time && state === 8) { + throw new Error("Circular dependency."); + } + if ((state & 16) === 0) { + if (node!.log === null) node!.log = createLog(); + logRead(node!.log); } - if (node!.log === null) node!.log = createLog(); - logRead(node!.log); } - return node!.value; }; } @@ -228,13 +242,17 @@ export class DataNode { type ComputationNode = { fn: ((v: any) => any) | null; value: any; + comparator?: (a: any, b: any) => boolean; age: number; state: number; source1: null | Log; source1slot: number; sources: null | Log[]; sourceslots: null | number[]; - owner: any; + dependents: null | (ComputationNode | null)[]; + dependentslot: number; + dependentcount: number; + owner: ComputationNode | null; log: Log | null; context: any; noRecycle?: boolean; @@ -245,15 +263,19 @@ function createComputationNode(): ComputationNode { return { fn: null, age: -1, - state: CURRENT, + state: 0, source1: null, source1slot: 0, sources: null, sourceslots: null, + dependents: null, + dependentslot: 0, + dependentcount: 0, owner: null, owned: null, log: null, value: undefined, + comparator: undefined, context: undefined, cleanups: null }; @@ -342,13 +364,11 @@ let RootClock = createClock(), RunningClock = null as any, // currently running clock Listener = null as ComputationNode | null, // currently listening computation Owner = null as ComputationNode | null, // owner for new computations + Pending = null as ComputationNode | null, // pending node LastNode = null as ComputationNode | null; // cached unused node, for re-use // Constants let NOTPENDING = {}, - CURRENT = 0, - STALE = 1, - RUNNING = 2, UNOWNED = createComputationNode(); // Functions @@ -372,28 +392,23 @@ var makeComputationNodeResult = { function makeComputationNode( fn: (v: T | undefined) => T, value: T | undefined, - sample: boolean + sample: boolean, + comparator?: (a: T, b: T) => boolean ) { const node = getCandidateNode(), listener = Listener, toplevel = RunningClock === null; node.owner = Owner; + node.comparator = comparator; Owner = node; Listener = sample ? null : node; - - if (toplevel) { - value = execToplevelComputation(fn, value as T); - } else { - value = fn(value); - } + value = toplevel ? execToplevelComputation(fn, value as T) : fn(value); Owner = node.owner; Listener = listener; var recycled = recycleOrClaimNode(node, fn, value, false); - if (toplevel) finishToplevelComputation(Owner, listener); - makeComputationNodeResult.node = recycled ? null : node; makeComputationNodeResult.value = value!; @@ -416,8 +431,11 @@ function finishToplevelComputation( owner: ComputationNode | null, listener: ComputationNode | null ) { - if (RootClock.changes.count > 0 || RootClock.updates.count > 0) { - RootClock.time++; + if ( + RootClock.changes.count !== 0 || + RootClock.updates.count !== 0 || + RootClock.disposes.count !== 0 + ) { try { run(RootClock); } finally { @@ -451,7 +469,7 @@ function recycleOrClaimNode( if (recycle) { LastNode = node; node.owner = null; - + resetComputation(node, 31); if (_owner !== null) { if (node.owned !== null) { if (_owner.owned === null) _owner.owned = node.owned; @@ -519,11 +537,20 @@ function logRead(from: Log) { } } +function liftComputation(node: ComputationNode) { + if ((node.state & 6) !== 0) { + applyUpstreamUpdates(node); + } + if ((node.state & 1) !== 0) { + updateNode(node); + } + resetComputation(node, 31); +} + function event() { // b/c we might be under a top level S.root(), have to preserve current root let owner = Owner; RootClock.updates.reset(); - RootClock.time++; try { run(RootClock); } finally { @@ -535,9 +562,7 @@ function event() { function run(clock: Clock) { let running = RunningClock, count = 0; - RunningClock = clock; - clock.disposes.reset(); // for each batch ... @@ -546,10 +571,7 @@ function run(clock: Clock) { clock.updates.count !== 0 || clock.disposes.count !== 0 ) { - if (count > 0) - // don't tick on first run, or else we expire already scheduled updates - clock.time++; - + clock.time++; clock.changes.run(applyDataChange); clock.updates.run(updateNode); clock.disposes.run(dispose); @@ -566,56 +588,153 @@ function run(clock: Clock) { function applyDataChange(data: DataNode) { data.value = data.pending; data.pending = NOTPENDING; - if (data.log) markComputationsStale(data.log); + if (data.log) setComputationState(data.log, stateStale); } -function markComputationsStale(log: Log) { - let node1 = log.node1, - nodes = log.nodes; - - // mark all downstream nodes stale which haven't been already - if (node1 !== null) markNodeStale(node1); - if (nodes !== null) { - for (let i = 0, len = nodes.length; i < len; i++) { - markNodeStale(nodes[i]); +function updateNode(node: ComputationNode) { + const state = node.state; + if ((state & 16) === 0) { + if ((state & 2) !== 0) { + node.dependents![node.dependentslot++] = null; + if (node.dependentslot === node.dependentcount) { + resetComputation(node, 14); + } + } else if ((state & 1) !== 0) { + if (node.comparator) { + const current = updateComputation(node); + const comparator = node.comparator; + if (!comparator(current, node.value)) { + markDownstreamComputations(node, false, true); + } + } else { + updateComputation(node); + } } } } -function markNodeStale(node: ComputationNode) { - let time = RootClock.time; +function updateComputation(node: ComputationNode) { + const value = node.value, + owner = Owner, + listener = Listener; + Owner = Listener = node; + node.state = 8; + cleanupNode(node, false); + node.value = node.fn!(node.value); + resetComputation(node, 31); + Owner = owner; + Listener = listener; + return value; +} + +function stateStale(node: ComputationNode) { + const time = RootClock.time; if (node.age < time) { + node.state |= 1; node.age = time; - node.state = STALE; - RootClock.updates.add(node); - if (node.owned !== null) markOwnedNodesForDisposal(node.owned); - if (node.log !== null) markComputationsStale(node.log); + setDownstreamState(node, !!node.comparator); } } -function markOwnedNodesForDisposal(owned: ComputationNode[]) { - for (let i = 0; i < owned.length; i++) { - let child = owned[i]; - child.age = RootClock.time; - child.state = CURRENT; - if (child.owned !== null) markOwnedNodesForDisposal(child.owned); +function statePending(node: ComputationNode) { + const time = RootClock.time; + if (node.age < time) { + node.state |= 2; + let dependents = node.dependents || (node.dependents = []) + dependents[node.dependentcount++] = Pending; + setDownstreamState(node, true); } } -function updateNode(node: ComputationNode) { - if (node.state === STALE) { - let owner = Owner, - listener = Listener; +function setDownstreamState(node: ComputationNode, pending: boolean) { + RootClock.updates.add(node); + if (node.comparator) { + const pending = Pending; + Pending = node; + markDownstreamComputations(node, true, false); + Pending = pending; + } else { + markDownstreamComputations(node, pending, false); + } +} - Owner = Listener = node; +function pendingStateStale(node: ComputationNode) { + if ((node.state & 2) !== 0) { + node.state = 1; + const time = RootClock.time; + if (node.age < time) { + node.age = time; + if (!node.comparator) { + markDownstreamComputations(node, false, true); + } + } + } +} - node.state = RUNNING; - cleanupNode(node, false); - node.value = node.fn!(node.value); - node.state = CURRENT; +function markDownstreamComputations( + node: ComputationNode, + onchange: boolean, + dirty: boolean +) { + const owned = node.owned; + if (owned !== null) { + const pending = onchange && !dirty; + markForDisposal(owned, pending, RootClock.time); + } + const log = node.log; + if (log !== null) { + setComputationState( + log, + dirty ? pendingStateStale : onchange ? statePending : stateStale + ); + } +} - Owner = owner; - Listener = listener; +function setComputationState(log: Log, stateFn: (v: ComputationNode) => void) { + const node1 = log.node1, + nodes = log.nodes; + if (node1 !== null) stateFn(node1); + if (nodes !== null) { + for (let i = 0, ln = nodes.length; i < ln; i++) { + stateFn(nodes[i]); + } + } +} + +function markForDisposal( + children: ComputationNode[], + pending: boolean, + time: number +) { + for (let i = 0, ln = children.length; i < ln; i++) { + const child = children[i]; + if (child !== null) { + if (pending) { + if ((child.state & 16) === 0) { + child.state |= 4; + } + } else { + child.age = time; + child.state = 16; + } + const owned = child.owned; + if (owned !== null) markForDisposal(owned, pending, time); + } + } +} + +function applyUpstreamUpdates(node: ComputationNode) { + if ((node.state & 4) !== 0) { + const owner = node.owner; + if ((owner!.state & 7) !== 0) liftComputation(owner!); + node.state &= ~4; + } + if ((node.state & 2) !== 0) { + const slots = node.dependents; + for (let i = node.dependentslot, ln = node.dependentcount; i < ln; i++) { + liftComputation(slots![i]!); + slots![i] = null; + } } } @@ -675,9 +794,16 @@ function cleanupSource(source: Log, slot: number) { } } +function resetComputation(node: ComputationNode, flags: number) { + node.state &= ~flags; + node.dependentslot = 0; + node.dependentcount = 0; +} + function dispose(node: ComputationNode) { node.fn = null; - node.owner = null; node.log = null; + node.dependents = null; cleanupNode(node, true); + resetComputation(node, 31); } diff --git a/src/state.ts b/src/state.ts index 6ca3e738b..e4ff2c641 100644 --- a/src/state.ts +++ b/src/state.ts @@ -150,10 +150,11 @@ const proxyTraps = { export function setProperty( state: StateNode, property: string | number, - value: any + value: any, + force?: boolean ) { value = unwrap(value) as StateNode; - if (state[property] === value) return; + if (!force && state[property] === value) return; const notify = Array.isArray(state) || !(property in state); if (value === void 0) { delete state[property]; @@ -164,18 +165,19 @@ export function setProperty( notify && (node = nodes._) && node.next(); } -function mergeState(state: StateNode, value: { [k: string]: any }) { +function mergeState(state: StateNode, value: { [k: string]: any }, force?: boolean) { const keys = Object.keys(value); for (let i = 0; i < keys.length; i += 1) { const key = keys[i]; - setProperty(state, key, value[key]); + setProperty(state, key, value[key], force); } } function updatePath( current: StateNode, path: any[], - traversed: (number | string)[] = [] + traversed: (number | string)[] = [], + force?: boolean ) { if (path.length === 1) { let value = path[0]; @@ -184,7 +186,7 @@ function updatePath( // reconciled if (value === undefined) return; } - mergeState(current, value); + mergeState(current, value, force); return; } @@ -195,19 +197,19 @@ function updatePath( if (Array.isArray(part)) { // Ex. update('data', [2, 23], 'label', l => l + ' !!!'); for (let i = 0; i < part.length; i++) { - updatePath(current, [part[i]].concat(path), traversed.concat([part[i]])); + updatePath(current, [part[i]].concat(path), traversed.concat([part[i]]), force); } } else if (isArray && partType === "function") { // Ex. update('data', i => i.id === 42, 'label', l => l + ' !!!'); for (let i = 0; i < current.length; i++) { if (part(current[i], i)) - updatePath(current, [i].concat(path), traversed.concat([i])); + updatePath(current, [i].concat(path), traversed.concat([i]), force); } } else if (isArray && partType === "object") { // Ex. update('data', { from: 3, to: 12, by: 2 }, 'label', l => l + ' !!!'); const { from = 0, to = current.length - 1, by = 1 } = part; for (let i = from; i <= to; i += by) { - updatePath(current, [i].concat(path), traversed.concat([i])); + updatePath(current, [i].concat(path), traversed.concat([i]), force); } } else if (path.length === 1) { let value = path[0]; @@ -223,9 +225,9 @@ function updatePath( isWrappable(value) && !Array.isArray(value) ) { - mergeState(current[part], value); - } else setProperty(current, part, value); - } else updatePath(current[part], path, traversed.concat([part])); + mergeState(current[part], value, force); + } else setProperty(current, part, value, force); + } else updatePath(current[part], path, traversed.concat([part]), force); } export function createState(state?: T | Wrapped) { @@ -248,3 +250,19 @@ export function createState(state?: T | Wrapped) { return [wrappedState, setState] as [Wrapped, typeof setState]; } + +// force state change even if value hasn't changed +export function force(update: StateSetter): (state: Wrapped) => void; +export function force(...path: StatePath): (state: Wrapped) => void; +export function force(paths: StatePath[]): (state: Wrapped) => void; +export function force(reconcile: (s: Wrapped) => void): (state: Wrapped) => void; +export function force(...args: any[]): (state: Wrapped) => void { + return state => { + state = unwrap(state); + if (Array.isArray(args[0])) { + for (let i = 0; i < args.length; i += 1) { + updatePath(state as T, args[i], [], true); + } + } else updatePath(state as T, args, [], true); + } +} diff --git a/test/dom/show.spec.jsx b/test/dom/show.spec.jsx index de885868d..723913e29 100644 --- a/test/dom/show.spec.jsx +++ b/test/dom/show.spec.jsx @@ -19,7 +19,7 @@ describe('Testing an only child when control flow', () => { setCount(7); expect(div.innerHTML).toBe('7'); setCount(5); - expect(div.innerHTML).toBe('7'); + expect(div.innerHTML).toBe('5'); setCount(2); expect(div.innerHTML).toBe(''); }); diff --git a/test/state.spec.js b/test/state.spec.js index f765630f1..ef19ef72c 100644 --- a/test/state.spec.js +++ b/test/state.spec.js @@ -1,4 +1,4 @@ -import { createRoot, createState, createSignal, createEffect, unwrap } from '../dist/index'; +import { createRoot, createState, createSignal, createEffect, unwrap, force } from '../dist/index'; describe('State immutablity', () => { test('Setting a property', () => { @@ -224,4 +224,56 @@ describe('State wrapping', () => { // not wrapped expect(state.time).toBe(date); }); +}); + +describe('Tracking Forced State changes', () => { + test('Track a state change', () => { + createRoot(() => { + var [state, setState] = createState({data: 2}), + executionCount = 0; + + expect.assertions(3); + createEffect(() => { + if (executionCount === 0) + expect(state.data).toBe(2); + else if (executionCount === 1) { + expect(state.data).toBe(5); + } else if (executionCount === 2) { + expect(state.data).toBe(5); + } else { + // should never get here + expect(executionCount).toBe(-1); + } + executionCount++; + }); + + setState({data: 5}); + + // same value again should retrigger + setState(force({data: 5})); + }); + }); + + test('Track a nested state change', () => { + createRoot(() => { + var [state, setState] = createState({user: {firstName: 'John', lastName: 'Smith'}}), + executionCount = 0; + + expect.assertions(2); + createEffect(() => { + if (executionCount === 0) { + expect(state.user.firstName).toBe('John'); + } else if (executionCount === 1) { + expect(state.user.firstName).toBe('John'); + } else { + // should never get here + expect(executionCount).toBe(-1); + } + executionCount++; + }); + + setState(force('user', 'firstName', 'John')); + + }); + }); }); \ No newline at end of file