Skip to content
Draft
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/exit-freeze-snapshot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
59 changes: 59 additions & 0 deletions packages/headless/src/hooks/use-transition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,63 @@ describe('useTransition', () => {
expect(result.current.mounted).toBe(true);
expect(result.current.transitionProps).toEqual({ 'data-open': '' });
});

it('reports the close complete once the exit settles and the element has unmounted', async () => {
const { ref, resolveAnim } = createAnimatingRef();
const onOpenChangeComplete = vi.fn();
const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref, onOpenChangeComplete }), {
initialProps: { open: true },
});
act(() => flushRaf());
onOpenChangeComplete.mockClear();

rerender({ open: false });
expect(onOpenChangeComplete).not.toHaveBeenCalled();

await act(async () => {
resolveAnim();
await new Promise(r => setTimeout(r, 0));
});

expect(result.current.mounted).toBe(false);
expect(onOpenChangeComplete).toHaveBeenCalledTimes(1);
expect(onOpenChangeComplete).toHaveBeenCalledWith(false);
});

it('reports the open complete once the enter settles', async () => {
const { ref, resolveAnim } = createAnimatingRef();
const onOpenChangeComplete = vi.fn();
renderHook(({ open }) => useTransition({ open, ref, onOpenChangeComplete }), {
initialProps: { open: true },
});
expect(onOpenChangeComplete).not.toHaveBeenCalled();

act(() => flushRaf());
await act(async () => {
resolveAnim();
await new Promise(r => setTimeout(r, 0));
});

expect(onOpenChangeComplete).toHaveBeenCalledTimes(1);
expect(onOpenChangeComplete).toHaveBeenCalledWith(true);
});

it('does not report a close that reopening interrupted', async () => {
const { ref, resolveAnim } = createAnimatingRef();
const onOpenChangeComplete = vi.fn();
const { rerender } = renderHook(({ open }) => useTransition({ open, ref, onOpenChangeComplete }), {
initialProps: { open: true },
});
act(() => flushRaf());
onOpenChangeComplete.mockClear();

rerender({ open: false });
rerender({ open: true });
await act(async () => {
resolveAnim();
await new Promise(r => setTimeout(r, 0));
});

expect(onOpenChangeComplete).not.toHaveBeenCalledWith(false);
});
});
38 changes: 27 additions & 11 deletions packages/headless/src/hooks/use-transition.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
'use client';

import { type CSSProperties, type RefObject, useEffect, useMemo } from 'react';
import { type CSSProperties, type RefObject, useEffect, useLayoutEffect, useMemo, useRef } from 'react';

import { useAnimationsFinished } from './use-animations-finished';
import { type TransitionStatus, useTransitionStatus } from './use-transition-status';

export interface UseTransitionOptions {
open: boolean;
ref: RefObject<HTMLElement | null>;
/**
* Fires once the enter or exit animation has finished — on exit, right after the element
* unmounts. State that should reset when the element closes belongs here rather than in
* `onOpenChange`, so the reset cannot show through the exit animation.
*/
onOpenChangeComplete?: (open: boolean) => void;
}

