|
1 | 1 | import { Component, Fragment, createElement, render } from 'preact'; |
2 | | -import { useEffect, useRef, useState } from 'preact/hooks'; |
| 2 | +import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; |
3 | 3 | import { act, teardown as teardownAct } from 'preact/test-utils'; |
4 | 4 | import { vi } from 'vitest'; |
5 | 5 | import { setupScratch, teardown } from '../../../test/_util/helpers'; |
@@ -225,6 +225,52 @@ describe('useEffect', () => { |
225 | 225 | expect(scratch.innerHTML).to.equal('<p>Error</p>'); |
226 | 226 | }); |
227 | 227 |
|
| 228 | + it('should route deferred cleanup errors after a state update', () => { |
| 229 | + const spy = vi.fn(); |
| 230 | + let hide; |
| 231 | + |
| 232 | + function ThrowOnUnmount() { |
| 233 | + useEffect( |
| 234 | + () => () => { |
| 235 | + throw new Error('err'); |
| 236 | + }, |
| 237 | + [] |
| 238 | + ); |
| 239 | + return <span>Child</span>; |
| 240 | + } |
| 241 | + |
| 242 | + function StatefulParent() { |
| 243 | + const [show, setShow] = useState(true); |
| 244 | + hide = () => setShow(false); |
| 245 | + return <div>{show ? <ThrowOnUnmount /> : <span>Gone</span>}</div>; |
| 246 | + } |
| 247 | + |
| 248 | + class ErrorBoundary extends Component { |
| 249 | + componentDidCatch(error) { |
| 250 | + spy(error); |
| 251 | + this.setState({ error: true }); |
| 252 | + } |
| 253 | + |
| 254 | + render(props, state) { |
| 255 | + return state.error ? <p>Error</p> : props.children; |
| 256 | + } |
| 257 | + } |
| 258 | + |
| 259 | + act(() => |
| 260 | + render( |
| 261 | + <ErrorBoundary> |
| 262 | + <StatefulParent /> |
| 263 | + </ErrorBoundary>, |
| 264 | + scratch |
| 265 | + ) |
| 266 | + ); |
| 267 | + act(() => hide()); |
| 268 | + |
| 269 | + expect(spy).toHaveBeenCalledOnce(); |
| 270 | + expect(spy.mock.calls[0][0]).to.have.property('message', 'err'); |
| 271 | + expect(scratch.innerHTML).to.equal('<p>Error</p>'); |
| 272 | + }); |
| 273 | + |
228 | 274 | it('catches errors when error is invoked during render', () => { |
229 | 275 | const spy = vi.fn(); |
230 | 276 | let errored; |
@@ -261,6 +307,35 @@ describe('useEffect', () => { |
261 | 307 | expect(scratch.innerHTML).to.equal('<p>Error</p>'); |
262 | 308 | }); |
263 | 309 |
|
| 310 | + it('should flush cleanups of a root unmounted from within an effect', async () => { |
| 311 | + const log = []; |
| 312 | + const host = document.createElement('div'); |
| 313 | + const other = document.createElement('div'); |
| 314 | + scratch.appendChild(host); |
| 315 | + scratch.appendChild(other); |
| 316 | + |
| 317 | + function Other() { |
| 318 | + useEffect(() => () => log.push('other cleanup'), []); |
| 319 | + return <p>other</p>; |
| 320 | + } |
| 321 | + |
| 322 | + function Trigger() { |
| 323 | + useEffect(() => { |
| 324 | + log.push('trigger effect'); |
| 325 | + render(null, other); |
| 326 | + }, []); |
| 327 | + return <p>trigger</p>; |
| 328 | + } |
| 329 | + |
| 330 | + render(<Other />, other); |
| 331 | + await new Promise(r => setTimeout(r, 60)); |
| 332 | + |
| 333 | + render(<Trigger />, host); |
| 334 | + await new Promise(r => setTimeout(r, 60)); |
| 335 | + |
| 336 | + expect(log).to.deep.equal(['trigger effect', 'other cleanup']); |
| 337 | + }); |
| 338 | + |
264 | 339 | it('should allow creating a new root', () => { |
265 | 340 | const root = document.createElement('div'); |
266 | 341 | const global = document.createElement('div'); |
@@ -665,6 +740,147 @@ describe('useEffect', () => { |
665 | 740 | }); |
666 | 741 | }); |
667 | 742 |
|
| 743 | + it('should run cleanup of an unmounted child after the parent commits (#4299)', () => { |
| 744 | + const log = []; |
| 745 | + |
| 746 | + function Child() { |
| 747 | + useEffect(() => { |
| 748 | + log.push('child effect'); |
| 749 | + return () => { |
| 750 | + log.push('child cleanup'); |
| 751 | + }; |
| 752 | + }, []); |
| 753 | + return null; |
| 754 | + } |
| 755 | + |
| 756 | + function Parent({ show }) { |
| 757 | + log.push('parent render'); |
| 758 | + useLayoutEffect(() => { |
| 759 | + log.push('parent layout effect'); |
| 760 | + }); |
| 761 | + return show ? <Child /> : null; |
| 762 | + } |
| 763 | + |
| 764 | + act(() => render(<Parent show={true} />, scratch)); |
| 765 | + act(() => render(<Parent show={false} />, scratch)); |
| 766 | + |
| 767 | + expect(log).to.deep.equal([ |
| 768 | + 'parent render', |
| 769 | + 'parent layout effect', |
| 770 | + 'child effect', |
| 771 | + 'parent render', |
| 772 | + 'parent layout effect', |
| 773 | + 'child cleanup' |
| 774 | + ]); |
| 775 | + }); |
| 776 | + |
| 777 | + it('should run cleanups of a deep removed subtree in tree order (#4299)', () => { |
| 778 | + const log = []; |
| 779 | + |
| 780 | + const Level = ({ name, children }) => { |
| 781 | + useLayoutEffect(() => () => log.push(`${name} layout cleanup`), []); |
| 782 | + useEffect(() => () => log.push(`${name} passive cleanup`), []); |
| 783 | + return <div class={name}>{children}</div>; |
| 784 | + }; |
| 785 | + |
| 786 | + const App = ({ show }) => ( |
| 787 | + <section> |
| 788 | + {show ? ( |
| 789 | + <Level name="A"> |
| 790 | + <Level name="B"> |
| 791 | + <Level name="C"> |
| 792 | + <span>leaf</span> |
| 793 | + </Level> |
| 794 | + </Level> |
| 795 | + </Level> |
| 796 | + ) : null} |
| 797 | + </section> |
| 798 | + ); |
| 799 | + |
| 800 | + act(() => render(<App show />, scratch)); |
| 801 | + log.length = 0; |
| 802 | + act(() => render(<App show={false} />, scratch)); |
| 803 | + |
| 804 | + // All layout cleanups run in the commit, then all passive ones after |
| 805 | + // paint, each top-down. Matches React 19. |
| 806 | + expect(log).to.deep.equal([ |
| 807 | + 'A layout cleanup', |
| 808 | + 'B layout cleanup', |
| 809 | + 'C layout cleanup', |
| 810 | + 'A passive cleanup', |
| 811 | + 'B passive cleanup', |
| 812 | + 'C passive cleanup' |
| 813 | + ]); |
| 814 | + }); |
| 815 | + |
| 816 | + it('should route a deferred cleanup error past boundaries inside the removed subtree', () => { |
| 817 | + const log = []; |
| 818 | + |
| 819 | + class Boundary extends Component { |
| 820 | + componentDidCatch(err) { |
| 821 | + log.push(`${this.props.name} caught: ${err.message}`); |
| 822 | + this.setState({ errored: true }); |
| 823 | + } |
| 824 | + |
| 825 | + render(props, state) { |
| 826 | + return state.errored ? <p>{props.name} error</p> : props.children; |
| 827 | + } |
| 828 | + } |
| 829 | + |
| 830 | + const Deep = () => { |
| 831 | + useEffect( |
| 832 | + () => () => { |
| 833 | + throw new Error('deep'); |
| 834 | + }, |
| 835 | + [] |
| 836 | + ); |
| 837 | + return <i>deep</i>; |
| 838 | + }; |
| 839 | + |
| 840 | + // `Inner` is itself being removed, so it must not handle the error; the |
| 841 | + // still-mounted `Outer` boundary has to. |
| 842 | + const Removed = () => ( |
| 843 | + <Boundary name="Inner"> |
| 844 | + <div> |
| 845 | + <Deep /> |
| 846 | + </div> |
| 847 | + </Boundary> |
| 848 | + ); |
| 849 | + |
| 850 | + const App = ({ show }) => ( |
| 851 | + <Boundary name="Outer"> |
| 852 | + <div>{show ? <Removed /> : null}</div> |
| 853 | + </Boundary> |
| 854 | + ); |
| 855 | + |
| 856 | + act(() => render(<App show />, scratch)); |
| 857 | + log.length = 0; |
| 858 | + act(() => render(<App show={false} />, scratch)); |
| 859 | + |
| 860 | + expect(log).to.deep.equal(['Outer caught: deep']); |
| 861 | + expect(scratch.innerHTML).to.equal('<p>Outer error</p>'); |
| 862 | + }); |
| 863 | + |
| 864 | + it('should run cleanups of unmounted components before new effects (#4299)', () => { |
| 865 | + const log = []; |
| 866 | + |
| 867 | + function Child({ name }) { |
| 868 | + useEffect(() => { |
| 869 | + log.push(`${name} effect`); |
| 870 | + return () => { |
| 871 | + log.push(`${name} cleanup`); |
| 872 | + }; |
| 873 | + }, []); |
| 874 | + return <p>{name}</p>; |
| 875 | + } |
| 876 | + |
| 877 | + act(() => render(<Child key="a" name="A" />, scratch)); |
| 878 | + log.length = 0; |
| 879 | + act(() => render(<Child key="b" name="B" />, scratch)); |
| 880 | + |
| 881 | + expect(log).to.deep.equal(['A cleanup', 'B effect']); |
| 882 | + }); |
| 883 | + |
668 | 884 | it('should not rerun when receiving NaN on subsequent renders', () => { |
669 | 885 | const calls = []; |
670 | 886 | const Component = ({ value }) => { |
|
0 commit comments