From 256ce1905ed91b150c05abab1a5eaa6ca362f72e Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 21 Oct 2026 18:54:38 +0000 Subject: [PATCH 1/3] fix(mobile): Android menus follow their button when the hinge moves the split An open anchored menu measured its button once, on open. When the hinge resized the list, the menu paired the new crease with the button's old position and ended up detached from it. The workspace layout now hands overlays the new hinge once the panes stop moving, and an open menu re-measures its button when that hinge changes. Fixes #21962 Co-authored-by: Alex <5401039+PixPMusic@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../src/components/ui/AndroidAnchoredMenu.tsx | 23 +++++++++++++++---- .../layout/AdaptiveWorkspaceLayout.tsx | 12 +++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx index a5015d98ea4b7..145d5c34406fc 100644 --- a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx @@ -6,6 +6,7 @@ import { BackHandler, Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; +import type { VerticalHinge } from "../../lib/layout"; import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement"; import { WindowHingeContext } from "../../native/window-hinge-context"; import { OverlayPortal } from "./OverlayPortal"; @@ -17,12 +18,14 @@ const ANCHOR_GAP = 6; // Both snapshots are taken at open time so later reflows (keyboard show/hide, // screen transitions) can't flip an opens-up menu to opens-down -// mid-presentation. +// mid-presentation. The anchor is re-measured when the hinge changes, since a +// hinge resize moves it with its pane. type AnchorSnapshot = { readonly x: number; readonly y: number; readonly width: number; readonly height: number; + readonly hinge: VerticalHinge | null; readonly keyboardWasVisible: boolean; }; @@ -63,9 +66,18 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const open = useCallback(() => { anchorRef.current?.measureInWindow((x, y, width, height) => { - setAnchor({ x, y, width, height, keyboardWasVisible: keyboardVisible }); + setAnchor({ x, y, width, height, hinge: windowHinge, keyboardWasVisible: keyboardVisible }); }); - }, [keyboardVisible]); + }, [keyboardVisible, windowHinge]); + + useEffect(() => { + if (anchor === null || anchor.hinge === windowHinge) return; + anchorRef.current?.measureInWindow((x, y, width, height) => { + setAnchor((current) => + current === null ? null : { ...current, x, y, width, height, hinge: windowHinge }, + ); + }); + }, [anchor, windowHinge]); const measureOverlay = useCallback(() => { overlayRef.current?.measureInWindow((x, y, width, height) => { @@ -95,6 +107,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { (action) => !(action.attributes?.hidden ?? false), ); + const hinge = anchor?.hinge ?? null; const local = anchor === null || overlay === null ? null @@ -113,7 +126,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { overlayWidth: overlay.width, desiredWidth: desiredMenuWidth, margin: SCREEN_MARGIN, - hinge: windowHinge && { x: windowHinge.x - overlay.x, width: windowHinge.width }, + hinge: hinge && { x: hinge.x - overlay.x, width: hinge.width }, }); // The keyboard stays up while the menu is open (in-window overlay, no // focus change), so the space it covers is not usable — without this the @@ -176,7 +189,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { - + Date: Wed, 21 Oct 2026 19:20:36 +0000 Subject: [PATCH 2/3] fix(mobile): re-measure menus after a resize that ends on its starting hinge Bending and flattening again before the panes settle left the published hinge unchanged, so a menu opened mid-resize kept its mid-resize anchor. The layout now publishes a new hinge value each time the panes come to rest. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ui/AndroidAnchoredMenu.tsx | 17 ++++++++--------- .../features/layout/AdaptiveWorkspaceLayout.tsx | 14 +++++++++----- apps/mobile/src/native/window-hinge-context.ts | 10 ++++++++-- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx index 145d5c34406fc..c04595d8e91ea 100644 --- a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx @@ -1,12 +1,11 @@ import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu"; -import type { ReactNode } from "react"; +import type { ContextType, ReactNode } from "react"; import { use, useCallback, useEffect, useRef, useState } from "react"; import type { StyleProp, ViewInstance, ViewStyle } from "react-native"; import { BackHandler, Pressable, ScrollView, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; -import type { VerticalHinge } from "../../lib/layout"; import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement"; import { WindowHingeContext } from "../../native/window-hinge-context"; import { OverlayPortal } from "./OverlayPortal"; @@ -18,14 +17,14 @@ const ANCHOR_GAP = 6; // Both snapshots are taken at open time so later reflows (keyboard show/hide, // screen transitions) can't flip an opens-up menu to opens-down -// mid-presentation. The anchor is re-measured when the hinge changes, since a -// hinge resize moves it with its pane. +// mid-presentation. The anchor is re-measured when the workspace publishes a new +// hinge value, since a hinge resize moves it with its pane. type AnchorSnapshot = { readonly x: number; readonly y: number; readonly width: number; readonly height: number; - readonly hinge: VerticalHinge | null; + readonly windowHinge: ContextType; readonly keyboardWasVisible: boolean; }; @@ -66,15 +65,15 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const open = useCallback(() => { anchorRef.current?.measureInWindow((x, y, width, height) => { - setAnchor({ x, y, width, height, hinge: windowHinge, keyboardWasVisible: keyboardVisible }); + setAnchor({ x, y, width, height, windowHinge, keyboardWasVisible: keyboardVisible }); }); }, [keyboardVisible, windowHinge]); useEffect(() => { - if (anchor === null || anchor.hinge === windowHinge) return; + if (anchor === null || anchor.windowHinge === windowHinge) return; anchorRef.current?.measureInWindow((x, y, width, height) => { setAnchor((current) => - current === null ? null : { ...current, x, y, width, height, hinge: windowHinge }, + current === null ? null : { ...current, x, y, width, height, windowHinge }, ); }); }, [anchor, windowHinge]); @@ -107,7 +106,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { (action) => !(action.attributes?.hidden ?? false), ); - const hinge = anchor?.hinge ?? null; + const hinge = anchor?.windowHinge.hinge ?? null; const local = anchor === null || overlay === null ? null diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c7788cfe846e8..da996144e4e17 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -586,15 +586,19 @@ function AdaptiveWorkspaceLayoutContent( const listPaneFloor = listPaneSizing.listPaneFloor ?? 0; const listPaneCeiling = listPaneSizing.listPaneCeiling ?? 0; const listPaneRevision = listPaneSizing.revision; - // A hinge resize slides overlay anchors with the panes. Overlays get the new hinge only once - // the panes are at rest, so they never pair it with an anchor that is still moving. - const [overlayHinge, setOverlayHinge] = useState(windowHinge); + // A hinge resize slides overlay anchors with the panes. Overlays get a new value only once the + // panes are at rest, so they never pair the hinge with an anchor that is still moving. A + // resize can end on the hinge it started from, so every resize publishes a new value. + const [overlayHinge, setOverlayHinge] = useState({ + hinge: windowHinge, + revision: listPaneRevision, + }); if ( - overlayHinge !== windowHinge && + (overlayHinge.hinge !== windowHinge || overlayHinge.revision !== listPaneRevision) && listPaneSizing.listPaneTarget === layout.listPaneWidth && listPaneFloor === listPaneCeiling ) { - setOverlayHinge(windowHinge); + setOverlayHinge({ hinge: windowHinge, revision: listPaneRevision }); } // Android lays the content out at its new width as soon as a pane toggles, then holds it // against the edge that stays put while the other one slides, so nothing reflows after the diff --git a/apps/mobile/src/native/window-hinge-context.ts b/apps/mobile/src/native/window-hinge-context.ts index f2e031b335d0a..2f7747ce8bdb9 100644 --- a/apps/mobile/src/native/window-hinge-context.ts +++ b/apps/mobile/src/native/window-hinge-context.ts @@ -1,5 +1,11 @@ import { createContext } from "react"; import type { VerticalHinge } from "../lib/layout"; -/** A creased vertical hinge in window coordinates, for overlays placed in window space. */ -export const WindowHingeContext = createContext(null); +/** + * A creased vertical hinge in window coordinates, for overlays placed in window space. The + * workspace publishes a new value each time its panes come to rest, since moving panes carry + * overlay anchors with them. + */ +export const WindowHingeContext = createContext<{ readonly hinge: VerticalHinge | null }>({ + hinge: null, +}); From ff7985dd09294aedc42fd1c045187a1987676979 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 21 Oct 2026 19:34:52 +0000 Subject: [PATCH 3/3] docs(mobile): say the hinge handover waits for the list pane resize Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx | 4 ++-- apps/mobile/src/native/window-hinge-context.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index da996144e4e17..1cb32759867cf 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -586,8 +586,8 @@ function AdaptiveWorkspaceLayoutContent( const listPaneFloor = listPaneSizing.listPaneFloor ?? 0; const listPaneCeiling = listPaneSizing.listPaneCeiling ?? 0; const listPaneRevision = listPaneSizing.revision; - // A hinge resize slides overlay anchors with the panes. Overlays get a new value only once the - // panes are at rest, so they never pair the hinge with an anchor that is still moving. A + // A hinge resize slides overlay anchors with the list pane. Overlays get a new value only once + // that resize settles, so they never pair the hinge with an anchor that is still moving. A // resize can end on the hinge it started from, so every resize publishes a new value. const [overlayHinge, setOverlayHinge] = useState({ hinge: windowHinge, diff --git a/apps/mobile/src/native/window-hinge-context.ts b/apps/mobile/src/native/window-hinge-context.ts index 2f7747ce8bdb9..7ff335f57340d 100644 --- a/apps/mobile/src/native/window-hinge-context.ts +++ b/apps/mobile/src/native/window-hinge-context.ts @@ -3,8 +3,8 @@ import type { VerticalHinge } from "../lib/layout"; /** * A creased vertical hinge in window coordinates, for overlays placed in window space. The - * workspace publishes a new value each time its panes come to rest, since moving panes carry - * overlay anchors with them. + * workspace publishes a new value each time a list pane resize settles, since a hinge resize + * carries overlay anchors with the pane. */ export const WindowHingeContext = createContext<{ readonly hinge: VerticalHinge | null }>({ hinge: null,