diff --git a/.changeset/wild-buttons-repeat.md b/.changeset/wild-buttons-repeat.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/wild-buttons-repeat.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/hooks/use-animations-finished.test.ts b/packages/headless/src/hooks/use-animations-finished.test.ts index c2c891bb084..e150bdaabfd 100644 --- a/packages/headless/src/hooks/use-animations-finished.test.ts +++ b/packages/headless/src/hooks/use-animations-finished.test.ts @@ -142,6 +142,33 @@ describe('useAnimationsFinished', () => { expect(callback).toHaveBeenCalledTimes(1); }); + it('returns a cancel function that aborts the pending wait', async () => { + let resolveAnim!: () => void; + const animPromise = new Promise(r => { + resolveAnim = r; + }); + const el = createMockElement([{ finished: animPromise }]); + const ref = { current: el } as RefObject; + + const { result } = renderHook(() => useAnimationsFinished(ref, false)); + + const callback = vi.fn(); + let cancel!: () => void; + act(() => { + cancel = result.current(callback); + }); + + act(() => cancel()); + + el.getAnimations = vi.fn(() => [] as unknown as Animation[]); + await act(async () => { + resolveAnim(); + await new Promise(r => setTimeout(r, 0)); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + it('cleans up on unmount', () => { let resolveAnim!: () => void; const animPromise = new Promise(r => { diff --git a/packages/headless/src/hooks/use-animations-finished.ts b/packages/headless/src/hooks/use-animations-finished.ts index 3ff4083e1b4..517b975f5f9 100644 --- a/packages/headless/src/hooks/use-animations-finished.ts +++ b/packages/headless/src/hooks/use-animations-finished.ts @@ -15,7 +15,8 @@ import { flushSync } from 'react-dom'; * an empty array before the enter transition has been registered. * * Each call aborts any pending wait from a previous call, so rapid open/close - * toggles don't leak stale callbacks. + * toggles don't leak stale callbacks, and returns a cancel function for callers + * that need to abandon a wait before it resolves. */ export function useAnimationsFinished(ref: RefObject, open: boolean) { const abortRef = useRef(null); @@ -34,10 +35,11 @@ export function useAnimationsFinished(ref: RefObject, open: const controller = new AbortController(); abortRef.current = controller; const { signal } = controller; + const cancel = () => controller.abort(); if (!element || typeof element.getAnimations !== 'function') { callback(); - return; + return cancel; } const runCheck = () => { @@ -87,10 +89,11 @@ export function useAnimationsFinished(ref: RefObject, open: attributeFilter: ['data-starting-style'], }); signal.addEventListener('abort', () => observer.disconnect()); - return; + return cancel; } runCheck(); + return cancel; }, [ref, open], ); diff --git a/packages/headless/src/hooks/use-transition.test.ts b/packages/headless/src/hooks/use-transition.test.ts index 4d507ec7e7d..a1fbe7e9f69 100644 --- a/packages/headless/src/hooks/use-transition.test.ts +++ b/packages/headless/src/hooks/use-transition.test.ts @@ -200,4 +200,28 @@ describe('useTransition', () => { await new Promise(r => setTimeout(r, 0)); }); }); + + it('does not unmount when the interrupted exit settles', async () => { + const { ref, el, resolveAnim } = createAnimatingRef(); + const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref }), { + initialProps: { open: true }, + }); + act(() => flushRaf()); + + // Close, then reopen before the exit animation finishes. + rerender({ open: false }); + rerender({ open: true }); + act(() => flushRaf()); + + // The exit's pending unmount must have been abandoned, so settling the + // animation leaves the element mounted rather than restarting its entrance. + el.getAnimations = vi.fn(() => [] as unknown as Animation[]); + await act(async () => { + resolveAnim(); + await new Promise(r => setTimeout(r, 0)); + }); + + expect(result.current.mounted).toBe(true); + expect(result.current.transitionProps).toEqual({ 'data-open': '' }); + }); }); diff --git a/packages/headless/src/hooks/use-transition.ts b/packages/headless/src/hooks/use-transition.ts index 783edaff9b7..0641f6e9ff5 100644 --- a/packages/headless/src/hooks/use-transition.ts +++ b/packages/headless/src/hooks/use-transition.ts @@ -46,7 +46,10 @@ export function useTransition({ open, ref }: UseTransitionOptions): UseTransitio if (transitionStatus !== 'ending') { return; } - runOnAnimationsFinished(() => { + // Cancelling on cleanup is what makes an exit interruptible: reopening + // mid-exit must abandon the pending unmount, not unmount once the + // retargeted transition settles. + return runOnAnimationsFinished(() => { setMounted(false); }); }, [transitionStatus, runOnAnimationsFinished, setMounted]); diff --git a/packages/headless/src/primitives/flow/flow.test.tsx b/packages/headless/src/primitives/flow/flow.test.tsx index f638c6e1c7d..331d784ab5c 100644 --- a/packages/headless/src/primitives/flow/flow.test.tsx +++ b/packages/headless/src/primitives/flow/flow.test.tsx @@ -57,6 +57,12 @@ describe('Flow', () => { globalThis.cancelAnimationFrame = originalCaf; }); + function flushRaf() { + const callbacks = [...rafCallbacks]; + rafCallbacks = []; + callbacks.forEach(callback => callback(performance.now())); + } + it('renders only the step matching the controlled value', () => { render(); @@ -147,6 +153,39 @@ describe('Flow', () => { finishAnimation(); }); + it('keeps the returning step mounted when its exit is interrupted', async () => { + let finishAnimation!: () => void; + const animationFinished = new Promise(resolve => { + finishAnimation = resolve; + }); + const { rerender } = render(); + const step = screen.getByTestId('password-step'); + step.getAnimations = vi.fn(() => [{ finished: animationFinished }] as unknown as Animation[]); + + rerender(); + expect(step).toHaveAttribute('data-ending-style'); + + rerender( + , + ); + act(() => flushRaf()); + + expect(step).toHaveAttribute('data-open'); + expect(step).not.toHaveAttribute('data-ending-style'); + + step.getAnimations = vi.fn(() => []); + await act(async () => { + finishAnimation(); + await animationFinished; + }); + + expect(screen.getByTestId('password-step')).toBe(step); + expect(step).not.toHaveAttribute('data-starting-style'); + }); + it('forwards its ref and supports a custom rendered element', () => { const ref = createRef(); @@ -195,11 +234,7 @@ describe('Flow', () => { expect(root.style.getPropertyValue('--cl-flow-step-height')).toBe('120px'); expect(root).toHaveAttribute('data-initial'); - act(() => { - const callbacks = [...rafCallbacks]; - rafCallbacks = []; - callbacks.forEach(callback => callback(performance.now())); - }); + act(() => flushRaf()); expect(root).not.toHaveAttribute('data-initial');