Created
January 4, 2024 12:41
-
-
Save honzabrecka/4a9afa740bae761f1984bfd6eae843ef to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { | |
useEffect, | |
useState, | |
useCallback, | |
StrictMode, | |
useSyncExternalStore, | |
} from 'react'; | |
import { render, waitFor, screen } from '@testing-library/react'; | |
export const strictWrapper = ({ children }: any) => ( | |
<StrictMode>{children}</StrictMode> | |
); | |
type ValueOrUpdater = unknown | ((prev: unknown) => unknown); | |
class Store { | |
values = {}; | |
listeners = {}; | |
subscribe(name: string, cb: () => void) { | |
this.listeners[name] ||= new Set(); | |
this.listeners[name].add(cb); | |
} | |
unsubscribe(name: string, cb: () => void) { | |
this.listeners[name] ||= new Set(); | |
this.listeners[name].delete(cb); | |
if (this.listeners[name].size === 0) { | |
delete this.listeners[name]; | |
} | |
} | |
getSnapshot(name: string) { | |
return this.values[name]; | |
} | |
setValue(name: string, value: ValueOrUpdater) { | |
this.values[name] = | |
typeof value === 'function' ? value(this.values[name]) : value; | |
if (this.listeners[name]) { | |
this.listeners[name].forEach((cb: () => void) => cb()); | |
} | |
} | |
} | |
let order; | |
const Child = ({ store, id }) => { | |
const name = `${id}/child`; | |
useEffect(() => { | |
order('Child Effect'); | |
store.setValue(name, (state) => state || 'baz'); | |
return () => { | |
order('Child Cleanup'); | |
store.setValue(name, undefined); | |
}; | |
}, [name]); | |
const subscribe = useCallback( | |
(cb) => { | |
store.subscribe(name, cb); | |
return () => { | |
store.unsubscribe(name, cb); | |
}; | |
}, | |
[name], | |
); | |
const getSnapshot = useCallback(() => { | |
return store.getSnapshot(name); | |
}, [name]); | |
const value = useSyncExternalStore(subscribe, getSnapshot); | |
order(`Child Render: ${id}, ${value}`); | |
return <div data-testid="child">{value}</div>; | |
}; | |
const Parent = ({ store }) => { | |
const [id] = useState(() => { | |
order('Parent State'); | |
// stable id does not help either | |
return 'parent'; | |
}); | |
const [show, setShow] = useState(false); // <-- set to true to render in different order | |
useEffect(() => { | |
order('Parent Effect'); | |
store.setValue(`${id}/child`, 'bar'); | |
setShow(true); | |
return () => { | |
order('Parent Cleanup'); | |
store.setValue(`${id}/child`, undefined); | |
}; | |
}, [id]); | |
order(`Parent Render: ${id}`); | |
// this conditional rendering is the problem | |
return show ? <Child store={store} id={id} /> : null; | |
}; | |
beforeEach(() => { | |
order = jest.fn(); | |
}); | |
test('react: call order in StrictMode', async () => { | |
const store = new Store(); | |
const { unmount } = render(<Parent store={store} />, { | |
wrapper: strictWrapper, | |
}); | |
await waitFor(() => { | |
expect(Object.values(store.values)).toEqual(['baz']); | |
expect(screen.getByTestId('child').textContent).toEqual('baz'); | |
}); | |
unmount(); | |
await waitFor(() => { | |
expect(order.mock.calls).toEqual([ | |
// order without conditional rendering | |
// ['Parent State'], | |
// ['Parent Render: parent'], | |
// ['Parent State'], | |
// ['Parent Render: parent'], | |
// ['Child Render: parent, undefined'], | |
// ['Child Render: parent, undefined'], | |
// ['Child Effect'], | |
// ['Parent Effect'], | |
// ['Child Cleanup'], | |
// ['Parent Cleanup'], | |
// ['Child Effect'], | |
// ['Parent Effect'], | |
// ['Child Render: parent, bar'], | |
// ['Child Render: parent, bar'], | |
// ['Parent Cleanup'], | |
// ['Child Cleanup'], | |
['Parent State'], | |
['Parent Render: parent'], | |
['Parent State'], | |
['Parent Render: parent'], | |
['Parent Effect'], | |
['Parent Cleanup'], | |
['Parent Effect'], | |
['Parent Render: parent'], | |
['Parent Render: parent'], | |
['Child Render: parent, bar'], | |
['Child Render: parent, bar'], | |
['Child Effect'], | |
['Child Cleanup'], // <-- this one resets (different order) | |
['Child Effect'], | |
['Child Render: parent, baz'], | |
['Child Render: parent, baz'], // <-- child renders with "baz" | |
['Parent Cleanup'], | |
['Child Cleanup'], | |
]); | |
}); | |
}); | |
test('react: call order in production', async () => { | |
const store = new Store(); | |
const { unmount } = render(<Parent store={store} />); | |
await waitFor(() => { | |
expect(Object.values(store.values)).toEqual(['bar']); | |
expect(screen.getByTestId('child').textContent).toEqual('bar'); | |
}); | |
unmount(); | |
await waitFor(() => { | |
expect(order.mock.calls).toEqual([ | |
// order without conditional rendering | |
// ['Parent State'], | |
// ['Parent Render'], | |
// ['Child Render: undefined'], | |
// ['Child Effect'], | |
// ['Parent Effect'], | |
// ['Child Render: bar'], | |
// ['Parent Cleanup'], | |
// ['Child Cleanup'], | |
['Parent State'], | |
['Parent Render: parent'], | |
['Parent Effect'], | |
['Parent Render: parent'], | |
['Child Render: parent, bar'], // <-- child renders with "bar" | |
['Child Effect'], | |
['Parent Cleanup'], | |
['Child Cleanup'], | |
]); | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment