diff --git a/docs/menus.md b/docs/menus.md index 15ea6b5a5d..590def2b33 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -200,13 +200,15 @@ its own. ## Gotchas -- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on - the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content - identity changes — a pushed page taller than the one it replaced is clipped without that. -- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back - 750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the - entering animation never ends at the off-screen measuring position; on a slow machine that - write moved the open menu back off-screen. +- **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the + surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms + after the start. Whatever moved or resized the surface in between was undone: content that + grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved + again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which + leaves no inline style; native keeps the Reanimated `Keyframe`. +- **Animate only once placed.** `AnchoredSurface` remounts the surface when its position resolves, + so the entering animation runs at the final position rather than ending unseen at the + off-screen measuring position. - **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is rarely the height of the page before it. A fill page is the one exception. - **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds diff --git a/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx b/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx new file mode 100644 index 0000000000..9d60353a52 --- /dev/null +++ b/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx @@ -0,0 +1,124 @@ +import React, { act, useEffect, useState, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Text, View } from "react-native"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; +import { MenuRoot, MenuSurface, MenuTrigger, type Size } from "./index"; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); + +/** + * A real browser, because the regression lives in Reanimated's web entering path: it snapshots + * the surface's rect when a custom keyframe ends and writes that rect back as inline + * top/left/width/height when it cleans the keyframe up, 750ms after the animation starts. + * Anything that moved or resized the surface in between is undone by that write. + */ + +interface Mounted { + root: Root; + container: HTMLDivElement; +} + +const mounted: Mounted[] = []; + +function mount(node: ReactNode): void { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(node)); + mounted.push({ root, container }); +} + +afterEach(() => { + for (const entry of mounted.splice(0)) { + act(() => entry.root.unmount()); + entry.container.remove(); + } +}); + +const OFFSET = 12; +const PILL_POSITION = { position: "absolute", top: 700, left: 40 } as const; +const SHORT: Size = { width: 200, height: 40 }; +const TALL: Size = { width: 200, height: 240 }; +const WIDE: Size = { width: 480, height: 40 }; +/** Inside the window between the entering animation ending and Reanimated's cleanup. */ +const GROWS_BEFORE_CLEANUP_MS = 400; +/** Past Reanimated's cleanup, where the surface used to keep a fixed height. */ +const GROWS_AFTER_CLEANUP_MS = 1_000; +/** Long enough after the content grew for anything that would write to the surface to land. */ +const SETTLE_MS = 1_200; + +/** Plain timers, not `act`: an async `act` holds every update until it resolves. */ +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function element(testID: string): HTMLElement { + const found = document.querySelector(`[data-testid="${testID}"]`); + if (!(found instanceof HTMLElement)) throw new Error(`${testID} did not render`); + return found; +} + +/** Stands in for a popover whose data lands after it opened, like a plugin's first RPC. */ +function GrowsAfterOpen({ afterMs, from, to }: { afterMs: number; from: Size; to: Size }) { + const [grown, setGrown] = useState(false); + useEffect(() => { + const timer = setTimeout(() => setGrown(true), afterMs); + return () => clearTimeout(timer); + }, [afterMs]); + return ; +} + +function mountMenuAbovePill(body: ReactNode): void { + mount( + + + + Open panel + + + {body} + + + , + ); + act(() => element("pill").click()); +} + +describe("anchored menu surface on web", () => { + beforeEach(async () => { + await page.viewport(1280, 900); + }); + + it.each([GROWS_BEFORE_CLEANUP_MS, GROWS_AFTER_CLEANUP_MS])( + "stays on its anchor when its content grows %ims after opening", + async (afterMs) => { + mountMenuAbovePill(); + + await wait(afterMs + SETTLE_MS); + + const pill = element("pill").getBoundingClientRect(); + const panel = element("panel").getBoundingClientRect(); + expect(panel.height).toBeGreaterThan(240); + expect(panel.bottom).toBeLessThanOrEqual(pill.top); + }, + ); + + it("widens when its content widens after the entering animation", async () => { + mountMenuAbovePill(); + + await wait(GROWS_BEFORE_CLEANUP_MS + SETTLE_MS); + + const body = element("body").getBoundingClientRect(); + const panel = element("panel").getBoundingClientRect(); + expect(panel.width).toBeGreaterThanOrEqual(body.width); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index 9631dbbe9a..c6935bfd91 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useId, + useLayoutEffect, useMemo, useState, type ReactElement, @@ -47,56 +48,41 @@ const contentEntering = new Keyframe({ 100: { opacity: 1, transform: [{ scale: 1 }] }, }).duration(CONTENT_ENTERING_DURATION_MS); +/** `contentEntering` for the browser's own animation engine. Both default to linear easing. */ +const WEB_CONTENT_ENTERING_KEYFRAMES: PropertyIndexedKeyframes = { + opacity: [0, 1], + transform: ["scale(0.97)", "scale(1)"], +}; + +/** Web plays the same keyframes itself; see `useWebContentEntering`. */ +const nativeContentEntering = isWeb ? undefined : contentEntering; + const contentExiting = new Keyframe({ 0: { opacity: 1, transform: [{ scale: 1 }] }, 100: { opacity: 0, transform: [{ scale: 0.97 }] }, }).duration(100); -function releaseFixedMenuHeight(surfaceNativeID: string): void { - if (!isWeb) return; - document.getElementById(surfaceNativeID)?.style.removeProperty("height"); -} - /** - * Reanimated's web entering animation leaves an inline height snapshot on the measured surface. - * Once the menu is open, height must return to content-sized so rows can grow in place — and so - * a submenu page taller than the page it replaced is not clipped to the old height. - * - * `revision` is what makes that second case work: bump it whenever the rendered content changes - * identity (a page push, for instance) and the snapshot is released again. + * On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's + * web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe + * up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that + * moved or resized in between — content that grew once its data arrived — snapped back to the old + * rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style. */ -function useReleaseFixedMenuHeight({ - contentSize, - enabled, +function useWebContentEntering({ + placed, surfaceNativeID, - revision, }: { - contentSize: Size | null; - enabled: boolean; + placed: boolean; surfaceNativeID: string; - revision?: string | number; }): void { - useEffect(() => { - if (!enabled) return undefined; - - const release = () => { - releaseFixedMenuHeight(surfaceNativeID); - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID)); - } - }; - const timers: ReturnType[] = [ - setTimeout(release, CONTENT_ENTERING_DURATION_MS), - ]; - - if (contentSize) { - timers.push(setTimeout(release, 0)); - } - - return () => { - for (const timer of timers) clearTimeout(timer); - }; - }, [contentSize, enabled, surfaceNativeID, revision]); + useLayoutEffect(() => { + if (!isWeb || !placed || typeof document === "undefined") return undefined; + const animation = document + .getElementById(surfaceNativeID) + ?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS); + return () => animation?.cancel(); + }, [placed, surfaceNativeID]); } /** @@ -224,8 +210,6 @@ export interface AnchoredSurfaceProps { fullWidth?: boolean; horizontalPadding?: number; scrollable?: boolean; - /** Bump when the rendered content changes identity, so the height snapshot is released. */ - revision?: string | number; /** A submenu sits inside its parent's overlay and must not paint a second backdrop. */ backdrop?: boolean; /** @@ -259,7 +243,6 @@ export function AnchoredSurface({ fullWidth = false, horizontalPadding = 16, scrollable = false, - revision, backdrop = true, onPointerEnter, onPointerLeave, @@ -269,33 +252,25 @@ export function AnchoredSurface({ }: AnchoredSurfaceProps): ReactElement | null { const { t } = useTranslation(); const surfaceNativeID = useId(); - const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } = - useAnchoredPosition({ - open, - anchorRect, - anchorRef, - side, - align, - offset, - scrollable, - maxHeight, - }); - - useReleaseFixedMenuHeight({ - contentSize, - enabled: open, - surfaceNativeID, - revision, + const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({ + open, + anchorRect, + anchorRef, + side, + align, + offset, + scrollable, + maxHeight, }); // The surface is placed once both measurements land. Until then it sits off-screen so it can - // be measured, and it must not animate: Reanimated's web entering path snapshots the element's - // rect when the animation ends and writes that rect back as inline top/left when it cleans the - // keyframe up 750ms later. If the animation ends before placement, the snapshot is the - // off-screen rect and the menu jumps back off-screen. Remounting on placement starts the - // animation from the final position. + // be measured, and it must not animate: an entering animation that ran there would be over + // before anyone saw the menu. Remounting on placement starts the native animation from the + // final position; web starts its own animation on placement. const placed = position !== null; + useWebContentEntering({ placed, surfaceNativeID }); + useEffect(() => { if (!isWeb || !open || !placed || typeof document === "undefined") return undefined; const frame = requestAnimationFrame(() => { @@ -383,7 +358,7 @@ export function AnchoredSurface({ dataSet={surfaceDataSet} style={styles.content} frameStyle={frameStyle} - entering={placed ? contentEntering : undefined} + entering={placed ? nativeContentEntering : undefined} exiting={ !placed || isWeb || !onExited ? undefined diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 56ff2c0578..581b620499 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -344,7 +344,6 @@ function MenuFlyout({ maxHeight={maxHeight} scrollable={scrollable} backdrop={false} - revision={page.id} onPointerEnter={cancelHoverClose} onPointerLeave={handleHoverOut} testID={testID}