fix(app): run menu selections that close the menu on iOS
Selecting an item parked its action in a ref and waited for the Modal's onDismiss to flush it. MenuOverlay returns null as soon as the menu closes, so the Modal unmounted and onDismiss never fired: every close-on-select action was dropped. Creating a terminal from the compact workspace menu did nothing at all. Wait on a timer instead. The grace period was always about giving UIKit time to tear the surface down before a native presenter launches, and a callback fired from inside a subtree being removed is racing its own removal. The sheet had no flush at all, so it was broken the same way for a different reason.
This commit is contained in:
@@ -26,6 +26,17 @@ when you have actually looked at it on a phone.
|
||||
`ContextMenu` is the exception: it defaults to `compactMode="sheet"` and enables native long press.
|
||||
Disable mobile triggering explicitly on draggable rows, where long press belongs to drag instead.
|
||||
|
||||
## Selecting an item on iOS
|
||||
|
||||
An item that closes the menu runs its action after a fixed grace period on iOS, not immediately:
|
||||
a native presenter launched while UIKit is still tearing down the surface can hang. Both surfaces
|
||||
unmount the moment the menu closes, so the wait is a timer rather than the surface's own dismissal
|
||||
callback — a callback fired from inside a subtree that is being removed is racing its own removal,
|
||||
and it loses. Selections used to be dropped entirely for exactly that reason.
|
||||
|
||||
The consequence for callers: an `onSelect` on iOS runs a beat after the press. Don't add a second
|
||||
delay on top of it, and don't read state that the same press mutated.
|
||||
|
||||
## Pages
|
||||
|
||||
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { Platform } from "react-native";
|
||||
import { IOS_TEARDOWN_GRACE_MS, useMenuState } from "./menu-context";
|
||||
|
||||
/**
|
||||
* `Platform.OS` is a plain property on the react-native-web double, so the iOS branch is
|
||||
* reachable by writing to it. The grace period only exists on iOS, which is exactly the platform
|
||||
* whose selections used to be dropped.
|
||||
*/
|
||||
const originalPlatform = Platform.OS;
|
||||
|
||||
function setPlatform(os: string) {
|
||||
Object.assign(Platform, { OS: os });
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
describe("useMenuState selectItem on iOS", () => {
|
||||
beforeEach(() => setPlatform("ios"));
|
||||
|
||||
it("runs a closing item's action once the surface has had time to go away", () => {
|
||||
const onSelect = vi.fn();
|
||||
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
|
||||
|
||||
act(() => result.current.selectItem(onSelect, true));
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
act(() => vi.advanceTimersByTime(IOS_TEARDOWN_GRACE_MS));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves the menu open for an item that does not close it", () => {
|
||||
const onSelect = vi.fn();
|
||||
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
|
||||
|
||||
act(() => result.current.selectItem(onSelect, false));
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMenuState selectItem elsewhere", () => {
|
||||
beforeEach(() => setPlatform("android"));
|
||||
|
||||
it("runs a closing item's action immediately", () => {
|
||||
const onSelect = vi.fn();
|
||||
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
|
||||
|
||||
act(() => result.current.selectItem(onSelect, true));
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -24,12 +24,14 @@ export type MenuPresentation = "popover" | "sheet";
|
||||
/** What a menu does on a compact screen. Wide screens are always a popover. */
|
||||
export type MenuCompactMode = "popover" | "sheet";
|
||||
|
||||
/** How long UIKit is given to finish removing the menu surface before an action runs. */
|
||||
export const IOS_TEARDOWN_GRACE_MS = 250;
|
||||
|
||||
export interface MenuContextValue {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
/** Runs an item's action, closing the menu first when the item asks for it. */
|
||||
selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void;
|
||||
flushPendingSelect: () => void;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
/** Point anchor for menus opened from a gesture rather than a trigger box. */
|
||||
anchorRect: Rect | null;
|
||||
@@ -104,7 +106,6 @@ export function useMenuState({
|
||||
compactMode?: MenuCompactMode;
|
||||
}): MenuContextValue {
|
||||
const triggerRef = useRef<View>(null);
|
||||
const pendingSelectRef = useRef<(() => void) | null>(null);
|
||||
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
|
||||
const [path, setPath] = useState<MenuPath>(MENU_ROOT_PATH);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -126,21 +127,6 @@ export function useMenuState({
|
||||
[setIsOpenState],
|
||||
);
|
||||
|
||||
const flushPendingSelect = useCallback(() => {
|
||||
const pendingSelect = pendingSelectRef.current;
|
||||
pendingSelectRef.current = null;
|
||||
if (!pendingSelect) return;
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
// Native presenters such as PHPicker can hang if launched while an RN
|
||||
// Modal is still completing dismissal on UIKit's side.
|
||||
setTimeout(pendingSelect, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingSelect();
|
||||
}, []);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(onSelect: (() => void) | undefined, closeOnSelect: boolean) => {
|
||||
if (!closeOnSelect) {
|
||||
@@ -148,14 +134,19 @@ export function useMenuState({
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
if (!onSelect) return;
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
pendingSelectRef.current = onSelect ?? null;
|
||||
setOpen(false);
|
||||
// Native presenters such as PHPicker can hang if launched while UIKit is still
|
||||
// tearing down the surface this menu was drawn in. The wait is timed rather than
|
||||
// driven by the surface's own dismissal callback: both surfaces unmount as soon as
|
||||
// the menu closes, so any such callback is racing its own removal and usually loses.
|
||||
setTimeout(onSelect, IOS_TEARDOWN_GRACE_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
onSelect?.();
|
||||
onSelect();
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
@@ -177,7 +168,6 @@ export function useMenuState({
|
||||
open: isOpen,
|
||||
setOpen,
|
||||
selectItem,
|
||||
flushPendingSelect,
|
||||
triggerRef,
|
||||
anchorRect,
|
||||
setAnchorRect,
|
||||
@@ -187,17 +177,6 @@ export function useMenuState({
|
||||
closeSub,
|
||||
goBack,
|
||||
}),
|
||||
[
|
||||
isOpen,
|
||||
setOpen,
|
||||
selectItem,
|
||||
flushPendingSelect,
|
||||
anchorRect,
|
||||
presentation,
|
||||
path,
|
||||
openSub,
|
||||
closeSub,
|
||||
goBack,
|
||||
],
|
||||
[isOpen, setOpen, selectItem, anchorRect, presentation, path, openSub, closeSub, goBack],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -384,12 +384,10 @@ export function AnchoredSurface({
|
||||
export function MenuOverlay({
|
||||
visible,
|
||||
onClose,
|
||||
onDismiss,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDismiss?: () => void;
|
||||
children: ReactElement | null;
|
||||
}): ReactElement | null {
|
||||
const floatingLayer = useOverlayLayer("floating");
|
||||
@@ -438,7 +436,6 @@ export function MenuOverlay({
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onDismiss={onDismiss}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
{overlay}
|
||||
|
||||
@@ -206,7 +206,7 @@ function MenuPopoverSurface({
|
||||
|
||||
return (
|
||||
<MenuSurfaceContext.Provider value={surfaceValue}>
|
||||
<MenuOverlay visible={menu.open} onClose={handleClose} onDismiss={menu.flushPendingSelect}>
|
||||
<MenuOverlay visible={menu.open} onClose={handleClose}>
|
||||
<>
|
||||
<AnchoredSurface
|
||||
open={menu.open}
|
||||
|
||||
Reference in New Issue
Block a user