diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..c09746cb --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Tests", + "type": "node", + "request": "launch", + "runtimeExecutable": "yarn", + "args": ["jest", "--runInBand"], + "console": "integratedTerminal", + "cwd": "${workspaceRoot}", + "internalConsoleOptions": "neverOpen" + }, + ] +} diff --git a/jest.setup.ts b/jest.setup.ts index 264828a9..344eef55 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1 +1,36 @@ import '@testing-library/jest-dom/extend-expect' + +let callstacks: Record = {} +let id = 0 + +afterEach(() => { + callstacks = {} + id = 0 +}) + +const generateId = () => { + const stack = + (new Error().stack || '') + .split('\n') + .find(line => /\.test\.tsx:/.test(line)) || '' + + return (callstacks[stack] ||= `:r${id++}:`) +} + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useId: generateId, +})) + +// React's `useId` gives new ids in the same callstack when a component tree is +// destroyed/unmounted. Call this to manually force ids to be recreated in tests +// to mimic React's behavior. +;(globalThis as any).clearUseIdEntry = (idNum: number) => { + const key = Object.keys(callstacks).find( + key => callstacks[key] === `:r${idNum}:` + ) + + if (key) { + delete callstacks[key] + } +} diff --git a/packages/atoms/src/classes/Selectors.ts b/packages/atoms/src/classes/Selectors.ts index f5730b22..c237957a 100644 --- a/packages/atoms/src/classes/Selectors.ts +++ b/packages/atoms/src/classes/Selectors.ts @@ -48,18 +48,6 @@ export class Selectors { */ public _refBaseKeys = new WeakMap, string>() - /** - * Used to work around React double-renders and double-effects. - */ - public _storage: Record< - string, - { - cache?: SelectorCache - ignorePhase?: number - timeoutId?: ReturnType - } - > = {} - constructor(private readonly ecosystem: Ecosystem) {} public addDependent( @@ -182,9 +170,8 @@ export class Selectors { args as Args, true ) - if (!id) return - return this._items[id] + return id && this._items[id] } /** @@ -305,6 +292,7 @@ export class Selectors { _graph.removeDependencies(id) _graph.removeNode(id) delete this._items[id] + this._refBaseKeys.delete(cache.selectorRef) cache.isDestroyed = true // don't delete the ref from this._refBaseKeys; this selector cache isn't // necessarily the only one using it (if the selector takes params). Just @@ -369,20 +357,19 @@ export class Selectors { /** * Should only be used internally */ - public _swapRefs( - oldRef: AtomSelectorOrConfig, - newRef: AtomSelectorOrConfig, + public _swapRefs( + oldCache: SelectorCache, + newRef: AtomSelectorOrConfig, args: any[] = [] ) { - const existingCache = this.find(oldRef, args) - const baseKey = this._refBaseKeys.get(oldRef) + const baseKey = this._refBaseKeys.get(oldCache.selectorRef) - if (!existingCache || !baseKey) return + if (!baseKey) return this._refBaseKeys.set(newRef, baseKey) - this._refBaseKeys.delete(oldRef) - existingCache.selectorRef = newRef - this.runSelector(existingCache.id, args) + this._refBaseKeys.delete(oldCache.selectorRef) + oldCache.selectorRef = newRef + this.runSelector(oldCache.id, args, false, true) } /** @@ -395,7 +382,6 @@ export class Selectors { }) this._refBaseKeys = new WeakMap() - this._storage = {} } /** @@ -429,7 +415,8 @@ export class Selectors { private runSelector( id: string, args: Args, - isInitializing?: boolean + isInitializing?: boolean, + skipNotifyingDependents?: boolean ) { const { _evaluationStack, _graph, _mods, modBus } = this.ecosystem _graph.bufferUpdates(id) @@ -451,7 +438,9 @@ export class Selectors { const result = selector(_evaluationStack.atomGetters, ...args) if (!isInitializing && !resultsComparator(result, cache.result as T)) { - _graph.scheduleDependents(id, cache.nextReasons, result, cache.result) + if (!skipNotifyingDependents) { + _graph.scheduleDependents(id, cache.nextReasons, result, cache.result) + } if (_mods.stateChanged) { modBus.dispatch( diff --git a/packages/machines/test/integrations/state-machines.test.tsx b/packages/machines/test/integrations/state-machines.test.tsx index 06e56eb3..3d775d54 100644 --- a/packages/machines/test/integrations/state-machines.test.tsx +++ b/packages/machines/test/integrations/state-machines.test.tsx @@ -3,7 +3,7 @@ import { InjectMachineStoreParams, MachineState, } from '@zedux/machines' -import { api, atom } from '@zedux/react' +import { api, atom } from '@zedux/atoms' import { ecosystem } from '../../../react/test/utils/ecosystem' const injectMachine = < diff --git a/packages/machines/test/snippets/api.tsx b/packages/machines/test/snippets/api.tsx index ce4fdf96..5c5fd603 100644 --- a/packages/machines/test/snippets/api.tsx +++ b/packages/machines/test/snippets/api.tsx @@ -5,7 +5,7 @@ import { ion, useAtomSelector, useAtomValue, -} from '@zedux/react' +} from '../../../react/src' import { injectMachineStore } from '@zedux/machines' import React, { Suspense, useState } from 'react' diff --git a/packages/react/src/hooks/useAtomInstance.ts b/packages/react/src/hooks/useAtomInstance.ts index c507ba13..6c95339f 100644 --- a/packages/react/src/hooks/useAtomInstance.ts +++ b/packages/react/src/hooks/useAtomInstance.ts @@ -117,8 +117,12 @@ export const useAtomInstance: { } return () => { - // no need to set the "ref"'s `.mounted` property to false here + // remove the edge immediately - no need for a delay here. When StrictMode + // double-invokes (invokes, then cleans up, then re-invokes) this effect, + // it's expected that any `ttl: 0` atoms get destroyed and recreated - + // that's part of what StrictMode is ensuring ecosystem._graph.removeEdge(dependentKey, instance.id) + // no need to set `render.mounted` to false here } }, [instance.id]) diff --git a/packages/react/src/hooks/useAtomSelector.ts b/packages/react/src/hooks/useAtomSelector.ts index b8b15c6f..200fd92a 100644 --- a/packages/react/src/hooks/useAtomSelector.ts +++ b/packages/react/src/hooks/useAtomSelector.ts @@ -29,10 +29,10 @@ export const useAtomSelector = ( const { _graph, selectors } = ecosystem const dependentKey = useReactComponentId() const [, render] = useState() - const storage = - (render as any).storage || (selectors._storage[dependentKey] ||= {}) - const existingCache = storage.cache as SelectorCache | undefined + const existingCache = (render as any).cache as + | SelectorCache + | undefined const argsChanged = !existingCache || @@ -46,98 +46,74 @@ export const useAtomSelector = ( : haveDepsChanged(existingCache.args, args)) const resolvedArgs = argsChanged ? args : (existingCache.args as Args) - const cache = selectors.getCache(selectorOrConfig, resolvedArgs) - const renderedResult = cache.result - - if (cache !== existingCache) { - if (existingCache) { - // yes, remove this during render - _graph.removeEdge(dependentKey, existingCache.id) - } - storage.cache = cache as SelectorCache - } - - // When an inline selector returns a referentially unstable result every run, - // we have to ignore the subsequent update. Do that using a "state machine" - // that goes from 0 -> 1 -> 2. This machine ensures that the ignored update - // occurs after the component rerenders and the effect reruns after that - // render. This works with strict mode on or off. Use the stable `render` - // function as a "ref" :O - if (storage.ignorePhase === 1) { - storage.ignorePhase = 2 + // if the refs/args don't match, existingCache has refCount: 1, there is no + // cache yet for the new ref, and the new ref has the same name, assume it's + // an inline selector + const isSwappingRefs = + existingCache && + existingCache.selectorRef !== selectorOrConfig && + !argsChanged + ? _graph.nodes[existingCache.id]?.refCount === 1 && + !selectors._refBaseKeys.has(selectorOrConfig) && + selectors._getIdealCacheId(existingCache.selectorRef) === + selectors._getIdealCacheId(selectorOrConfig) + : false + + if (isSwappingRefs) { + // switch `mounted` to false temporarily to prevent circular rerenders + ;(render as any).mounted = false + selectors._swapRefs( + existingCache as SelectorCache, + selectorOrConfig as AtomSelectorOrConfig, + resolvedArgs + ) + ;(render as any).mounted = false } - let cancelCleanup = false + const cache = isSwappingRefs + ? (existingCache as SelectorCache) + : selectors.getCache(selectorOrConfig, resolvedArgs) - useEffect(() => { - cancelCleanup = true - delete selectors._storage[dependentKey] - ;(render as any).storage = storage - - // re-get the cache in case an unmounting component's effect cleanup - // destroyed it before we could add this dependent - const newCache = selectors.getCache(selectorOrConfig, resolvedArgs) - - const cleanup = () => { - if (cancelCleanup) { - cancelCleanup = false - - return - } + const addEdge = () => { + if (!_graph.nodes[cache.id]?.dependents.get(dependentKey)) { + _graph.addEdge(dependentKey, cache.id, OPERATION, External, () => { + if ((render as any).mounted) render({}) + }) + } + } - if (storage.ignorePhase !== 1) { - delete selectors._storage[dependentKey] + // Yes, subscribe during render. This operation is idempotent. + addEdge() - queueMicrotask(() => { - _graph.removeEdge(dependentKey, newCache.id) - }) - } - } + const renderedResult = cache.result + ;(render as any).cache = cache as SelectorCache - // Make this function idempotent to guard against React's double-invocation - if (_graph.nodes[newCache.id]?.dependents.get(dependentKey)) { - return cleanup - } + useEffect(() => { + // Try adding the edge again (will be a no-op unless React's StrictMode ran + // this effect's cleanup unnecessarily) + addEdge() - _graph.addEdge(dependentKey, newCache.id, OPERATION, External, () => - render({}) - ) + // use the referentially stable render function as a ref :O + ;(render as any).mounted = true // an unmounting component's effect cleanup can force-destroy the selector - // or update its dependencies before this component is mounted. If that - // happened, trigger a rerender to recache the selector and/or get its new - // result. On the rerender, ignore changes - if (newCache.result !== renderedResult && !storage.ignorePhase) { - storage.ignorePhase = 1 + // or update the state of its dependencies (causing it to rerun) before we + // set `render.mounted`. If that happened, trigger a rerender to recreate + // the selector and/or get its new state + if (cache.isDestroyed || cache.result !== renderedResult) { render({}) } - if (storage.ignorePhase === 2) { - storage.ignorePhase = 0 + return () => { + // remove the edge immediately - no need for a delay here. When StrictMode + // double-invokes (invokes, then cleans up, then re-invokes) this effect, + // it's expected that selectors and `ttl: 0` atoms with no other + // dependents get destroyed and recreated - that's part of what StrictMode + // is ensuring + _graph.removeEdge(dependentKey, cache.id) + // no need to set `render.mounted` to false here } - - // React StrictMode's double renders can wreak havoc on the selector cache. - // Clean up havoc - if (storage.timeoutId == null) { - const removeCruft = () => { - storage.timeoutId = null - cancelCleanup = false - - Object.values(selectors._storage).forEach(storageItem => { - if (storageItem.cache?.id) { - selectors._destroySelector(storageItem.cache.id) - } - }) - } - - storage.timeoutId = - typeof requestIdleCallback !== 'undefined' - ? requestIdleCallback(removeCruft, { timeout: 500 }) - : setTimeout(removeCruft, 500) - } - - return cleanup }, [cache]) return renderedResult as T diff --git a/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap b/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap index c0fa6219..a7795463 100644 --- a/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap +++ b/packages/react/test/integrations/__snapshots__/selection.test.tsx.snap @@ -106,10 +106,10 @@ exports[`selection same-name selectors share the namespace when destroyed and re { "1": { "dependencies": Map { - "@@selector-common-name-0" => true, + "@@selector-common-name-4" => true, }, "dependents": Map { - "no-4" => { + "no-5" => { "callback": undefined, "createdAt": 123456789, "flags": 3, @@ -136,12 +136,12 @@ exports[`selection same-name selectors share the namespace when destroyed and re "refCount": 1, "weight": 3, }, - "@@selector-common-name-0": { + "@@selector-common-name-2": { "dependencies": Map { "root" => true, }, "dependents": Map { - "1" => { + "2" => { "callback": undefined, "createdAt": 123456789, "flags": 0, @@ -152,12 +152,12 @@ exports[`selection same-name selectors share the namespace when destroyed and re "refCount": 1, "weight": 2, }, - "@@selector-common-name-2": { + "@@selector-common-name-4": { "dependencies": Map { "root" => true, }, "dependents": Map { - "2" => { + "1" => { "callback": undefined, "createdAt": 123456789, "flags": 0, @@ -177,7 +177,7 @@ exports[`selection same-name selectors share the namespace when destroyed and re "flags": 0, "operation": "get", }, - "@@selector-common-name-0" => { + "@@selector-common-name-4" => { "callback": undefined, "createdAt": 123456789, "flags": 0, @@ -195,10 +195,10 @@ exports[`selection same-name selectors share the namespace when destroyed and re { "1": { "dependencies": Map { - "@@selector-common-name-0" => true, + "@@selector-common-name-4" => true, }, "dependents": Map { - "no-4" => { + "no-5" => { "callback": undefined, "createdAt": 123456789, "flags": 3, @@ -209,7 +209,7 @@ exports[`selection same-name selectors share the namespace when destroyed and re "refCount": 1, "weight": 3, }, - "@@selector-common-name-0": { + "@@selector-common-name-4": { "dependencies": Map { "root" => true, }, @@ -228,7 +228,7 @@ exports[`selection same-name selectors share the namespace when destroyed and re "root": { "dependencies": Map {}, "dependents": Map { - "@@selector-common-name-0" => { + "@@selector-common-name-4" => { "callback": undefined, "createdAt": 123456789, "flags": 0, diff --git a/packages/react/test/integrations/selection.test.tsx b/packages/react/test/integrations/selection.test.tsx index cdce800d..ddb267b2 100644 --- a/packages/react/test/integrations/selection.test.tsx +++ b/packages/react/test/integrations/selection.test.tsx @@ -150,6 +150,11 @@ describe('selection', () => { expect(selector3).toHaveBeenCalledTimes(1) expect((await findByTestId('text')).innerHTML).toBe('c1') + // reset the 3 useId calls in ResurrectingComponent's useAtomSelectors + ;(globalThis as any).clearUseIdEntry(1) + ;(globalThis as any).clearUseIdEntry(2) + ;(globalThis as any).clearUseIdEntry(3) + act(() => { fireEvent.click(button) jest.runAllTimers() diff --git a/packages/react/test/units/Selectors.test.tsx b/packages/react/test/units/Selectors.test.tsx index ed1388cf..55b7b6e8 100644 --- a/packages/react/test/units/Selectors.test.tsx +++ b/packages/react/test/units/Selectors.test.tsx @@ -62,9 +62,8 @@ describe('the Selectors class', () => { ecosystem.selectors.destroyCache(cache2b) // destroys only selector2 expect(ecosystem.selectors._items).toEqual({ - // id # is still 1 'cause the Selector class's `_refBaseKeys` still holds - // the cached key despite `cache2b`'s destruction above - '@@selector-selector1-1': expect.any(Object), + // id 3 'cause cache1b is the 4th id'd item created + '@@selector-selector1-3': expect.any(Object), }) cleanup() @@ -116,10 +115,9 @@ describe('the Selectors class', () => { expect(cache1.isDestroyed).toBe(true) expect(cache2.isDestroyed).toBe(true) expect(ecosystem.selectors.dehydrate()).toEqual({ - // ids 2 & 1 - the refs are still cached in the Selector class's - // `_refBaseKeys` - '@@selector-selector1-2': 'ab', - '@@selector-selector2-1': 'abc', + // 5 ids have been created at this point + '@@selector-selector1-4': 'ab', + '@@selector-selector2-3': 'abc', '@@selector-selector3-0': 'abcd', }) diff --git a/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap b/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap index 6b174fce..56577753 100644 --- a/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap +++ b/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap @@ -21,7 +21,7 @@ exports[`useAtomSelector inline selector that returns a different object referen "1" => true, }, "dependents": Map { - "Test-:rb:" => { + "Test-:r0:" => { "callback": [Function], "createdAt": 123456789, "flags": 2, @@ -40,7 +40,7 @@ exports[`useAtomSelector inline selector that returns a different object referen "1": { "dependencies": Map {}, "dependents": Map { - "@@selector-unnamed-1" => { + "@@selector-unnamed-0" => { "callback": undefined, "createdAt": 123456789, "flags": 0, @@ -51,16 +51,17 @@ exports[`useAtomSelector inline selector that returns a different object referen "refCount": 1, "weight": 1, }, - "@@selector-unnamed-1": { + "@@selector-unnamed-0": { "dependencies": Map { "1" => true, }, "dependents": Map { - "Test-:rb:" => { + "Test-:r0:" => { "callback": [Function], "createdAt": 123456789, "flags": 2, "operation": "useAtomSelector", + "task": undefined, }, }, "isSelector": true, @@ -75,7 +76,13 @@ exports[`useAtomSelector inline selector that returns a different object referen "1": { "dependencies": Map {}, "dependents": Map { - "@@selector-unnamed-1" => { + "@@selector-unnamed-0" => { + "callback": undefined, + "createdAt": 123456789, + "flags": 0, + "operation": "get", + }, + "@@selector-unnamed-2" => { "callback": undefined, "createdAt": 123456789, "flags": 0, @@ -83,15 +90,31 @@ exports[`useAtomSelector inline selector that returns a different object referen }, }, "isSelector": undefined, - "refCount": 1, + "refCount": 2, "weight": 1, }, - "@@selector-unnamed-1": { + "@@selector-unnamed-0": { + "dependencies": Map { + "1" => true, + }, + "dependents": Map { + "Test-:r0:" => { + "callback": [Function], + "createdAt": 123456789, + "flags": 2, + "operation": "useAtomSelector", + }, + }, + "isSelector": true, + "refCount": 1, + "weight": 2, + }, + "@@selector-unnamed-2": { "dependencies": Map { "1" => true, }, "dependents": Map { - "Test-:rd:" => { + "Test-:r0:" => { "callback": [Function], "createdAt": 123456789, "flags": 2, @@ -110,7 +133,13 @@ exports[`useAtomSelector inline selector that returns a different object referen "1": { "dependencies": Map {}, "dependents": Map { - "@@selector-unnamed-3" => { + "@@selector-unnamed-0" => { + "callback": undefined, + "createdAt": 123456789, + "flags": 0, + "operation": "get", + }, + "@@selector-unnamed-2" => { "callback": undefined, "createdAt": 123456789, "flags": 0, @@ -118,19 +147,37 @@ exports[`useAtomSelector inline selector that returns a different object referen }, }, "isSelector": undefined, - "refCount": 1, + "refCount": 2, "weight": 1, }, - "@@selector-unnamed-3": { + "@@selector-unnamed-0": { + "dependencies": Map { + "1" => true, + }, + "dependents": Map { + "Test-:r0:" => { + "callback": [Function], + "createdAt": 123456789, + "flags": 2, + "operation": "useAtomSelector", + "task": undefined, + }, + }, + "isSelector": true, + "refCount": 1, + "weight": 2, + }, + "@@selector-unnamed-2": { "dependencies": Map { "1" => true, }, "dependents": Map { - "Test-:rd:" => { + "Test-:r0:" => { "callback": [Function], "createdAt": 123456789, "flags": 2, "operation": "useAtomSelector", + "task": undefined, }, }, "isSelector": true, diff --git a/packages/react/test/units/useAtomSelector.test.tsx b/packages/react/test/units/useAtomSelector.test.tsx index 808cecef..b776cbd3 100644 --- a/packages/react/test/units/useAtomSelector.test.tsx +++ b/packages/react/test/units/useAtomSelector.test.tsx @@ -168,7 +168,7 @@ describe('useAtomSelector', () => { }) }) - test('useAtomSelector creates/uses a different cache when selector goes from object form to function form', async () => { + test('useAtomSelector reuses the same cache when selector goes from object form to function form', async () => { jest.useFakeTimers() const selector1 = jest.fn(() => 1) @@ -208,7 +208,7 @@ describe('useAtomSelector', () => { expect(div.innerHTML).toBe('1') expect(selector1).toHaveBeenCalledTimes(2) expect(ecosystem.selectors.dehydrate()).toEqual({ - '@@selector-mockConstructor-1': 1, + '@@selector-mockConstructor-0': 1, }) }) @@ -305,7 +305,7 @@ describe('useAtomSelector', () => { expect(div.innerHTML).toBe('0') expect(selector1).toHaveBeenCalledTimes(2) expect(ecosystem.selectors.dehydrate()).toEqual({ - '@@selector-mockConstructor-1-[0]': 0, + '@@selector-mockConstructor-0-[0]': 0, }) }) @@ -356,7 +356,7 @@ describe('useAtomSelector', () => { expect(div.innerHTML).toBe('1') expect(selector1).toHaveBeenCalledTimes(2) expect(ecosystem.selectors.dehydrate()).toEqual({ - '@@selector-mockConstructor-2': 1, + '@@selector-mockConstructor-0': 1, }) expect(renders).toBe(2) }) @@ -440,13 +440,22 @@ describe('useAtomSelector', () => { const button = await findByTestId('button') const div = await findByTestId('text') - jest.runAllTimers() - + // TODO: These snapshots are temporarily wrong in StrictMode pre React 19 + // (React 18 StrictMode causes a memory leak). Upgrading to React 19 will + // fix them. expect(ecosystem.selectors.findAll()).toMatchInlineSnapshot(` { - "@@selector-unnamed-1": SelectorCache { + "@@selector-unnamed-0": SelectorCache { "args": [], - "id": "@@selector-unnamed-1", + "id": "@@selector-unnamed-0", + "nextReasons": [], + "prevReasons": [], + "result": 1, + "selectorRef": [Function], + }, + "@@selector-unnamed-2": SelectorCache { + "args": [], + "id": "@@selector-unnamed-2", "nextReasons": [], "prevReasons": [], "result": 1, @@ -465,9 +474,17 @@ describe('useAtomSelector', () => { expect(ecosystem.selectors.findAll()).toMatchInlineSnapshot(` { - "@@selector-unnamed-3": SelectorCache { + "@@selector-unnamed-0": SelectorCache { "args": [], - "id": "@@selector-unnamed-3", + "id": "@@selector-unnamed-0", + "nextReasons": [], + "prevReasons": [], + "result": 1, + "selectorRef": [Function], + }, + "@@selector-unnamed-2": SelectorCache { + "args": [], + "id": "@@selector-unnamed-2", "nextReasons": [], "prevReasons": [], "result": 1, @@ -533,10 +550,12 @@ describe('useAtomSelector', () => { const div = await findByTestId('text') - jest.runAllTimers() - expect(div.innerHTML).toBe('1') - expect(renders).toBe(2) // 2 rerenders + 2 for strict mode + expect(renders).toBe(4) // 2 rerenders + 2 for strict mode + + // TODO: These snapshots are temporarily wrong in StrictMode pre React 19 + // (React 18 StrictMode causes a memory leak). Upgrading to React 19 will + // fix them. expect(ecosystem._graph.nodes).toMatchSnapshot() act(() => { @@ -545,7 +564,7 @@ describe('useAtomSelector', () => { }) expect(div.innerHTML).toBe('2') - expect(renders).toBe(4) // 4 rerenders + 4 for strict mode + expect(renders).toBe(6) // 3 rerenders + 3 for strict mode expect(ecosystem._graph.nodes).toMatchSnapshot() }) }) diff --git a/packages/react/test/utils/ecosystem.ts b/packages/react/test/utils/ecosystem.ts index 4ba4903e..b2f08fae 100644 --- a/packages/react/test/utils/ecosystem.ts +++ b/packages/react/test/utils/ecosystem.ts @@ -1,5 +1,5 @@ import { act } from '@testing-library/react' -import { createEcosystem } from '@zedux/react' +import { createEcosystem } from '@zedux/atoms' export const ecosystem = createEcosystem({ id: 'test' })