export interface TransitionProps {
Expand Down Expand Up @@ -38,21 +44,31 @@ export interface UseTransitionReturn {
* transitions (via `[data-starting-style]` / `[data-ending-style]`) and
* CSS keyframe animations (via `[data-open]` / `[data-closed]`).
*/
export function useTransition({ open, ref }: UseTransitionOptions): UseTransitionReturn {
export function useTransition({ open, ref, onOpenChangeComplete }: UseTransitionOptions): UseTransitionReturn {
const { mounted, transitionStatus, setMounted } = useTransitionStatus(open);
const runOnAnimationsFinished = useAnimationsFinished(ref, open);

const onOpenChangeCompleteRef = useRef(onOpenChangeComplete);
useLayoutEffect(() => {
onOpenChangeCompleteRef.current = onOpenChangeComplete;
});

useEffect(() => {
if (transitionStatus !== 'ending') {
return;
// `ending` outlives a reopen by one frame (the status clears on the next rAF), and the
// element must not be scheduled to unmount in that window.
if (!open && transitionStatus === 'ending') {
// 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);
onOpenChangeCompleteRef.current?.(false);
});
}
if (open && transitionStatus === undefined) {
return runOnAnimationsFinished(() => onOpenChangeCompleteRef.current?.(true));
}
// 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]);
}, [open, transitionStatus, runOnAnimationsFinished, setMounted]);

const transitionProps = useMemo<TransitionProps>(() => {
const props: TransitionProps = {};
Expand Down
32 changes: 18 additions & 14 deletions packages/headless/src/primitives/dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,17 +148,18 @@ the close was pointer-driven, where focus is left where the pointer put it (see

### `Dialog.Root`

| Prop | Type | Default | Description |
| -------------- | ----------------------------------------------------------- | ---------- | --------------------------------------------------------------------- |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean, details: DialogOpenChangeDetails) => void` | — | Called when open state changes; `details` names the trigger behind it |
| `modal` | `boolean` | `true` | Traps focus and blocks page interaction |
| `role` | `'dialog' \| 'alertdialog'` | `'dialog'` | The popup's ARIA role |
| `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog |
| `handle` | `DialogHandle` | — | Connects detached triggers (see `Dialog.createHandle()`) |
| `triggerId` | `string \| null` | — | Controls which trigger the open is attributed to |
| `children` | `ReactNode \| ({ payload }) => ReactNode` | — | Content, or a render function of the active `payload` |
| Prop | Type | Default | Description |
| ---------------------- | ----------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------- |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean, details: DialogOpenChangeDetails) => void` | — | Called when open state changes; `details` names the trigger behind it |
| `onOpenChangeComplete` | `(open: boolean) => void` | — | Called once the open or close animation has finished; reset what the dialog showed here |
| `modal` | `boolean` | `true` | Traps focus and blocks page interaction |
| `role` | `'dialog' \| 'alertdialog'` | `'dialog'` | The popup's ARIA role |
| `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog |
| `handle` | `DialogHandle` | — | Connects detached triggers (see `Dialog.createHandle()`) |
| `triggerId` | `string \| null` | — | Controls which trigger the open is attributed to |
| `children` | `ReactNode \| ({ payload }) => ReactNode` | — | Content, or a render function of the active `payload` |

#### `closedBy`

Expand Down Expand Up @@ -218,9 +219,12 @@ root, and `initialFocus={false}` on the popup so mounting does not steal focus.

`DialogFocusTarget` is `boolean | RefObject | (interactionType) => boolean | void | HTMLElement | null`.

The popup's children are held at their last committed frame while it exits (`Freeze`), so state
that resets on close — a machine returning to its initial state — does not flash through the
fade. The popup element itself stays live for `data-closed` / `data-ending-style`.
The popup's children are held while it exits (`Freeze` from `@clerk/headless/utils`): the popup
keeps rendering the element it was handed on the last open render, so state a parent bakes into
that JSX — a machine returning to its initial state, a form clearing — does not flash through the
fade. Components inside keep rendering, so a value they read through a hook (context, a store)
still moves; wrap that read in `useFrozenValue`, or reset it in `onOpenChangeComplete`, which
fires after the exit. The popup element itself stays live for `data-closed` / `data-ending-style`.

### `Dialog.Backdrop`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`

Expand Down
7 changes: 7 additions & 0 deletions packages/headless/src/primitives/dialog/dialog-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ export interface DialogProps<Payload = unknown> {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean, details: DialogOpenChangeDetails) => void;
/**
* Fires once the open or close animation has finished. Reset what the dialog showed here
* rather than in `onOpenChange`: the popup's contents hold their last frame while closing, and
* this is the first moment a reset cannot show through the animation.
*/
onOpenChangeComplete?: (open: boolean) => void;
/** When true, the dialog traps focus and blocks interaction with the rest of the page. Default: true */
modal?: boolean;
/** Which gestures dismiss the dialog. Default: `any` */
Expand Down Expand Up @@ -207,6 +213,7 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
const { mounted, transitionProps } = useTransition({
open,
ref: popupRef,
onOpenChangeComplete: props.onOpenChangeComplete,
});

// Below `useTransition` because it needs `mounted`: what a stacked child has to key off is
Expand Down
11 changes: 5 additions & 6 deletions packages/headless/src/primitives/flow/flow-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { inertProps } from '@clerk/shared/inert';
import React, { useLayoutEffect, useRef } from 'react';

import { useTransition } from '../../hooks/use-transition';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { type ComponentProps, Freeze, mergeProps, useRender } from '../../utils';
import { useFlowContext } from './flow-context';

export interface FlowStepProps extends ComponentProps<'div'> {
Expand All @@ -16,12 +16,9 @@ export const FlowStep = React.forwardRef<HTMLDivElement, FlowStepProps>(function
const { value, direction, registerActiveStep, unregisterActiveStep } = useFlowContext();
const open = ids.includes(value);
const stepRef = useRef<HTMLDivElement | null>(null);
const activeChildrenRef = useRef(children);
const hasBeenClosed = useRef(false);

if (open) {
activeChildrenRef.current = children;
} else {
if (!open) {
hasBeenClosed.current = true;
}

Expand Down Expand Up @@ -49,7 +46,9 @@ export const FlowStep = React.forwardRef<HTMLDivElement, FlowStepProps>(function
...effectiveTransitionProps.style,
['--cl-flow-transition-direction' as string]: String(direction),
},
children: open ? children : activeChildrenRef.current,
// An outgoing step keeps showing what it showed while active, not what its controller has
// since moved on to.
children: <Freeze frozen={!open}>{children}</Freeze>,
};

return useRender({
Expand Down
21 changes: 11 additions & 10 deletions packages/headless/src/primitives/popover/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,16 @@ const [open, setOpen] = useState(false);

### `Popover.Root`

| Prop | Type | Default | Description |
| -------------- | ------------------------- | ---------- | ----------------------------------- |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `placement` | `Placement` | `"bottom"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |
| `alignOffset` | `number` | `0` | Nudge along the alignment axis (px) |
| `modal` | `boolean` | `false` | Traps focus within the popover |
| Prop | Type | Default | Description |
| ---------------------- | ------------------------- | ---------- | -------------------------------------------------------------------------------------- |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `onOpenChangeComplete` | `(open: boolean) => void` | — | Called once the open or close animation has finished; reset what the popup showed here |
| `placement` | `Placement` | `"bottom"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |
| `alignOffset` | `number` | `0` | Nudge along the alignment axis (px) |
| `modal` | `boolean` | `false` | Traps focus within the popover |

### `Popover.Trigger`, `Popover.Positioner`, `Popover.Popup`, `Popover.Title`, `Popover.Description`, `Popover.Close`

Expand Down Expand Up @@ -105,7 +106,7 @@ Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. The popu
- **Title and Description are optional but recommended.** They wire `aria-labelledby` and `aria-describedby` to the positioner. If omitted, those attributes are simply absent.
- **Non-modal by default.** Unlike Dialog, the page remains interactive behind the popover. Set `modal={true}` for a stricter focus trap.
- **Nested popovers are supported.** The `FloatingTree` pattern handles nesting automatically.
- **Popup contents freeze while closing.** The popup outlives `open` by its exit animation, so its children are wrapped in `Freeze` (`@clerk/headless/utils`) and hold their last frame instead of re-rendering under the animation. The popup element itself keeps updating, so `data-closed` / `data-ending-style` still land. Freezing wraps the children in a `display: contents` element and detaches refs inside them until the popup reopens.
- **Popup contents hold while closing.** The popup outlives `open` by its exit animation, so its children are wrapped in `Freeze` (`@clerk/headless/utils`): the popup keeps rendering the element it was handed on the last open render, so props, conditionals, and callbacks baked into that JSX hold instead of changing under the animation. Components inside keep rendering, so a value read through a hook (context, a store) still moves — wrap that read in `useFrozenValue` to hold it too, or reset it in `onOpenChangeComplete`, which fires after the exit. The popup element itself keeps updating, so `data-closed` / `data-ending-style` still land.

## ARIA

Expand Down
7 changes: 7 additions & 0 deletions packages/headless/src/primitives/popover/popover-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export interface PopoverProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/**
* Fires once the open or close animation has finished. Reset what the popup showed here
* rather than in `onOpenChange`: the contents hold their last frame while closing, and this is
* the first moment a reset cannot show through the animation.
*/
onOpenChangeComplete?: (open: boolean) => void;
placement?: Placement;
sideOffset?: number;
alignOffset?: number;
Expand Down Expand Up @@ -97,6 +103,7 @@ function PopoverInner(props: PopoverProps) {
const { mounted, transitionProps } = useTransition({
open,
ref: popupRef,
onOpenChangeComplete: props.onOpenChangeComplete,
});

const click = useClick(floatingContext);
Expand Down
Loading
Loading