Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/wild-buttons-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
27 changes: 27 additions & 0 deletions packages/headless/src/hooks/use-animations-finished.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(r => {
resolveAnim = r;
});
const el = createMockElement([{ finished: animPromise }]);
const ref = { current: el } as RefObject<HTMLElement | null>;

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<void>(r => {
Expand Down
9 changes: 6 additions & 3 deletions packages/headless/src/hooks/use-animations-finished.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement | null>, open: boolean) {
const abortRef = useRef<AbortController | null>(null);
Expand All @@ -34,10 +35,11 @@ export function useAnimationsFinished(ref: RefObject<HTMLElement | null>, 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 = () => {
Expand Down Expand Up @@ -87,10 +89,11 @@ export function useAnimationsFinished(ref: RefObject<HTMLElement | null>, open:
attributeFilter: ['data-starting-style'],
});
signal.addEventListener('abort', () => observer.disconnect());
return;
return cancel;
}

runCheck();
return cancel;
},
[ref, open],
);
Expand Down
24 changes: 24 additions & 0 deletions packages/headless/src/hooks/use-transition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '' });
});
});
5 changes: 4 additions & 1 deletion packages/headless/src/hooks/use-transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
45 changes: 40 additions & 5 deletions packages/headless/src/primitives/flow/flow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TestFlow value='password' />);

Expand Down Expand Up @@ -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<void>(resolve => {
finishAnimation = resolve;
});
const { rerender } = render(<TestFlow value='password' />);
const step = screen.getByTestId('password-step');
step.getAnimations = vi.fn(() => [{ finished: animationFinished }] as unknown as Animation[]);

rerender(<TestFlow value='otp' />);
expect(step).toHaveAttribute('data-ending-style');

rerender(
<TestFlow
value='password'
direction={-1}
/>,
);
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<HTMLDivElement>();

Expand Down Expand Up @@ -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');

Expand Down
Loading