diff --git a/src/Portal.tsx b/src/Portal.tsx index 7fbca4f..16a2d7b 100644 --- a/src/Portal.tsx +++ b/src/Portal.tsx @@ -99,15 +99,17 @@ const Portal = React.forwardRef((props, ref) => { >(() => getPortalContainer(getContainer)); React.useEffect(() => { - const customizeContainer = getPortalContainer(getContainer); - // Tell component that we check this in effect which is safe to be `null` - setInnerContainer( - () => - // React do the state update even the value is the same, - // Use function call to force React to compare update - customizeContainer ?? null, - ); + const customizeContainer = getPortalContainer(getContainer) ?? null; + + // Re-resolved after every render — a stable `getContainer` may close over a + // ref whose element changes — but only a changed container is stored. React + // skips a same-value update only while this fiber has no pending lane; once + // one lingers, every no-op call schedules a render, and a parent re-rendering + // on each keystroke stacks those renders into "Maximum update depth exceeded". + if (customizeContainer !== innerContainer) { + setInnerContainer(customizeContainer); + } }); const [defaultContainer, queueCreate] = useDom( diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 5b81aa8..d7e5760 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -93,6 +93,49 @@ describe('Portal', () => { expect(container).toMatchSnapshot(); expect(renderTimes).toEqual(2); }); + + it('switch container without a redundant render', () => { + // The container is re-resolved after every render, but only a changed + // one may be stored: React skips a same-value update only while the fiber + // has no pending lane, so a no-op right after a real update costs a + // render, and under rapid parent re-renders those renders compound into + // "Maximum update depth exceeded" (#27). + const portal = Portal as unknown as { + render: (...args: unknown[]) => React.ReactNode; + }; + const originalRender = portal.render; + let portalRenders = 0; + portal.render = (...args) => { + portalRenders += 1; + return originalRender(...args); + }; + + const first = document.createElement('div'); + const second = document.createElement('div'); + document.body.append(first, second); + + const Demo = ({ target }: { target: HTMLElement }) => ( + target}> + + + ); + + try { + const { rerender } = render(); + expect(first.querySelector('.switch-content')).toBeTruthy(); + + portalRenders = 0; + rerender(); + + expect(second.querySelector('.switch-content')).toBeTruthy(); + // The parent's render, plus the one storing the new container. + expect(portalRenders).toEqual(2); + } finally { + portal.render = originalRender; + first.remove(); + second.remove(); + } + }); }); describe('dynamic change autoLock', () => {