diff --git a/hooks/src/index.js b/hooks/src/index.js index c43d040fa9..aeca5944da 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -447,13 +447,14 @@ export function useId() { function flushAfterPaintEffects() { let component; while ((component = afterPaintEffects.shift())) { - if (!component._parentDom || !component.__hooks) continue; + const hooks = component.__hooks; + if (!component._parentDom || !hooks) continue; try { - component.__hooks._pendingEffects.some(invokeCleanup); - component.__hooks._pendingEffects.some(invokeEffect); - component.__hooks._pendingEffects = []; + hooks._pendingEffects.some(invokeCleanup); + hooks._pendingEffects.some(invokeEffect); + hooks._pendingEffects = []; } catch (e) { - component.__hooks._pendingEffects = []; + hooks._pendingEffects = []; options._catchError(e, component._vnode); } } diff --git a/hooks/test/browser/useEffect.test.js b/hooks/test/browser/useEffect.test.js index feb554192d..3bf8c938cf 100644 --- a/hooks/test/browser/useEffect.test.js +++ b/hooks/test/browser/useEffect.test.js @@ -636,4 +636,33 @@ describe('useEffect', () => { expect(calls.length).to.equal(1); expect(calls).to.deep.equal(['doing effecthi']); }); + + it('should not crash when effect throws and component is unmounted by render(null) during flush', () => { + // In flushAfterPaintEffects(): + // 1. Guard checks component.__hooks — truthy, passes + // 2. invokeEffect runs the effect callback + // 3. The callback calls render(null, scratch) which unmounts the tree + // → options.unmount sets component.__hooks = undefined + // 4. Resetting the hooks array to an empty array would throw an error + let setVal; + + function App() { + const [val, _setVal] = useState(0); + setVal = _setVal; + useEffect(() => { + if (val === 1) { + render(null, scratch); + } + }, [val]); + return