diff --git a/packages/react/src/hooks/useAtomInstance.ts b/packages/react/src/hooks/useAtomInstance.ts index d2c38812..ec01f5c1 100644 --- a/packages/react/src/hooks/useAtomInstance.ts +++ b/packages/react/src/hooks/useAtomInstance.ts @@ -81,7 +81,7 @@ export const useAtomInstance: { let edge: DependentEdge | undefined - const addEdge = () => { + const addEdge = (isMaterialized?: boolean) => { if (!ecosystem._graph.nodes[instance.id]?.dependents.get(dependentKey)) { edge = ecosystem._graph.addEdge( dependentKey, @@ -94,6 +94,7 @@ export const useAtomInstance: { ) if (edge) { + edge.isMaterialized = isMaterialized edge.dependentKey = dependentKey if (instance._lastEdge) { @@ -131,8 +132,10 @@ export const useAtomInstance: { } // Try adding the edge again (will be a no-op unless React's StrictMode ran - // this effect's cleanup unnecessarily) - addEdge() + // this effect's cleanup unnecessarily OR other effects in child components + // cleaned up this component's edges before it could materialize them. + // That's fine, just recreate them with `isMaterialized: true` now) + addEdge(true) // use the referentially stable render function as a ref :O ;(render as any).mounted = true diff --git a/packages/react/src/hooks/useAtomSelector.ts b/packages/react/src/hooks/useAtomSelector.ts index 46b3ebc2..b1adc3f6 100644 --- a/packages/react/src/hooks/useAtomSelector.ts +++ b/packages/react/src/hooks/useAtomSelector.ts @@ -78,13 +78,14 @@ export const useAtomSelector = ( let edge: DependentEdge | undefined - const addEdge = () => { + const addEdge = (isMaterialized?: boolean) => { if (!_graph.nodes[cache.id]?.dependents.get(dependentKey)) { edge = _graph.addEdge(dependentKey, cache.id, OPERATION, External, () => { if ((render as any).mounted) render({}) }) if (edge) { + edge.isMaterialized = isMaterialized edge.dependentKey = dependentKey if (cache._lastEdge) { @@ -143,8 +144,10 @@ export const useAtomSelector = ( } // Try adding the edge again (will be a no-op unless React's StrictMode ran - // this effect's cleanup unnecessarily) - addEdge() + // this effect's cleanup unnecessarily OR other effects in child components + // cleaned up this component's edges before it could materialize them. + // That's fine, just recreate them with `isMaterialized: true` now) + addEdge(true) // use the referentially stable render function as a ref :O ;(render as any).mounted = true diff --git a/packages/react/test/regressions/165.test.tsx b/packages/react/test/regressions/165.test.tsx new file mode 100644 index 00000000..f9abee91 --- /dev/null +++ b/packages/react/test/regressions/165.test.tsx @@ -0,0 +1,171 @@ +import { + atom, + AtomGetters, + useAtomSelector, + useAtomState, + useAtomValue, +} from '@zedux/react' +import React from 'react' +import { renderInEcosystem } from '../utils/renderInEcosystem' +import { act } from '@testing-library/react' +import { ecosystem } from '../utils/ecosystem' + +const itemsAtom = atom('items', [{ id: 1 }]) +const exampleAtom = atom('example', 'a') +const selector = ({ get }: AtomGetters) => get(exampleAtom) + +let isUsingSelectors = false + +function Item({ id }: { id: number }) { + const exampleValue = isUsingSelectors + ? useAtomSelector(selector) + : useAtomValue(exampleAtom) + + return
{exampleValue}
+} + +function List() { + const [items, setItems] = useAtomState(itemsAtom) + const exampleValue = isUsingSelectors + ? useAtomSelector(selector) + : useAtomValue(exampleAtom) + + return ( +
+
{exampleValue}
+
    + {items.map(item => ( + + ))} +
+ +
+ ) +} + +describe('issue #165', () => { + test('atoms in strict mode', async () => { + ;(globalThis as any).useReact18UseId() + isUsingSelectors = false + + const { findByTestId } = renderInEcosystem(, { + useStrictMode: true, + }) + const button = await findByTestId('addItem') + const list = await findByTestId('list') + const item1 = await findByTestId('item1') + + expect(list).toHaveTextContent('a') + expect(item1).toHaveTextContent('a') + + act(() => { + button.click() + }) + + const item2 = await findByTestId('item2') + + expect(item2).toHaveTextContent('a') + + act(() => { + ecosystem.getInstance(exampleAtom).setState('aa') + }) + + expect(list).toHaveTextContent('aa') + expect(item1).toHaveTextContent('aa') + expect(item2).toHaveTextContent('aa') + }) + + test('atoms without strict mode', async () => { + ;(globalThis as any).useReact18UseId() + isUsingSelectors = false + + const { findByTestId } = renderInEcosystem() + const button = await findByTestId('addItem') + const list = await findByTestId('list') + const item1 = await findByTestId('item1') + + expect(list).toHaveTextContent('a') + expect(item1).toHaveTextContent('a') + + act(() => { + button.click() + }) + + const item2 = await findByTestId('item2') + + expect(item2).toHaveTextContent('a') + + act(() => { + ecosystem.getInstance(exampleAtom).setState('aa') + }) + + expect(list).toHaveTextContent('aa') + expect(item1).toHaveTextContent('aa') + expect(item2).toHaveTextContent('aa') + }) + + test('selectors in strict mode', async () => { + ;(globalThis as any).useReact18UseId() + isUsingSelectors = true + + const { findByTestId } = renderInEcosystem(, { + useStrictMode: true, + }) + const button = await findByTestId('addItem') + const list = await findByTestId('list') + const item1 = await findByTestId('item1') + + expect(list).toHaveTextContent('a') + expect(item1).toHaveTextContent('a') + + act(() => { + button.click() + }) + + const item2 = await findByTestId('item2') + + expect(item2).toHaveTextContent('a') + + act(() => { + ecosystem.getInstance(exampleAtom).setState('aa') + }) + + expect(list).toHaveTextContent('aa') + expect(item1).toHaveTextContent('aa') + expect(item2).toHaveTextContent('aa') + }) + + test('selectors without strict mode', async () => { + ;(globalThis as any).useReact18UseId() + isUsingSelectors = true + + const { findByTestId } = renderInEcosystem() + const button = await findByTestId('addItem') + const list = await findByTestId('list') + const item1 = await findByTestId('item1') + + expect(list).toHaveTextContent('a') + expect(item1).toHaveTextContent('a') + + act(() => { + button.click() + }) + + const item2 = await findByTestId('item2') + + expect(item2).toHaveTextContent('a') + + act(() => { + ecosystem.getInstance(exampleAtom).setState('aa') + }) + + expect(list).toHaveTextContent('aa') + expect(item1).toHaveTextContent('aa') + expect(item2).toHaveTextContent('aa') + }) +}) diff --git a/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap b/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap index 3d4f82a1..7e31f77a 100644 --- a/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap +++ b/packages/react/test/units/__snapshots__/useAtomSelector.test.tsx.snap @@ -101,6 +101,7 @@ exports[`useAtomSelector inline selector that returns a different object referen "createdAt": 123456789, "dependentKey": "Test-:rb:", "flags": 2, + "isMaterialized": undefined, "operation": "useAtomSelector", }, }, @@ -137,6 +138,7 @@ exports[`useAtomSelector inline selector that returns a different object referen "createdAt": 123456789, "dependentKey": "Test-:rb:", "flags": 2, + "isMaterialized": undefined, "operation": "useAtomSelector", "task": undefined, },