From badfe7db5f26eb1e9505b00b2940adb12d708a75 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:13:23 +0000 Subject: [PATCH 1/5] Flyout: freeze content during the hide animation and animate it by default Adds an internal Freeze component that suspends its children without a fallback, so React keeps their last committed DOM while ignoring updates from props, state and context, and reveals the DOM React hides for the suspended tree. Freezing in a transition, which would never commit, gives up after a short timeout instead of blocking it. Flyout content is frozen as soon as it stops being active, so Select, DropdownMenu and Autocomplete no longer need to disable the hide animation. disableHideAnimation is kept as an opt-out. Also fixes issues surfaced by animating these components: - aria-expanded reports the collapsed state as soon as the flyout closes - content that's animating out ignores pointer events - content closed before its open transition started is removed right away, since the cancelled transition never fires transitionend - focus trap is released when the content is removed without animation Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFwAYqyYv9BQJuc5zDGzgv --- .changeset/flyout-frozen-exit-animation.md | 5 + .../components/Autocomplete/Autocomplete.tsx | 1 - .../components/DropdownMenu/DropdownMenu.tsx | 1 - .../src/components/Flyout/Flyout.module.css | 5 + .../src/components/Flyout/Flyout.types.ts | 2 + .../src/components/Flyout/FlyoutContent.tsx | 5 +- .../components/Flyout/FlyoutControlled.tsx | 27 +++- .../src/components/Flyout/FlyoutTrigger.tsx | 6 +- .../Flyout/tests/Flyout.stories.tsx | 134 ++++++++++++++++++ .../Select/SelectCustomControlled.tsx | 1 - .../Select/tests/Select.stories.tsx | 12 +- .../_private/Freeze/Freeze.module.css | 3 + .../src/components/_private/Freeze/Freeze.tsx | 82 +++++++++++ .../_private/Freeze/Freeze.types.ts | 21 +++ .../src/components/_private/Freeze/index.ts | 1 + .../_private/Freeze/tests/Freeze.stories.tsx | 103 ++++++++++++++ 16 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 .changeset/flyout-frozen-exit-animation.md create mode 100644 packages/reshaped/src/components/_private/Freeze/Freeze.module.css create mode 100644 packages/reshaped/src/components/_private/Freeze/Freeze.tsx create mode 100644 packages/reshaped/src/components/_private/Freeze/Freeze.types.ts create mode 100644 packages/reshaped/src/components/_private/Freeze/index.ts create mode 100644 packages/reshaped/src/components/_private/Freeze/tests/Freeze.stories.tsx diff --git a/.changeset/flyout-frozen-exit-animation.md b/.changeset/flyout-frozen-exit-animation.md new file mode 100644 index 000000000..34753c76b --- /dev/null +++ b/.changeset/flyout-frozen-exit-animation.md @@ -0,0 +1,5 @@ +--- +"reshaped": minor +--- + +Restored the `Flyout` hide animation by default for `Select`, `DropdownMenu` and `Autocomplete`. Flyout content is now frozen while animating out, so it keeps its last rendered state even when the component updates on close, for example when `Select` changes its selected value. `disableHideAnimation` is still supported to opt out of the animation. Closing animations now also report the trigger as collapsed right away, ignore pointer events on the content that's animating out and no longer leave the content mounted when it's closed right after opening. diff --git a/packages/reshaped/src/components/Autocomplete/Autocomplete.tsx b/packages/reshaped/src/components/Autocomplete/Autocomplete.tsx index 740e2950f..d2f7cf9c3 100644 --- a/packages/reshaped/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/reshaped/src/components/Autocomplete/Autocomplete.tsx @@ -207,7 +207,6 @@ const Autocomplete: React.FC = (props) => { fallbackMinHeight={fallbackMinHeight} contentMaxHeight={contentMaxHeight} contentZIndex={contentZIndex} - disableHideAnimation instanceRef={instanceRef} > diff --git a/packages/reshaped/src/components/DropdownMenu/DropdownMenu.tsx b/packages/reshaped/src/components/DropdownMenu/DropdownMenu.tsx index 601790b76..11e299d2c 100644 --- a/packages/reshaped/src/components/DropdownMenu/DropdownMenu.tsx +++ b/packages/reshaped/src/components/DropdownMenu/DropdownMenu.tsx @@ -35,7 +35,6 @@ const DropdownMenu: React.FC = (props) => { padding={1} trapFocusMode={trapFocusMode} triggerType={triggerType} - disableHideAnimation={triggerType !== "hover"} > {children} diff --git a/packages/reshaped/src/components/Flyout/Flyout.module.css b/packages/reshaped/src/components/Flyout/Flyout.module.css index 48feb9f6b..69676ceb0 100644 --- a/packages/reshaped/src/components/Flyout/Flyout.module.css +++ b/packages/reshaped/src/components/Flyout/Flyout.module.css @@ -103,6 +103,11 @@ transform: scale(1) translateY(0); } +/* Content animating out stays visible but shouldn't be interactive anymore */ +.positioner:not(.--visible) .content { + pointer-events: none; +} + .positioner.--animated .content { transition-duration: var(--rs-duration-rapid); transition-property: opacity, transform; diff --git a/packages/reshaped/src/components/Flyout/Flyout.types.ts b/packages/reshaped/src/components/Flyout/Flyout.types.ts index c891c4708..ae1c661db 100644 --- a/packages/reshaped/src/components/Flyout/Flyout.types.ts +++ b/packages/reshaped/src/components/Flyout/Flyout.types.ts @@ -200,6 +200,8 @@ export type ContentProps = { export type ContextProps = { id: string; + /** Content is requested to be visible, stays false while the content is animating out */ + active: boolean; flyout: UseFlyoutData; width?: Width; triggerElRef?: React.RefObject; diff --git a/packages/reshaped/src/components/Flyout/FlyoutContent.tsx b/packages/reshaped/src/components/Flyout/FlyoutContent.tsx index ac07fef66..c460b3b9b 100644 --- a/packages/reshaped/src/components/Flyout/FlyoutContent.tsx +++ b/packages/reshaped/src/components/Flyout/FlyoutContent.tsx @@ -3,6 +3,7 @@ import React from "react"; import { classNames } from "@reshaped/utilities"; +import Freeze from "@/components/_private/Freeze"; import Portal from "@/components/_private/Portal"; import useIsomorphicLayoutEffect from "@/hooks/useIsomorphicLayoutEffect"; import ScrollArea from "../ScrollArea"; @@ -14,6 +15,7 @@ import s from "./Flyout.module.css"; const FlyoutContent: React.FC = (props) => { const { children, className, attributes, colorMode } = props; const { + active, flyout, id, triggerElRef, @@ -119,7 +121,8 @@ const FlyoutContent: React.FC = (props) => { scrollableAttributes={scrollableAttributes} scrollableClassName={scrollableClassName} > - {children} + {/* Keep the content unchanged while it's animating out, even if its state changes on close */} + {children} diff --git a/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx b/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx index e43c3e903..fccd346c0 100644 --- a/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx +++ b/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx @@ -370,6 +370,26 @@ const FlyoutControlled: React.FC = (props) = if (status === "rendered") show(); }, [status, show]); + /** + * Closing right after opening, before the content moved, cancels the transition instead of reversing it + * so transitionend never fires. Removing the content right away when there is nothing to wait for + */ + useIsomorphicLayoutEffect(() => { + if (status !== "hidden") return; + + const contentEl = flyoutElRef.current?.firstElementChild; + const animations = contentEl?.getAnimations?.() ?? []; + const hasTransition = animations.some( + (animation) => + animation instanceof CSSTransition && animation.transitionProperty === "transform" + ); + + if (hasTransition) return; + + remove(); + onAfterCloseRef.current?.(); + }, [status, remove, onAfterCloseRef]); + /** * Handle focus trap * @@ -402,8 +422,8 @@ const FlyoutControlled: React.FC = (props) = }, [status, triggerType, trapFocusMode, autoFocus]); React.useEffect(() => { - if (!disableHideAnimation && status !== "hidden") return; - if (disableHideAnimation && isRendered) return; + // Release when the hide animation starts or right away when the content is removed without it + if (status !== "hidden" && isRendered) return; if (trapFocusRef.current?.trapped) { /* Locking the popover to not open it again on trigger focus */ @@ -418,7 +438,7 @@ const FlyoutControlled: React.FC = (props) = trapFocusRef.current.release({ withoutFocusReturn: !shouldReturnFocusRef.current }); shouldReturnFocusRef.current = true; } - }, [status, isRendered, triggerType, disableHideAnimation]); + }, [status, isRendered, triggerType]); /** * Clean up safe polygon tracking on unmount or when flyout closes @@ -477,6 +497,7 @@ const FlyoutControlled: React.FC = (props) = = (props) => { const { children } = props; const { id, + active: requestedActive, triggerElRef, triggerType, flyout, @@ -53,8 +54,9 @@ const FlyoutTrigger: React.FC = (props) => { childrenAttributes["aria-haspopup"] = "menu"; } - childrenAttributes["aria-expanded"] = active; - childrenAttributes["aria-controls"] = active ? id : undefined; + // Content is reported as collapsed right away, even while it's still animating out + childrenAttributes["aria-expanded"] = active && requestedActive; + childrenAttributes["aria-controls"] = active && requestedActive ? id : undefined; } return ( diff --git a/packages/reshaped/src/components/Flyout/tests/Flyout.stories.tsx b/packages/reshaped/src/components/Flyout/tests/Flyout.stories.tsx index 9de85911b..aaaf16d74 100644 --- a/packages/reshaped/src/components/Flyout/tests/Flyout.stories.tsx +++ b/packages/reshaped/src/components/Flyout/tests/Flyout.stories.tsx @@ -724,6 +724,140 @@ export const disableHideAnimation = { render: () => , }; +export const testFrozenContentOnClose: StoryObj<{ handleAfterClose: ReturnType }> = { + name: "test: content doesn't change while closing", + args: { + handleAfterClose: fn(), + }, + render: (args) => { + const [active, setActive] = React.useState(false); + const [value, setValue] = React.useState("First"); + + return ( + setActive(true)} + onClose={() => setActive(false)} + onAfterClose={args.handleAfterClose} + > + + {(attributes) => } + + + +
Selected: {value}
+ +
+
+
+ ); + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement.ownerDocument.body); + + // Transitions are disabled for the first frames after the color mode gets applied + await waitFor(() => { + expect(document.documentElement).not.toHaveAttribute("data-rs-no-transition"); + }); + + await userEvent.click(canvas.getByRole("button", { name: "First" })); + + await waitFor(() => { + expect(canvas.getByTestId("selected-value")).toBeVisible(); + }); + + // Record the content state after every DOM update since the hide animation might end before click resolves + const snapshots: { text?: string | null; visible: boolean }[] = []; + const observer = new MutationObserver(() => { + const el = canvasElement.ownerDocument.querySelector( + "[data-testid=selected-value]" + ); + if (!el) return; + snapshots.push({ text: el.textContent, visible: el.checkVisibility() }); + }); + observer.observe(canvasElement.ownerDocument.body, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + + await userEvent.click(canvas.getByText("Select second")); + + await waitFor(() => { + expect(canvas.queryByTestId("selected-value")).not.toBeInTheDocument(); + }); + observer.disconnect(); + + // Trigger is updated right away while the content keeps its last state during the hide animation + expect(canvas.getAllByRole("button")[0]).toHaveTextContent("Second"); + expect(snapshots.length).toBeGreaterThan(0); + snapshots.forEach((snapshot) => { + expect(snapshot).toEqual({ text: "Selected: First", visible: true }); + }); + expect(args.handleAfterClose).toHaveBeenCalledTimes(1); + }, +}; + +export const testCloseRightAfterOpen: StoryObj<{ handleAfterClose: ReturnType }> = { + name: "test: close right after opening", + args: { + handleAfterClose: fn(), + }, + render: (args) => { + const [active, setActive] = React.useState(false); + const [closeOnOpen, setCloseOnOpen] = React.useState(false); + + // Closing in the same frame, before the open transition starts + React.useEffect(() => { + if (active && closeOnOpen) setActive(false); + }, [active, closeOnOpen]); + + return ( + + + {(attributes) => ( + + )} + + + + + + ); + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement.ownerDocument.body); + + // Transitions are disabled for the first frames after the color mode gets applied + await waitFor(() => { + expect(document.documentElement).not.toHaveAttribute("data-rs-no-transition"); + }); + + await userEvent.click(canvas.getByRole("button", { name: "Trigger" })); + + await waitFor(() => { + expect(canvas.queryByText("Content")).not.toBeInTheDocument(); + expect(args.handleAfterClose).toHaveBeenCalledTimes(1); + }); + }, +}; + export const disabled: StoryObj<{ handleOpen: ReturnType }> = { name: "disabled", args: { diff --git a/packages/reshaped/src/components/Select/SelectCustomControlled.tsx b/packages/reshaped/src/components/Select/SelectCustomControlled.tsx index bd8a7d5bd..43d85075b 100644 --- a/packages/reshaped/src/components/Select/SelectCustomControlled.tsx +++ b/packages/reshaped/src/components/Select/SelectCustomControlled.tsx @@ -179,7 +179,6 @@ const SelectCustomControlled: React.FC = (props) => { return ( { + expect(within(canvasElement.ownerDocument.body).queryAllByRole("option")).toHaveLength(0); + }); + const [_, triggerWithGroups] = canvas.getAllByRole("button"); const hiddenInputs2 = canvasElement.querySelectorAll('input[type="hidden"]'); const [__, hiddenInputWithGroups] = Array.from(hiddenInputs2); @@ -843,6 +848,11 @@ export const defaultHandlers: StoryObj<{ // Controlled await userEvent.click(document.body); + // Wait for the previous menu to finish its hide animation + await waitFor(() => { + expect(within(canvasElement.ownerDocument.body).queryAllByRole("option")).toHaveLength(0); + }); + const [__, controlled, focusable] = canvas.getAllByRole("button"); const hiddenInputs2 = canvasElement.querySelectorAll('input[type="hidden"]'); const [___, inputControlled] = Array.from(hiddenInputs2); diff --git a/packages/reshaped/src/components/_private/Freeze/Freeze.module.css b/packages/reshaped/src/components/_private/Freeze/Freeze.module.css new file mode 100644 index 000000000..9efb21f7a --- /dev/null +++ b/packages/reshaped/src/components/_private/Freeze/Freeze.module.css @@ -0,0 +1,3 @@ +.root { + display: contents; +} diff --git a/packages/reshaped/src/components/_private/Freeze/Freeze.tsx b/packages/reshaped/src/components/_private/Freeze/Freeze.tsx new file mode 100644 index 000000000..8eb51927a --- /dev/null +++ b/packages/reshaped/src/components/_private/Freeze/Freeze.tsx @@ -0,0 +1,82 @@ +"use client"; + +import React from "react"; + +import useIsomorphicLayoutEffect from "@/hooks/useIsomorphicLayoutEffect"; +import type * as T from "./Freeze.types"; +import s from "./Freeze.module.css"; + +/** + * Time given to a frozen render to get committed before giving up on freezing. + * Synchronous updates commit right away, while transitions never commit an already visible + * Suspense boundary switching to its fallback and would otherwise wait for it forever + */ +const COMMIT_TIMEOUT = 100; + +const neverResolved = new Promise(() => {}); + +const Suspender: React.FC = (props) => { + const { frozen, frozenRef, children } = props; + const state = frozenRef.current; + + if (!frozen || state.expired) return children; + if (state.committed) throw neverResolved; + + if (!state.pending) { + state.pending = new Promise((resolve) => { + setTimeout(() => { + if (!state.committed) state.expired = true; + resolve(); + }, COMMIT_TIMEOUT); + }); + } + + throw state.pending; +}; + +/** + * Keeps the last rendered output of its children on the screen while frozen, ignoring any updates + * coming from their props, state or context. Used for exit animations, so content doesn't change + * while it's animating out, for example when the selected value changes after closing a menu. + * + * Frozen children are suspended without a fallback, so React keeps their DOM in place and stops + * committing changes to it. React hides suspended DOM nodes, so the wrapper element is revealed back + * in the same commit before the browser paints + */ +const Freeze: React.FC = (props) => { + const { frozen, children } = props; + const frozenRef = React.useRef({ committed: false, expired: false }); + // React detaches refs inside suspended trees, so we keep the last attached element instead + const elRef = React.useRef(null); + const attachRef = React.useCallback((el: HTMLDivElement | null) => { + if (el) elRef.current = el; + }, []); + + useIsomorphicLayoutEffect(() => { + const state = frozenRef.current; + + if (!frozen) { + frozenRef.current = { committed: false, expired: false }; + return; + } + + if (state.expired) return; + + state.committed = true; + elRef.current?.style.removeProperty("display"); + }, [frozen]); + + return ( + +
+ + {children} + +
+
+ ); +}; + +Freeze.displayName = "Freeze"; + +export default Freeze; diff --git a/packages/reshaped/src/components/_private/Freeze/Freeze.types.ts b/packages/reshaped/src/components/_private/Freeze/Freeze.types.ts new file mode 100644 index 000000000..598e6ba7d --- /dev/null +++ b/packages/reshaped/src/components/_private/Freeze/Freeze.types.ts @@ -0,0 +1,21 @@ +import type React from "react"; + +export type Props = { + /** Stop committing updates to the children and keep their last rendered output on the screen */ + frozen: boolean; + /** Node for inserting children */ + children?: React.ReactNode; +}; + +export type SuspenderProps = Props & { + frozenRef: React.RefObject; +}; + +export type FrozenState = { + /** Frozen render was committed, children have to stay suspended until unfrozen */ + committed: boolean; + /** Pending suspension used to detect renders that never get committed */ + pending?: Promise; + /** Pending suspension expired without a commit, children are rendered without freezing */ + expired: boolean; +}; diff --git a/packages/reshaped/src/components/_private/Freeze/index.ts b/packages/reshaped/src/components/_private/Freeze/index.ts new file mode 100644 index 000000000..f8dd5620a --- /dev/null +++ b/packages/reshaped/src/components/_private/Freeze/index.ts @@ -0,0 +1 @@ +export { default } from "./Freeze"; diff --git a/packages/reshaped/src/components/_private/Freeze/tests/Freeze.stories.tsx b/packages/reshaped/src/components/_private/Freeze/tests/Freeze.stories.tsx new file mode 100644 index 000000000..38a9e34e7 --- /dev/null +++ b/packages/reshaped/src/components/_private/Freeze/tests/Freeze.stories.tsx @@ -0,0 +1,103 @@ +import { StoryObj } from "@storybook/react-vite"; +import React from "react"; +import { expect, userEvent, waitFor, within } from "storybook/test"; + +import Freeze from "@/components/_private/Freeze"; +import Button from "@/components/Button"; +import View from "@/components/View"; + +export default { title: "Internal/Freeze" }; + +const ValueContext = React.createContext(0); + +const ContextValue = () => { + const value = React.useContext(ValueContext); + + return
Context: {value}
; +}; + +const Demo = (props: { transition?: boolean }) => { + const [frozen, setFrozen] = React.useState(false); + const [value, setValue] = React.useState(0); + + const update = (nextFrozen: boolean) => { + const apply = () => { + setFrozen(nextFrozen); + setValue((prev) => prev + 1); + }; + + if (props.transition) { + React.startTransition(apply); + } else { + apply(); + } + }; + + return ( + + + + + + +
{frozen ? "Frozen" : "Active"}
+ + + +
Prop: {value}
+ +
+
+
+ ); +}; + +export const base: StoryObj = { + name: "base", + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const [freezeButton, unfreezeButton] = canvas.getAllByRole("button"); + + await userEvent.click(freezeButton); + + // Updates coming in the same render as freezing are ignored + expect(canvas.getByTestId("frozen-state")).toHaveTextContent("Frozen"); + expect(canvas.getByTestId("prop-value")).toHaveTextContent("Prop: 0"); + expect(canvas.getByTestId("context-value")).toHaveTextContent("Context: 0"); + + // Frozen content stays visible even though it's suspended + expect(canvas.getByTestId("prop-value")).toBeVisible(); + expect(canvas.getByTestId("context-value")).toBeVisible(); + + await userEvent.click(freezeButton); + expect(canvas.getByTestId("prop-value")).toHaveTextContent("Prop: 0"); + + await userEvent.click(unfreezeButton); + + await waitFor(() => { + expect(canvas.getByTestId("frozen-state")).toHaveTextContent("Active"); + expect(canvas.getByTestId("prop-value")).toHaveTextContent("Prop: 3"); + expect(canvas.getByTestId("context-value")).toHaveTextContent("Context: 3"); + }); + expect(canvas.getByTestId("prop-value")).toBeVisible(); + }, +}; + +export const transition: StoryObj = { + name: "test: freezing in a transition", + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const [freezeButton] = canvas.getAllByRole("button"); + + await userEvent.click(freezeButton); + + // Transitions can't commit a frozen render, so they shouldn't be blocked by it + await waitFor(() => { + expect(canvas.getByTestId("frozen-state")).toHaveTextContent("Frozen"); + expect(canvas.getByTestId("prop-value")).toHaveTextContent("Prop: 1"); + }); + expect(canvas.getByTestId("prop-value")).toBeVisible(); + }, +}; From 9575eddb97dd8c283a84cb70dfab39d178e4db0f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:17:02 +0000 Subject: [PATCH 2/5] Freeze: undo the Suspense display in an insertion effect and suspend with a sibling Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFwAYqyYv9BQJuc5zDGzgv --- .../src/components/_private/Freeze/Freeze.tsx | 74 ++++++++----------- .../_private/Freeze/Freeze.types.ts | 15 ++-- 2 files changed, 37 insertions(+), 52 deletions(-) diff --git a/packages/reshaped/src/components/_private/Freeze/Freeze.tsx b/packages/reshaped/src/components/_private/Freeze/Freeze.tsx index 8eb51927a..e04e2f5f0 100644 --- a/packages/reshaped/src/components/_private/Freeze/Freeze.tsx +++ b/packages/reshaped/src/components/_private/Freeze/Freeze.tsx @@ -2,76 +2,66 @@ import React from "react"; -import useIsomorphicLayoutEffect from "@/hooks/useIsomorphicLayoutEffect"; import type * as T from "./Freeze.types"; import s from "./Freeze.module.css"; /** - * Time given to a frozen render to get committed before giving up on freezing. - * Synchronous updates commit right away, while transitions never commit an already visible - * Suspense boundary switching to its fallback and would otherwise wait for it forever + * Transitions never commit an already visible Suspense boundary switching to its fallback, + * so we stop freezing if the frozen render isn't committed in time instead of blocking them forever */ const COMMIT_TIMEOUT = 100; +const infinitePromise = new Promise(() => {}); -const neverResolved = new Promise(() => {}); +const Suspend: React.FC<{ stateRef: React.RefObject }> = (props) => { + const state = props.stateRef.current; -const Suspender: React.FC = (props) => { - const { frozen, frozenRef, children } = props; - const state = frozenRef.current; + if (state.expired) return null; + if (state.committed) throw infinitePromise; - if (!frozen || state.expired) return children; - if (state.committed) throw neverResolved; - - if (!state.pending) { - state.pending = new Promise((resolve) => { - setTimeout(() => { - if (!state.committed) state.expired = true; - resolve(); - }, COMMIT_TIMEOUT); - }); - } + state.pending ??= new Promise((resolve) => { + setTimeout(() => { + state.expired = !state.committed; + resolve(); + }, COMMIT_TIMEOUT); + }); throw state.pending; }; /** - * Keeps the last rendered output of its children on the screen while frozen, ignoring any updates - * coming from their props, state or context. Used for exit animations, so content doesn't change - * while it's animating out, for example when the selected value changes after closing a menu. - * - * Frozen children are suspended without a fallback, so React keeps their DOM in place and stops - * committing changes to it. React hides suspended DOM nodes, so the wrapper element is revealed back - * in the same commit before the browser paints + * Keeps the last rendered output of the children while frozen, ignoring updates from their props, state or context. + * Suspended children keep their DOM but React hides it, so we show it again before the browser paints. + * Wrapper element keeps text nodes from getting cleared and doesn't override the children inline styles */ const Freeze: React.FC = (props) => { const { frozen, children } = props; - const frozenRef = React.useRef({ committed: false, expired: false }); - // React detaches refs inside suspended trees, so we keep the last attached element instead + const stateRef = React.useRef({}); const elRef = React.useRef(null); - const attachRef = React.useCallback((el: HTMLDivElement | null) => { - if (el) elRef.current = el; - }, []); - - useIsomorphicLayoutEffect(() => { - const state = frozenRef.current; + // Insertion effect is the earliest opportunity to undo the Suspense display: none + React.useInsertionEffect(() => { if (!frozen) { - frozenRef.current = { committed: false, expired: false }; + stateRef.current = {}; return; } - if (state.expired) return; + if (stateRef.current.expired) return; - state.committed = true; + stateRef.current.committed = true; elRef.current?.style.removeProperty("display"); }, [frozen]); return ( - -
- - {children} - + + {/* React detaches refs in suspended trees, so we keep the last attached element */} +
{ + if (el) elRef.current = el; + }} + > + {frozen && } + {children}
); diff --git a/packages/reshaped/src/components/_private/Freeze/Freeze.types.ts b/packages/reshaped/src/components/_private/Freeze/Freeze.types.ts index 598e6ba7d..d66545a80 100644 --- a/packages/reshaped/src/components/_private/Freeze/Freeze.types.ts +++ b/packages/reshaped/src/components/_private/Freeze/Freeze.types.ts @@ -7,15 +7,10 @@ export type Props = { children?: React.ReactNode; }; -export type SuspenderProps = Props & { - frozenRef: React.RefObject; -}; - -export type FrozenState = { - /** Frozen render was committed, children have to stay suspended until unfrozen */ - committed: boolean; - /** Pending suspension used to detect renders that never get committed */ +export type State = { + /** Frozen render was committed, children stay suspended until unfrozen */ + committed?: boolean; + /** Frozen render wasn't committed in time, children render without freezing */ + expired?: boolean; pending?: Promise; - /** Pending suspension expired without a commit, children are rendered without freezing */ - expired: boolean; }; From 0984f55ef8b1c38cbd1051d4b5ac864c4d1cb62f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:36:36 +0000 Subject: [PATCH 3/5] Flyout: simplify the cancelled transition check and keep the trigger aria attributes as before Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TFwAYqyYv9BQJuc5zDGzgv --- .changeset/flyout-frozen-exit-animation.md | 2 +- .../components/Flyout/FlyoutControlled.tsx | 14 ++---- .../src/components/Flyout/FlyoutTrigger.tsx | 6 +-- .../Flyout/tests/Flyout.stories.tsx | 46 +++++++++++++++++++ .../Select/tests/Select.stories.tsx | 6 ++- 5 files changed, 57 insertions(+), 17 deletions(-) diff --git a/.changeset/flyout-frozen-exit-animation.md b/.changeset/flyout-frozen-exit-animation.md index 34753c76b..91f98c8d0 100644 --- a/.changeset/flyout-frozen-exit-animation.md +++ b/.changeset/flyout-frozen-exit-animation.md @@ -2,4 +2,4 @@ "reshaped": minor --- -Restored the `Flyout` hide animation by default for `Select`, `DropdownMenu` and `Autocomplete`. Flyout content is now frozen while animating out, so it keeps its last rendered state even when the component updates on close, for example when `Select` changes its selected value. `disableHideAnimation` is still supported to opt out of the animation. Closing animations now also report the trigger as collapsed right away, ignore pointer events on the content that's animating out and no longer leave the content mounted when it's closed right after opening. +Restored the `Flyout` hide animation by default for `Select`, `DropdownMenu` and `Autocomplete`. Flyout content is now frozen while animating out, so it keeps its last rendered state even when the component updates on close, for example when `Select` changes its selected value. `disableHideAnimation` is still supported to opt out of the animation. Content that's animating out no longer reacts to pointer events, content closed right after opening no longer stays mounted, and focus now returns to the trigger when the content is closed without an animation, for example with reduced motion enabled. diff --git a/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx b/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx index fccd346c0..c1868c048 100644 --- a/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx +++ b/packages/reshaped/src/components/Flyout/FlyoutControlled.tsx @@ -371,20 +371,12 @@ const FlyoutControlled: React.FC = (props) = }, [status, show]); /** - * Closing right after opening, before the content moved, cancels the transition instead of reversing it - * so transitionend never fires. Removing the content right away when there is nothing to wait for + * Closing before the open transition starts cancels it instead of reversing, + * so there is no transitionend to wait for */ useIsomorphicLayoutEffect(() => { if (status !== "hidden") return; - - const contentEl = flyoutElRef.current?.firstElementChild; - const animations = contentEl?.getAnimations?.() ?? []; - const hasTransition = animations.some( - (animation) => - animation instanceof CSSTransition && animation.transitionProperty === "transform" - ); - - if (hasTransition) return; + if (flyoutElRef.current?.firstElementChild?.getAnimations?.().length) return; remove(); onAfterCloseRef.current?.(); diff --git a/packages/reshaped/src/components/Flyout/FlyoutTrigger.tsx b/packages/reshaped/src/components/Flyout/FlyoutTrigger.tsx index 47d1aaaaa..81c4550f8 100644 --- a/packages/reshaped/src/components/Flyout/FlyoutTrigger.tsx +++ b/packages/reshaped/src/components/Flyout/FlyoutTrigger.tsx @@ -7,7 +7,6 @@ const FlyoutTrigger: React.FC = (props) => { const { children } = props; const { id, - active: requestedActive, triggerElRef, triggerType, flyout, @@ -54,9 +53,8 @@ const FlyoutTrigger: React.FC = (props) => { childrenAttributes["aria-haspopup"] = "menu"; } - // Content is reported as collapsed right away, even while it's still animating out - childrenAttributes["aria-expanded"] = active && requestedActive; - childrenAttributes["aria-controls"] = active && requestedActive ? id : undefined; + childrenAttributes["aria-expanded"] = active; + childrenAttributes["aria-controls"] = active ? id : undefined; } return ( diff --git a/packages/reshaped/src/components/Flyout/tests/Flyout.stories.tsx b/packages/reshaped/src/components/Flyout/tests/Flyout.stories.tsx index aaaf16d74..7e4594b64 100644 --- a/packages/reshaped/src/components/Flyout/tests/Flyout.stories.tsx +++ b/packages/reshaped/src/components/Flyout/tests/Flyout.stories.tsx @@ -858,6 +858,52 @@ export const testCloseRightAfterOpen: StoryObj<{ handleAfterClose: ReturnType ( + + + {(attributes) => } + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + const trigger = canvas.getByRole("button", { name: "Trigger" }); + + // Wait for the provider to enable transitions after mount before disabling them again + await waitFor(() => { + expect(document.documentElement).not.toHaveAttribute("data-rs-no-transition"); + }); + + // Same as prefers-reduced-motion, content is removed without the hide animation + document.documentElement.setAttribute("data-rs-no-transition", "true"); + + try { + trigger.focus(); + await userEvent.keyboard("{Enter}"); + + await waitFor(() => { + expect(canvas.getByRole("dialog")).toContainElement(document.activeElement as HTMLElement); + }); + + await userEvent.keyboard("{Escape}"); + + await waitFor(() => { + expect(canvas.queryByRole("dialog")).not.toBeInTheDocument(); + }); + expect(trigger).toHaveFocus(); + } finally { + document.documentElement.removeAttribute("data-rs-no-transition"); + } + }, +}; + export const disabled: StoryObj<{ handleOpen: ReturnType }> = { name: "disabled", args: { diff --git a/packages/reshaped/src/components/Select/tests/Select.stories.tsx b/packages/reshaped/src/components/Select/tests/Select.stories.tsx index cd0825f09..a3d7eefe3 100644 --- a/packages/reshaped/src/components/Select/tests/Select.stories.tsx +++ b/packages/reshaped/src/components/Select/tests/Select.stories.tsx @@ -440,7 +440,11 @@ export const renderTrigger: StoryObj<{ handleChange: Mock }> = { expect(hiddenInput).toHaveValue("turtle"); expect(trigger).toHaveTextContent("Turtle"); - expect(trigger).toHaveAttribute("aria-expanded", "false"); + + // Trigger stays expanded until the hide animation ends + await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); expect(args.handleChange).toHaveBeenCalledTimes(1); expect(args.handleChange).toHaveBeenCalledWith({ name: "animal", From 04ee128c4ed7f310f2f1633d070bea7284900664 Mon Sep 17 00:00:00 2001 From: Dima Belyaev Date: Fri, 9 Oct 2026 00:17:40 +0100 Subject: [PATCH 4/5] flyout review --- packages/reshaped/src/components/Flyout/Flyout.module.css | 6 +----- packages/reshaped/src/components/Flyout/Flyout.types.ts | 1 - packages/reshaped/src/components/Flyout/FlyoutContent.tsx | 1 - 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/reshaped/src/components/Flyout/Flyout.module.css b/packages/reshaped/src/components/Flyout/Flyout.module.css index 69676ceb0..7dfbb3681 100644 --- a/packages/reshaped/src/components/Flyout/Flyout.module.css +++ b/packages/reshaped/src/components/Flyout/Flyout.module.css @@ -101,11 +101,7 @@ .positioner.--visible .content { opacity: 1; transform: scale(1) translateY(0); -} - -/* Content animating out stays visible but shouldn't be interactive anymore */ -.positioner:not(.--visible) .content { - pointer-events: none; + pointer-events: all; } .positioner.--animated .content { diff --git a/packages/reshaped/src/components/Flyout/Flyout.types.ts b/packages/reshaped/src/components/Flyout/Flyout.types.ts index ae1c661db..c635e64d2 100644 --- a/packages/reshaped/src/components/Flyout/Flyout.types.ts +++ b/packages/reshaped/src/components/Flyout/Flyout.types.ts @@ -200,7 +200,6 @@ export type ContentProps = { export type ContextProps = { id: string; - /** Content is requested to be visible, stays false while the content is animating out */ active: boolean; flyout: UseFlyoutData; width?: Width; diff --git a/packages/reshaped/src/components/Flyout/FlyoutContent.tsx b/packages/reshaped/src/components/Flyout/FlyoutContent.tsx index c460b3b9b..6d35d0d6d 100644 --- a/packages/reshaped/src/components/Flyout/FlyoutContent.tsx +++ b/packages/reshaped/src/components/Flyout/FlyoutContent.tsx @@ -121,7 +121,6 @@ const FlyoutContent: React.FC = (props) => { scrollableAttributes={scrollableAttributes} scrollableClassName={scrollableClassName} > - {/* Keep the content unchanged while it's animating out, even if its state changes on close */} {children}
From 70463b516c3e20c472503ff04bc0b738d09d748b Mon Sep 17 00:00:00 2001 From: Dima Belyaev Date: Fri, 9 Oct 2026 00:43:32 +0100 Subject: [PATCH 5/5] updates --- packages/reshaped/src/components/_private/Freeze/Freeze.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/reshaped/src/components/_private/Freeze/Freeze.tsx b/packages/reshaped/src/components/_private/Freeze/Freeze.tsx index e04e2f5f0..583eebbf0 100644 --- a/packages/reshaped/src/components/_private/Freeze/Freeze.tsx +++ b/packages/reshaped/src/components/_private/Freeze/Freeze.tsx @@ -28,11 +28,6 @@ const Suspend: React.FC<{ stateRef: React.RefObject }> = (props) => { throw state.pending; }; -/** - * Keeps the last rendered output of the children while frozen, ignoring updates from their props, state or context. - * Suspended children keep their DOM but React hides it, so we show it again before the browser paints. - * Wrapper element keeps text nodes from getting cleared and doesn't override the children inline styles - */ const Freeze: React.FC = (props) => { const { frozen, children } = props; const stateRef = React.useRef({});