import { RenderResult, act, render } from '@testing-library/react' import { Atom, Computed, atom } from '@tldraw/state' import { Component, ReactNode, useState } from 'react' import { vi } from 'vitest' import { useAtom } from './useAtom' import { useComputed } from './useComputed' import { useValue } from './useValue' // Error boundary component for testing class TestErrorBoundary extends Component< { children: ReactNode; onError?(error: Error): void }, { hasError: boolean; error: Error | null } > { constructor(props: { children: ReactNode; onError?(error: Error): void }) { super(props) this.state = { hasError: false, error: null } } static getDerivedStateFromError(error: Error) { return { hasError: true, error } } override componentDidCatch(error: Error) { this.props.onError?.(error) } override render() { if (this.state.hasError) { return
Error: {this.state.error?.message}
} return this.props.children } } test('useValue returns a value from a computed', async () => { let theComputed = null as null | Computed let theAtom = null as null | Atom function Component() { const a = useAtom('a', 1) theAtom = a const b = useComputed('a+1', () => a.get() + 1, []) theComputed = b return <>{useValue(b)} } let view: RenderResult await act(() => { view = render() }) expect(theComputed).not.toBeNull() expect(theComputed?.get()).toBe(2) expect(theComputed?.name).toBe('useComputed(a+1)') expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"2"`) await act(() => { theAtom?.set(5) }) expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"6"`) }) test('useValue returns a value from an atom', async () => { let theAtom = null as null | Atom function Component() { const a = useAtom('a', 1) theAtom = a return <>{useValue(a)} } let view: RenderResult await act(() => { view = render() }) expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"1"`) await act(() => { theAtom?.set(5) }) expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"5"`) }) test('useValue returns a value from a compute function', async () => { let theAtom = null as null | Atom let setB = null as null | ((b: number) => void) function Component() { const a = useAtom('a', 1) const [b, _setB] = useState(1) setB = _setB theAtom = a const c = useValue('a+b', () => a.get() + b, [b]) return <>{c} } let view: RenderResult await act(() => { view = render() }) expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"2"`) await act(() => { theAtom?.set(5) }) expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"6"`) await act(() => { setB!(5) }) expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"10"`) }) test("useValue doesn't throw when used in a zombie-child component", async () => { const theAtom = atom>('map', { a: 1, b: 2, c: 3 }) let numThrows = 0 function Parent() { const ids = useValue('ids', () => Object.keys(theAtom.get()), []) return ( <> {ids.map((id) => ( ))} ) } function Child({ id }: { id: string }) { const value = useValue( 'value', () => { if (!(id in theAtom.get())) { numThrows++ throw new Error('id not found!') } return theAtom.get()[id] }, [id] ) return <>{value} } let view: RenderResult act(() => { view = render() }) expect(view!.asFragment().textContent).toMatchInlineSnapshot('"123"') expect(numThrows).toBe(0) // remove id 'b' creating a zombie-child act(() => { theAtom?.update(({ b: _, ...rest }) => rest) }) expect(view!.asFragment().textContent).toMatchInlineSnapshot('"13"') expect(numThrows).toBe(1) }) test('useValue throws synchronously during render when the computed throws', async () => { const theAtom = atom('map', null) let caughtError = null as null | Error // Suppress React's console.error for this test function Component({ id }: { id: string }) { const value = useValue( 'value', () => { const error = theAtom.get() if (error) throw error return 1 }, [id] ) return <>{value} } let view: RenderResult act(() => { view = render( { caughtError = error }} > ) }) expect(view!.asFragment().textContent).toMatchInlineSnapshot('"1"') // ignore console.error here because react will log the error to console.error // even though it's caught by the error boundary const originalError = console.error console.error = vi.fn() try { act(() => { theAtom.set(new Error('test')) }) } finally { console.error = originalError } expect(caughtError).toBeInstanceOf(Error) expect(caughtError?.message).toBe('test') expect(view!.getByTestId('error-boundary')).toBeTruthy() expect(view!.getByTestId('error-boundary').textContent).toBe('Error: test') })