diff --git a/packages/react/src/components/__tests__/createInlineOverlayComponent.spec.tsx b/packages/react/src/components/__tests__/createInlineOverlayComponent.spec.tsx index e2cb8176af8..6c12aa60d5b 100644 --- a/packages/react/src/components/__tests__/createInlineOverlayComponent.spec.tsx +++ b/packages/react/src/components/__tests__/createInlineOverlayComponent.spec.tsx @@ -30,14 +30,97 @@ const IonPopover = createInlineOverlayComponent('ion-popover', undefin /** * Simulate what CoreDelegate does when an overlay presents: it teleports the - * host element out of its portal parent into another in-document container - * (the running app uses ion-app; here we use any sibling). + * host element out of its portal parent into another in-document container. + * The running app uses the single `ion-app` for every overlay, so one shared + * destination is created lazily and reused - overlays that present in + * sequence end up as siblings there, in presentation order. */ const teleport = (el: HTMLElement) => { - const dest = document.createElement('div'); - dest.id = 'teleport-destination'; - document.body.appendChild(dest); + let dest = document.getElementById('teleport-destination'); + if (!dest) { + dest = document.createElement('div'); + dest.id = 'teleport-destination'; + document.body.appendChild(dest); + } dest.appendChild(el); + return dest; +}; + +/** + * A component that suspends until `resolve` is called, plus the helpers to + * drive it. Rendering `` inside a boundary hides that boundary's + * content - React runs `componentWillUnmount` on everything in it without + * actually unmounting - and `reveal()` brings the same instances back with + * `componentDidMount`. + */ +const createSuspender = () => { + let resolveSuspender!: () => void; + let hasResolved = false; + const suspenderPromise = new Promise((resolve) => { + resolveSuspender = () => { + hasResolved = true; + resolve(); + }; + }); + + return { + Suspender: () => { + if (!hasResolved) { + throw suspenderPromise; + } + return null; + }, + reveal: async () => { + await act(async () => { + resolveSuspender(); + await suspenderPromise; + }); + }, + }; +}; + +/** + * The ids of `#teleport-destination`'s children, in document order. Document + * order is what core's `getPresentedOverlay` reads to decide which overlay + * Escape, hardware back and the focus trap act on, so a restore that changes + * it changes which overlay the user is talking to. + */ +const teleportedOrder = () => + Array.from(document.getElementById('teleport-destination')?.children ?? []).map((el) => el.id); + +/** + * Render `children` inside a Suspense boundary alongside a sibling that can be + * made to suspend on demand. `hide()` suspends that sibling, which is what the + * reported bug hits: the overlay itself renders fine, something else in the + * boundary does not, and React hides the whole boundary - running + * `componentWillUnmount` on the overlay wrapper without unmounting it. + */ +const renderWithBoundary = (children: React.ReactNode) => { + const { Suspender, reveal } = createSuspender(); + + let suspend!: () => void; + const Boundary = () => { + const [isSuspended, setIsSuspended] = React.useState(false); + suspend = () => setIsSuspended(true); + + return ( + loading}> + {children} + {isSuspended ? : null} + + ); + }; + + const result = render(); + + return { + ...result, + hide: () => + act(() => { + suspend(); + }), + reveal, + }; }; afterEach(() => { @@ -153,3 +236,210 @@ describe('createInlineOverlayComponent: unmount cleanup', () => { expect(document.querySelector('ion-popover')).toBeNull(); }); }); + +describe('createInlineOverlayComponent: hidden subtree restore', () => { + it('restores a relocated nested overlay when a Suspense boundary hides and reveals it', async () => { + /** + * React runs `componentWillUnmount` when it *hides* a subtree as well as + * when it destroys one: a Suspense boundary falling back after mount runs + * it, then runs `componentDidMount` again on the same instance when the + * boundary reveals its content. A host removed while hidden has to come + * back, since React only re-inserts nodes it removed itself. Otherwise an + * overlay that was mid-`present()` is gone for good, with no dismiss + * lifecycle ever firing. + */ + const { hide, reveal } = renderWithBoundary( + + + + ); + + const popover = document.body.querySelector('ion-popover') as HTMLElement; + + // CoreDelegate teleports the host out of its `