From fd243c45da2eb0b70e339fd5a1d8794a30272dc2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 27 Oct 2026 18:11:17 -0600 Subject: [PATCH 1/2] fix(mosaic): show connected account verification errors without animating on load --- ...cted-accounts-verification-error-no-animate.md | 2 ++ .../mosaic/src/components/section/section.tsx | 2 +- .../mosaic/src/primitives/hooks/use-transition.ts | 15 +++++++++++---- 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 .changeset/connected-accounts-verification-error-no-animate.md diff --git a/.changeset/connected-accounts-verification-error-no-animate.md b/.changeset/connected-accounts-verification-error-no-animate.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/connected-accounts-verification-error-no-animate.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index e3435143c62..04a6b3b4755 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -308,7 +308,7 @@ const SectionError = React.forwardRef(f const element = React.useRef(null); const [messageElement, setMessageElement] = React.useState(null); const height = useMessageHeight(messageElement); - const { mounted, transitionProps } = useTransition({ open, ref: element }); + const { mounted, transitionProps } = useTransition({ open, ref: element, animateOnMount: false }); const message = useHeldMessage(children, open); const messageProps = stylex.props(reset.base, feedbackStyles.message, feedbackStyles.error); diff --git a/packages/mosaic/src/primitives/hooks/use-transition.ts b/packages/mosaic/src/primitives/hooks/use-transition.ts index 0641f6e9ff5..322691c9cc3 100644 --- a/packages/mosaic/src/primitives/hooks/use-transition.ts +++ b/packages/mosaic/src/primitives/hooks/use-transition.ts @@ -1,6 +1,6 @@ 'use client'; -import { type CSSProperties, type RefObject, useEffect, useMemo } from 'react'; +import { type CSSProperties, type RefObject, useEffect, useMemo, useState } from 'react'; import { useAnimationsFinished } from './use-animations-finished'; import { type TransitionStatus, useTransitionStatus } from './use-transition-status'; @@ -8,6 +8,7 @@ import { type TransitionStatus, useTransitionStatus } from './use-transition-sta export interface UseTransitionOptions { open: boolean; ref: RefObject; + animateOnMount?: boolean; } export interface TransitionProps { @@ -38,8 +39,12 @@ 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, animateOnMount = true }: UseTransitionOptions): UseTransitionReturn { const { mounted, transitionStatus, setMounted } = useTransitionStatus(open); + const [skipEnter, setSkipEnter] = useState(open && !animateOnMount); + if (skipEnter && transitionStatus !== 'starting') { + setSkipEnter(false); + } const runOnAnimationsFinished = useAnimationsFinished(ref, open); useEffect(() => { @@ -62,13 +67,15 @@ export function useTransition({ open, ref }: UseTransitionOptions): UseTransitio props['data-closed'] = ''; } if (transitionStatus === 'starting') { - props['data-starting-style'] = ''; + if (!skipEnter) { + props['data-starting-style'] = ''; + } props.style = { transition: 'none' }; } else if (transitionStatus === 'ending') { props['data-ending-style'] = ''; } return props; - }, [open, mounted, transitionStatus]); + }, [open, mounted, transitionStatus, skipEnter]); return { mounted, transitionStatus, transitionProps }; } From aabf7a1d7508d376dbd98a24e27de7b12a64b8f2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 10:09:48 -0600 Subject: [PATCH 2/2] fix(mosaic): default initial transitions to static content --- packages/mosaic/AGENTS.md | 2 +- packages/mosaic/docs/headless.md | 10 +- packages/mosaic/docs/motion.md | 6 + .../initial-transition.browser.test.tsx | 121 ++++++++++++++++++ .../components/action-bar/action-bar.test.tsx | 2 + .../src/components/action-bar/action-bar.tsx | 2 +- .../mosaic/src/components/card/card.test.tsx | 4 +- .../components/field/__tests__/field.test.tsx | 4 +- .../mosaic/src/components/reveal/reveal.tsx | 10 +- .../mosaic/src/components/section/section.tsx | 2 +- .../primitives/accordion/accordion-panel.tsx | 20 +-- .../collapsible/collapsible-panel.tsx | 17 +-- .../mosaic/src/primitives/flow/flow-step.tsx | 11 +- .../__tests__/use-transition-status.test.ts | 35 +++-- .../hooks/__tests__/use-transition.test.ts | 65 +++++++++- .../primitives/hooks/use-transition-status.ts | 13 +- .../src/primitives/hooks/use-transition.ts | 19 +-- .../mosaic/src/primitives/tabs/tabs-panel.tsx | 16 +-- .../primitives/tag-input/tag-input-tag.tsx | 2 +- .../primitives/tag-input/tag-input.test.tsx | 6 + .../src/primitives/toast/toast-root.tsx | 2 +- .../src/primitives/toast/toast.test.tsx | 8 ++ packages/mosaic/src/styles/feedback.styles.ts | 4 +- packages/mosaic/src/utils/feedback.tsx | 2 +- 24 files changed, 272 insertions(+), 111 deletions(-) create mode 100644 packages/mosaic/src/components/__tests__/initial-transition.browser.test.tsx diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 0ef6ef8a5da..742f7698028 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -50,7 +50,7 @@ Most problems a feature hits already have a shared answer. Find the row for your | A value turned into kebab case, e.g. for a `data-*` attribute | `kebabCase` (`src/utils/kebab-case.ts`) | | Current time for display labels | `useNow` (`src/hooks/`) | | Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) | -| An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` | +| An inline message row; height is undefined until measured | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` | | Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`src/queries/use-user-sessions-query.ts`) | | An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` | | Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) | diff --git a/packages/mosaic/docs/headless.md b/packages/mosaic/docs/headless.md index 1566e746658..3a4e5685209 100644 --- a/packages/mosaic/docs/headless.md +++ b/packages/mosaic/docs/headless.md @@ -75,10 +75,12 @@ All enter/exit timing lives in **CSS**; the primitives only toggle `data-*` attributes and drive unmount off the Web Animations API. Root spreads `transitionProps` (from `useTransition`) onto the Popup; the lifecycle: -- **Open** → synchronously `mounted=true`, status `'starting'`: first committed - frame carries `data-open` + `data-starting-style` + inline `transition: none` - (snapshot frame). One rAF later `data-starting-style` clears → CSS transitions - fire toward the resting style. +- **Initially open** → `mounted=true`, with `data-open` and no starting style by + default. Set `animateOnMount: true` to start an entrance on mount. +- **Open after mount** → synchronously `mounted=true`, status `'starting'`: first + committed frame carries `data-open` + `data-starting-style` + inline + `transition: none` (snapshot frame). One rAF later `data-starting-style` + clears → CSS transitions fire toward the resting style. - **Close** → status `'ending'`: `data-closed` + `data-ending-style` applied. After all animations on the element finish (`useAnimationsFinished`), `mounted` flips false and the element unmounts. diff --git a/packages/mosaic/docs/motion.md b/packages/mosaic/docs/motion.md index 27e9f966960..01607c0f186 100644 --- a/packages/mosaic/docs/motion.md +++ b/packages/mosaic/docs/motion.md @@ -231,6 +231,12 @@ over: the wrapper is its own clip layer (no padding, `align-content: end`), the message leaves the flow pinned to the **bottom** (`inset: auto 0 0`) so it rides the moving edge, and the mask is the same static top fade. +`useMessageHeight` returns `undefined` until the first measurement. +`feedbackHeight.measured` leaves the height variable unset in that state so an +initially visible row uses natural layout instead of transitioning from a false +zero height. Once measured, the hook retains the last numeric height, including +zero, while the content exits. + The spacing above a message is the parent's gap, which a closed row must not cost. Cancel the parent's gap on the wrapper and put it back inside the clip as the message's margin: one constant that is also the fade's length (`GAP` in diff --git a/packages/mosaic/src/components/__tests__/initial-transition.browser.test.tsx b/packages/mosaic/src/components/__tests__/initial-transition.browser.test.tsx new file mode 100644 index 00000000000..89d7070ebce --- /dev/null +++ b/packages/mosaic/src/components/__tests__/initial-transition.browser.test.tsx @@ -0,0 +1,121 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Card } from '../card'; +import { Field } from '../field'; +import { Section } from '../section'; + +afterEach(() => cleanup()); + +const cases = [ + { + name: 'section error', + property: 'height', + renderMessage: (message: string) => {message}, + }, + { + name: 'card banner', + property: 'grid-template-rows', + renderMessage: (message: string) => {message}, + }, +]; + +describe.each(cases)('$name motion', ({ property, renderMessage }) => { + it('shows an initial message without animating its layout', async () => { + render(renderMessage('Something went wrong.')); + const message = screen.getByRole('alert'); + + expect(getComputedStyle(message).transitionProperty).toBe(property); + expect(getComputedStyle(message).transitionDuration).not.toBe('0s'); + expect(message.getBoundingClientRect().height).toBeGreaterThan(0); + expect( + message + .getAnimations({ subtree: true }) + .map(animation => + animation instanceof CSSTransition ? animation.transitionProperty : animation.constructor.name, + ), + ).toEqual([]); + + await new Promise(resolve => requestAnimationFrame(() => resolve())); + await new Promise(resolve => requestAnimationFrame(() => resolve())); + + expect(message.getAnimations({ subtree: true })).toHaveLength(0); + }); + + it('animates a message that arrives after mount', async () => { + const { rerender } = render(renderMessage('')); + + await new Promise(resolve => requestAnimationFrame(() => resolve())); + rerender(renderMessage('Something went wrong.')); + const message = screen.getByRole('alert'); + expect(message.getBoundingClientRect().height).toBe(0); + + await new Promise(resolve => requestAnimationFrame(() => resolve())); + await new Promise(resolve => requestAnimationFrame(() => resolve())); + + expect( + message + .getAnimations() + .some(animation => animation instanceof CSSTransition && animation.transitionProperty === property), + ).toBe(true); + }); +}); + +it('animates field feedback opening, resizing, closing, and reopening', async () => { + const renderMessage = (text: string) => ( +
+ + {text} + +
+ ); + const { rerender } = render(renderMessage('')); + const message = screen.getByRole('status'); + expect(message.getBoundingClientRect().height).toBe(0); + await new Promise(resolve => requestAnimationFrame(() => resolve())); + + rerender(renderMessage('Invalid email.')); + await waitFor(() => + expect( + message + .getAnimations() + .some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'), + ).toBe(true), + ); + await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0)); + const shortHeight = message.getBoundingClientRect().height; + expect(shortHeight).toBeGreaterThan(0); + + rerender(renderMessage('This email address is invalid. Enter a complete email address before continuing.')); + await waitFor(() => + expect( + message + .getAnimations() + .some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'), + ).toBe(true), + ); + await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0)); + expect(message.getBoundingClientRect().height).toBeGreaterThan(shortHeight); + + rerender(renderMessage('')); + await waitFor(() => + expect( + message + .getAnimations() + .some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'), + ).toBe(true), + ); + await waitFor(() => expect(message.getBoundingClientRect().height).toBe(0)); + await waitFor(() => expect(screen.queryByText(/This email address is invalid/)).toBeNull()); + + rerender(renderMessage('Invalid email.')); + await waitFor(() => + expect( + message + .getAnimations() + .some(animation => animation instanceof CSSTransition && animation.transitionProperty === 'height'), + ).toBe(true), + ); + await waitFor(() => expect(message.getAnimations({ subtree: true })).toHaveLength(0)); + expect(message.getBoundingClientRect().height).toBe(shortHeight); +}); diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx index caed024cd9f..d85a0955249 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx @@ -71,6 +71,8 @@ describe('Mosaic ActionBar', () => { const bar = screen.getByRole('toolbar', { name: 'Bulk actions' }); expect(bar).toHaveClass('cl-action-bar'); expect(bar).toHaveAttribute('data-open', 'true'); + expect(bar).not.toHaveAttribute('data-starting-style'); + expect(bar.style.transition).toBe(''); expect(bar).toHaveAttribute('aria-controls', 'members'); expect(bar).toHaveAttribute('aria-orientation', 'horizontal'); expect(bar).toHaveAccessibleDescription('3 selected'); diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx index 7198b4fd0ab..2d97a731238 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -129,7 +129,7 @@ const Root = React.forwardRef(function Actio refs.setReference(anchor.current); }); - const { mounted, transitionStatus, setMounted } = useTransitionStatus(open); + const { mounted, transitionStatus, setMounted } = useTransitionStatus({ open }); const runOnAnimationsFinished = useAnimationsFinished(barRef, open); const focusWithinRef = React.useRef(false); diff --git a/packages/mosaic/src/components/card/card.test.tsx b/packages/mosaic/src/components/card/card.test.tsx index e218d9f8d6d..89739b7814d 100644 --- a/packages/mosaic/src/components/card/card.test.tsx +++ b/packages/mosaic/src/components/card/card.test.tsx @@ -354,12 +354,12 @@ describe('Mosaic Card', () => { ); const slot = screen.getByRole('alert'); expect(slot).toHaveAttribute('data-open'); - expect(slot).toHaveAttribute('data-starting-style'); + expect(slot).not.toHaveAttribute('data-starting-style'); expect(slot.previousElementSibling).toHaveClass('cl-card-header'); expect(slot.nextElementSibling).toHaveClass('cl-card-content'); const banner = slot.querySelector('.cl-banner-root'); expect(banner).toHaveAttribute('data-color', 'negative'); - expect(banner).toHaveAttribute('data-starting-style'); + expect(banner).not.toHaveAttribute('data-starting-style'); expect(screen.getByText('Something went wrong.')).toHaveClass('cl-banner-label'); await act(async () => { diff --git a/packages/mosaic/src/components/field/__tests__/field.test.tsx b/packages/mosaic/src/components/field/__tests__/field.test.tsx index b75f8e2bbcb..23660d012c5 100644 --- a/packages/mosaic/src/components/field/__tests__/field.test.tsx +++ b/packages/mosaic/src/components/field/__tests__/field.test.tsx @@ -490,7 +490,7 @@ describe('Mosaic Field', () => { expect(error?.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); }); - it('mounts through the headless enter transition and animates the message height', () => { + it('animates the registered message height while initially present feedback stays at rest', () => { const probe = stylex.create({ message: { transitionProperty: { @@ -525,7 +525,7 @@ describe('Mosaic Field', () => { expect(atoms(probe.feedback)).toHaveLength(3); expect(error).toHaveClass(...atoms(probe.feedback)); expect(error).toHaveAttribute('data-open'); - expect(error).toHaveAttribute('data-starting-style'); + expect(error).not.toHaveAttribute('data-starting-style'); }); it('renders nothing without a message', () => { diff --git a/packages/mosaic/src/components/reveal/reveal.tsx b/packages/mosaic/src/components/reveal/reveal.tsx index 8f867e23599..c504f1e97b1 100644 --- a/packages/mosaic/src/components/reveal/reveal.tsx +++ b/packages/mosaic/src/components/reveal/reveal.tsx @@ -19,14 +19,6 @@ export const Reveal = React.forwardRef(function Mos ) { const element = React.useRef(null); const { mounted, transitionProps } = useTransition({ open, ref: element }); - const [hasBeenClosed, setHasBeenClosed] = React.useState(!open); - if (!open && !hasBeenClosed) { - setHasBeenClosed(true); - } - const effectiveTransitionProps = hasBeenClosed - ? transitionProps - : { ...transitionProps, 'data-starting-style': undefined, style: undefined }; - return useRender({ defaultTagName: 'div', ref: [ref, element], @@ -34,7 +26,7 @@ export const Reveal = React.forwardRef(function Mos ...mergeStyleProps( themeProps('reveal'), stylex.props(reset.base, reveal.root, xstyle), - { ...effectiveTransitionProps }, + { ...transitionProps }, rest, ), children: ( diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index 04a6b3b4755..e3435143c62 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -308,7 +308,7 @@ const SectionError = React.forwardRef(f const element = React.useRef(null); const [messageElement, setMessageElement] = React.useState(null); const height = useMessageHeight(messageElement); - const { mounted, transitionProps } = useTransition({ open, ref: element, animateOnMount: false }); + const { mounted, transitionProps } = useTransition({ open, ref: element }); const message = useHeldMessage(children, open); const messageProps = stylex.props(reset.base, feedbackStyles.message, feedbackStyles.error); diff --git a/packages/mosaic/src/primitives/accordion/accordion-panel.tsx b/packages/mosaic/src/primitives/accordion/accordion-panel.tsx index 4b262f5e616..d1a38ae58c3 100644 --- a/packages/mosaic/src/primitives/accordion/accordion-panel.tsx +++ b/packages/mosaic/src/primitives/accordion/accordion-panel.tsx @@ -18,13 +18,6 @@ export const AccordionPanel = React.forwardRef(null); const [height, setHeight] = useState(undefined); - // Track whether open has ever transitioned from true→false. - // Until that happens, skip enter animations (prevents animate-on-load). - const hasBeenClosed = useRef(false); - if (!open) { - hasBeenClosed.current = true; - } - const { mounted, transitionStatus, transitionProps } = useTransition({ open, ref: panelRef, @@ -70,23 +63,14 @@ export const AccordionPanel = React.forwardRef = { id: panelId, role: 'region' as const, 'aria-labelledby': triggerId, - ...effectiveTransitionProps, + ...transitionProps, style: { '--cl-accordion-panel-height': height != null ? `${height}px` : undefined, - ...effectiveTransitionProps.style, + ...transitionProps.style, }, }; diff --git a/packages/mosaic/src/primitives/collapsible/collapsible-panel.tsx b/packages/mosaic/src/primitives/collapsible/collapsible-panel.tsx index 446b90a73be..279411930ac 100644 --- a/packages/mosaic/src/primitives/collapsible/collapsible-panel.tsx +++ b/packages/mosaic/src/primitives/collapsible/collapsible-panel.tsx @@ -19,11 +19,6 @@ export const CollapsiblePanel = React.forwardRef(undefined); const [width, setWidth] = useState(undefined); - const hasBeenClosed = useRef(false); - if (!open) { - hasBeenClosed.current = true; - } - const { mounted, transitionStatus, transitionProps } = useTransition({ open, ref: panelRef, @@ -77,23 +72,15 @@ export const CollapsiblePanel = React.forwardRef = { id: panelId, role: 'region' as const, 'aria-labelledby': triggerId, - ...effectiveTransitionProps, + ...transitionProps, style: { '--collapsible-panel-height': height != null ? `${height}px` : undefined, '--collapsible-panel-width': width != null ? `${width}px` : undefined, - ...effectiveTransitionProps.style, + ...transitionProps.style, }, }; diff --git a/packages/mosaic/src/primitives/flow/flow-step.tsx b/packages/mosaic/src/primitives/flow/flow-step.tsx index 5f03b208574..66d6a4eb4e1 100644 --- a/packages/mosaic/src/primitives/flow/flow-step.tsx +++ b/packages/mosaic/src/primitives/flow/flow-step.tsx @@ -40,14 +40,11 @@ export const FlowStep = React.forwardRef(function const open = ids.includes(value); const stepRef = useRef(null); const activeChildrenRef = useRef(children); - const hasBeenClosed = useRef(false); const focusTargetsRef = useRef(new Set()); const wasOpenRef = useRef(open); if (open) { activeChildrenRef.current = children; - } else { - hasBeenClosed.current = true; } const { mounted, transitionProps } = useTransition({ open, ref: stepRef }); @@ -97,16 +94,12 @@ export const FlowStep = React.forwardRef(function [open, parentActive, registerFocusTarget, unregisterFocusTarget], ); - const effectiveTransitionProps = !hasBeenClosed.current - ? { ...transitionProps, 'data-starting-style': undefined, style: undefined } - : transitionProps; - const defaultProps = { - ...effectiveTransitionProps, + ...transitionProps, ...inertProps(!open), 'aria-hidden': !open ? true : undefined, style: { - ...effectiveTransitionProps.style, + ...transitionProps.style, '--cl-flow-transition-direction': String(direction), }, children: open ? children : activeChildrenRef.current, diff --git a/packages/mosaic/src/primitives/hooks/__tests__/use-transition-status.test.ts b/packages/mosaic/src/primitives/hooks/__tests__/use-transition-status.test.ts index c75c6c0eb83..c63fc588be4 100644 --- a/packages/mosaic/src/primitives/hooks/__tests__/use-transition-status.test.ts +++ b/packages/mosaic/src/primitives/hooks/__tests__/use-transition-status.test.ts @@ -33,19 +33,19 @@ describe('useTransitionStatus', () => { } it('returns mounted=false and status=undefined when open=false', () => { - const { result } = renderHook(() => useTransitionStatus(false)); + const { result } = renderHook(() => useTransitionStatus({ open: false })); expect(result.current.mounted).toBe(false); expect(result.current.transitionStatus).toBeUndefined(); }); - it("returns mounted=true and status='starting' when open=true", () => { - const { result } = renderHook(() => useTransitionStatus(true)); + it('mounts initially open content without an entrance by default', () => { + const { result } = renderHook(() => useTransitionStatus({ open: true })); expect(result.current.mounted).toBe(true); - expect(result.current.transitionStatus).toBe('starting'); + expect(result.current.transitionStatus).toBeUndefined(); }); it('synchronously sets mounted and starting when open flips true', () => { - const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), { + const { result, rerender } = renderHook(({ open }) => useTransitionStatus({ open }), { initialProps: { open: false }, }); expect(result.current.mounted).toBe(false); @@ -58,7 +58,7 @@ describe('useTransitionStatus', () => { }); it('clears starting status after one rAF', () => { - const { result } = renderHook(() => useTransitionStatus(true)); + const { result } = renderHook(() => useTransitionStatus({ open: true, animateOnMount: true })); expect(result.current.transitionStatus).toBe('starting'); act(() => flushRaf()); @@ -67,10 +67,10 @@ describe('useTransitionStatus', () => { }); it('sets ending status synchronously when open flips false', () => { - const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), { + const { result, rerender } = renderHook(({ open }) => useTransitionStatus({ open }), { initialProps: { open: true }, }); - act(() => flushRaf()); // clear starting + act(() => flushRaf()); rerender({ open: false }); @@ -79,7 +79,7 @@ describe('useTransitionStatus', () => { }); it('stays mounted until setMounted(false) is called', () => { - const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), { + const { result, rerender } = renderHook(({ open }) => useTransitionStatus({ open }), { initialProps: { open: true }, }); act(() => flushRaf()); @@ -92,7 +92,7 @@ describe('useTransitionStatus', () => { }); it('clears transitionStatus when unmounted', () => { - const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), { + const { result, rerender } = renderHook(({ open }) => useTransitionStatus({ open }), { initialProps: { open: true }, }); act(() => flushRaf()); @@ -105,7 +105,7 @@ describe('useTransitionStatus', () => { }); it('handles rapid open→close before rAF fires', () => { - const { result, rerender } = renderHook(({ open }) => useTransitionStatus(open), { + const { result, rerender } = renderHook(({ open }) => useTransitionStatus({ open }), { initialProps: { open: false }, }); @@ -123,4 +123,17 @@ describe('useTransitionStatus', () => { act(() => flushRaf()); expect(result.current.transitionStatus).toBe('ending'); }); + + it('does not replay an entrance when the mount option changes', () => { + const { result, rerender } = renderHook( + ({ animateOnMount }) => useTransitionStatus({ open: true, animateOnMount }), + { + initialProps: { animateOnMount: false }, + }, + ); + + rerender({ animateOnMount: true }); + + expect(result.current.transitionStatus).toBeUndefined(); + }); }); diff --git a/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts b/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts index ccf3e195f23..5dc3c128f4f 100644 --- a/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts +++ b/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts @@ -61,29 +61,84 @@ describe('useTransition', () => { expect(result.current.transitionProps).toEqual({}); }); - it('returns mounted=true with starting-style props on open', () => { + it('mounts initially open content without starting style by default', () => { const ref = createElementRef(); const { result } = renderHook(() => useTransition({ open: true, ref })); expect(result.current.mounted).toBe(true); + expect(result.current.transitionStatus).toBeUndefined(); + expect(result.current.transitionProps).toEqual({ 'data-open': '' }); + }); + + it('clears opted-in starting style after first frame, keeps data-open', () => { + const ref = createElementRef(); + const { result } = renderHook(() => useTransition({ open: true, ref, animateOnMount: true })); + expect(result.current.transitionProps).toEqual({ 'data-open': '', 'data-starting-style': '', style: { transition: 'none' }, }); + + act(() => flushRaf()); + + expect(result.current.transitionProps).toEqual({ + 'data-open': '', + }); }); - it('clears starting-style after first frame, keeps data-open', () => { + it('animates a later opening and a reopen with the default mount policy', async () => { const ref = createElementRef(); - const { result } = renderHook(() => useTransition({ open: true, ref })); + const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref }), { + initialProps: { open: false }, + }); + rerender({ open: true }); + expect(result.current.transitionProps['data-starting-style']).toBe(''); act(() => flushRaf()); - expect(result.current.transitionProps).toEqual({ - 'data-open': '', + await act(async () => { + rerender({ open: false }); + await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(result.current.mounted).toBe(false); + + rerender({ open: true }); + expect(result.current.transitionProps['data-starting-style']).toBe(''); }); + it('does not replay an entrance when the mount option changes', () => { + const ref = createElementRef(); + const { result, rerender } = renderHook( + ({ animateOnMount }) => useTransition({ open: true, ref, animateOnMount }), + { initialProps: { animateOnMount: false } }, + ); + + rerender({ animateOnMount: true }); + expect(result.current.transitionProps).toEqual({ 'data-open': '' }); + }); + + it.each([undefined, false, true])( + 'animates reopen after an initially open instance closes (%s)', + async animateOnMount => { + const ref = createElementRef(); + const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref, animateOnMount }), { + initialProps: { open: true }, + }); + + expect(result.current.transitionStatus).toBe(animateOnMount ? 'starting' : undefined); + act(() => flushRaf()); + await act(async () => { + rerender({ open: false }); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(result.current.mounted).toBe(false); + + rerender({ open: true }); + expect(result.current.transitionProps['data-starting-style']).toBe(''); + }, + ); + it('sets closing props while animations are running', async () => { const { ref, el, resolveAnim } = createAnimatingRef(); const { result, rerender } = renderHook(({ open }) => useTransition({ open, ref }), { diff --git a/packages/mosaic/src/primitives/hooks/use-transition-status.ts b/packages/mosaic/src/primitives/hooks/use-transition-status.ts index 500a0c1585a..eaa73c9bcf5 100644 --- a/packages/mosaic/src/primitives/hooks/use-transition-status.ts +++ b/packages/mosaic/src/primitives/hooks/use-transition-status.ts @@ -4,13 +4,18 @@ import { useEffect, useState } from 'react'; export type TransitionStatus = 'starting' | 'ending' | undefined; +export interface UseTransitionStatusOptions { + open: boolean; + animateOnMount?: boolean; +} + /** * Core state machine for enter/exit animations. * * Tracks whether a component should be in the DOM (`mounted`) and which * animation phase it is in (`transitionStatus`). * - * - When `open` becomes true: synchronously sets `mounted=true` and + * - When `open` becomes true after mount: synchronously sets `mounted=true` and * `transitionStatus='starting'` during render so the first committed DOM * frame carries `[data-starting-style]`. One animation frame later, clears * the status so the CSS transition fires. @@ -18,9 +23,11 @@ export type TransitionStatus = 'starting' | 'ending' | undefined; * The element stays mounted until the caller explicitly calls `setMounted(false)` * (typically after all CSS animations have finished). */ -export function useTransitionStatus(open: boolean) { +export function useTransitionStatus({ open, animateOnMount = false }: UseTransitionStatusOptions) { const [mounted, setMounted] = useState(open); - const [transitionStatus, setTransitionStatus] = useState(open ? 'starting' : undefined); + const [transitionStatus, setTransitionStatus] = useState( + open && animateOnMount ? 'starting' : undefined, + ); // Synchronous render-phase state updates. Running these during render (not in // useEffect) guarantees the first committed DOM includes the right data diff --git a/packages/mosaic/src/primitives/hooks/use-transition.ts b/packages/mosaic/src/primitives/hooks/use-transition.ts index 322691c9cc3..4d5d9a5ced9 100644 --- a/packages/mosaic/src/primitives/hooks/use-transition.ts +++ b/packages/mosaic/src/primitives/hooks/use-transition.ts @@ -1,6 +1,6 @@ 'use client'; -import { type CSSProperties, type RefObject, useEffect, useMemo, useState } from 'react'; +import { type CSSProperties, type RefObject, useEffect, useMemo } from 'react'; import { useAnimationsFinished } from './use-animations-finished'; import { type TransitionStatus, useTransitionStatus } from './use-transition-status'; @@ -33,18 +33,15 @@ export interface UseTransitionReturn { * - `transitionProps`: spread onto the element. Exposes * `data-open` / `data-closed` / `data-starting-style` / * `data-ending-style` data attributes and an inline `transition: none` on - * the first mount frame. + * the first frame of an entrance. Initially open content only enters when + * `animateOnMount` is true. * * Consumers drive all animation via CSS — no durations in JS. Works with CSS * transitions (via `[data-starting-style]` / `[data-ending-style]`) and * CSS keyframe animations (via `[data-open]` / `[data-closed]`). */ -export function useTransition({ open, ref, animateOnMount = true }: UseTransitionOptions): UseTransitionReturn { - const { mounted, transitionStatus, setMounted } = useTransitionStatus(open); - const [skipEnter, setSkipEnter] = useState(open && !animateOnMount); - if (skipEnter && transitionStatus !== 'starting') { - setSkipEnter(false); - } +export function useTransition({ open, ref, animateOnMount }: UseTransitionOptions): UseTransitionReturn { + const { mounted, transitionStatus, setMounted } = useTransitionStatus({ open, animateOnMount }); const runOnAnimationsFinished = useAnimationsFinished(ref, open); useEffect(() => { @@ -67,15 +64,13 @@ export function useTransition({ open, ref, animateOnMount = true }: UseTransitio props['data-closed'] = ''; } if (transitionStatus === 'starting') { - if (!skipEnter) { - props['data-starting-style'] = ''; - } + props['data-starting-style'] = ''; props.style = { transition: 'none' }; } else if (transitionStatus === 'ending') { props['data-ending-style'] = ''; } return props; - }, [open, mounted, transitionStatus, skipEnter]); + }, [open, mounted, transitionStatus]); return { mounted, transitionStatus, transitionProps }; } diff --git a/packages/mosaic/src/primitives/tabs/tabs-panel.tsx b/packages/mosaic/src/primitives/tabs/tabs-panel.tsx index f88ea085a65..50b569be635 100644 --- a/packages/mosaic/src/primitives/tabs/tabs-panel.tsx +++ b/packages/mosaic/src/primitives/tabs/tabs-panel.tsx @@ -27,18 +27,6 @@ export const TabsPanel = React.forwardRef(functi ref: panelRef, }); - // Suppress enter animation on initial mount so the initially-selected panel - // appears instantly. After the panel has been deselected once, subsequent - // selections will animate normally. Matches the Accordion pattern. - const hasBeenDeselected = useRef(false); - if (!isSelected) { - hasBeenDeselected.current = true; - } - - const effectiveTransitionProps = !hasBeenDeselected.current - ? { ...transitionProps, 'data-starting-style': undefined, style: undefined } - : transitionProps; - const state = { hidden: !mounted }; const defaultProps = { @@ -48,9 +36,9 @@ export const TabsPanel = React.forwardRef(functi tabIndex: 0, ...inertProps(!isSelected), hidden: mounted ? undefined : true, - ...effectiveTransitionProps, + ...transitionProps, style: { - ...effectiveTransitionProps.style, + ...transitionProps.style, '--cl-tab-transition-direction': direction === 'forward' ? '1' : '-1', }, children: keepMounted || mounted ? {children} : null, diff --git a/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx b/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx index 3f4d7e26f11..bb863a7194f 100644 --- a/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx +++ b/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx @@ -22,7 +22,7 @@ export const TagInputTag = React.forwardRef( const invalid = tags.find(tag => tag.value === tagValue)?.invalid ?? false; const elementRef = useRef(null); - const { mounted, transitionProps } = useTransition({ open: present, ref: elementRef }); + const { mounted, transitionProps } = useTransition({ open: present, ref: elementRef, animateOnMount: true }); useEffect(() => { if (!mounted) { diff --git a/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx b/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx index 93914763d9b..ccac1b0fdaa 100644 --- a/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx +++ b/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx @@ -83,6 +83,12 @@ function holdExitAnimations() { describe('TagInput', () => { describe('adding', () => { + it('starts the entrance for a tag present on mount', () => { + render(); + + expect(tag('a@clerk.dev')).toHaveAttribute('data-starting-style'); + }); + it('adds the typed text as a tag on Enter and clears the input', async () => { const user = userEvent.setup(); const onValueChange = vi.fn(); diff --git a/packages/mosaic/src/primitives/toast/toast-root.tsx b/packages/mosaic/src/primitives/toast/toast-root.tsx index 01b43a8b206..93203713021 100644 --- a/packages/mosaic/src/primitives/toast/toast-root.tsx +++ b/packages/mosaic/src/primitives/toast/toast-root.tsx @@ -88,7 +88,7 @@ export const ToastRoot = React.forwardRef(functi const [descriptionId, setDescriptionId] = useState(undefined); const open = toast.transitionStatus !== 'ending'; - const { mounted, transitionProps } = useTransition({ open, ref: rootRef }); + const { mounted, transitionProps } = useTransition({ open, ref: rootRef, animateOnMount: true }); useEffect(() => { if (!mounted) { diff --git a/packages/mosaic/src/primitives/toast/toast.test.tsx b/packages/mosaic/src/primitives/toast/toast.test.tsx index dfaca03b59d..7042d2eafde 100644 --- a/packages/mosaic/src/primitives/toast/toast.test.tsx +++ b/packages/mosaic/src/primitives/toast/toast.test.tsx @@ -60,6 +60,14 @@ function renderToast( describe('Toast', () => { describe('adding', () => { + it('starts the entrance when a toast arrives', () => { + renderToast(); + + fireEvent.click(screen.getByRole('button', { name: 'Add toast' })); + + expect(screen.getByRole('dialog', { name: 'Saved' })).toHaveAttribute('data-starting-style'); + }); + it('renders a toast added through useToastManager', async () => { const user = userEvent.setup(); renderToast(); diff --git a/packages/mosaic/src/styles/feedback.styles.ts b/packages/mosaic/src/styles/feedback.styles.ts index eeac8068e2e..f6f3d1e481c 100644 --- a/packages/mosaic/src/styles/feedback.styles.ts +++ b/packages/mosaic/src/styles/feedback.styles.ts @@ -64,5 +64,7 @@ export const feedbackStyles = stylex.create({ }); export const feedbackHeight = stylex.create({ - measured: (height: number) => ({ '--_cl-feedback-height': `${height}px` }), + measured: (height: number | undefined) => ({ + '--_cl-feedback-height': height === undefined ? null : `${height}px`, + }), }); diff --git a/packages/mosaic/src/utils/feedback.tsx b/packages/mosaic/src/utils/feedback.tsx index 25298625ec0..b1b4857cde9 100644 --- a/packages/mosaic/src/utils/feedback.tsx +++ b/packages/mosaic/src/utils/feedback.tsx @@ -11,7 +11,7 @@ export function hasMessage(children: React.ReactNode) { } export function useMessageHeight(active: HTMLElement | null) { - const [height, setHeight] = React.useState(0); + const [height, setHeight] = React.useState(undefined); useSafeLayoutEffect(() => { if (!active) {