From 3778d0ad7bf1de22df8f8937937d0c316eed0d61 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 22:05:54 +0200 Subject: [PATCH] Fix mobile sidebar swipe and drag failures (#2709) * fix(app): prevent text selection during Android swipes Gesture interception did not cancel the native view where the touch began, leaving Android's pending text-selection action alive. * fix(app): restore iOS sidebar close swipes Fabric could fail to attach close handlers when their target lived inside retained but display-hidden panel content. Keep a registered native gesture host mounted outside the presentation wrapper so close swipes work over nested lists without activating rows. * fix(app): recover sidebar drags after cancellation Cancelled native drags could leave a lifted row and the shared outer list locked. Scope terminal cleanup to the owning gesture session and release the lock on cancellation or unmount. * test(app): clean up partial drag fixtures --- packages/app/maestro/README.md | 25 ++ .../maestro/ios-sidebar-close-regression.yaml | 40 ++ .../sidebar-drag-cancellation-regression.yaml | 105 +++++ .../test-sidebar-drag-cancellation-ios.sh | 170 +++++++ .../src/components/draggable-list.native.tsx | 2 + .../src/components/draggable-list.types.ts | 2 + packages/app/src/components/left-sidebar.tsx | 3 + .../src/components/sidebar-workspace-list.tsx | 11 + .../app/src/mobile-panels/presentation.tsx | 41 +- ...eact-native-draggable-flatlist+4.0.3.patch | 419 +++++++++++++++++- .../react-native-gesture-handler+2.28.0.patch | 170 ++++++- 11 files changed, 966 insertions(+), 22 deletions(-) create mode 100644 packages/app/maestro/ios-sidebar-close-regression.yaml create mode 100644 packages/app/maestro/sidebar-drag-cancellation-regression.yaml create mode 100755 packages/app/maestro/test-sidebar-drag-cancellation-ios.sh diff --git a/packages/app/maestro/README.md b/packages/app/maestro/README.md index 88bf8f249..b4073532e 100644 --- a/packages/app/maestro/README.md +++ b/packages/app/maestro/README.md @@ -3,6 +3,31 @@ This directory contains local mobile UI flows. Keep flows small enough that a failure screenshot proves the intended behavior, not just that the app launched. +## iOS Sidebar Close Regression + +`ios-sidebar-close-regression.yaml` exercises close swipes over a semantic header +and the nested workspace list without activating the content below the swipe. +Start the `sh.paseo.debug` dev client against the intended Metro server first; +the flow preserves that running connection. + +```bash +maestro test packages/app/maestro/ios-sidebar-close-regression.yaml --udid +``` + +## iOS Sidebar Drag Cancellation Regression + +`test-sidebar-drag-cancellation-ios.sh` creates three temporary projects and +uses real native slow swipes to cover successful reorder, a second touch during +the drop spring, horizontal close cancellation, outer-list scroll recovery, and +a subsequent reorder. The harness removes its daemon projects and local fixture +directories on exit. + +Start the debug dev client against the intended Metro server, then run: + +```bash +bash packages/app/maestro/test-sidebar-drag-cancellation-ios.sh +``` + ## New Workspace Android Flow Use these files when debugging or extending workspace creation on Android: diff --git a/packages/app/maestro/ios-sidebar-close-regression.yaml b/packages/app/maestro/ios-sidebar-close-regression.yaml new file mode 100644 index 000000000..2b61a39d9 --- /dev/null +++ b/packages/app/maestro/ios-sidebar-close-regression.yaml @@ -0,0 +1,40 @@ +appId: sh.paseo.debug +--- +# Establish History with the sidebar closed. The host filter then proves a close +# swipe did not activate the row below it. +- tapOn: + id: "menu-button" +- tapOn: + id: "sidebar-sessions" +- assertVisible: + id: "sessions-host-filter-trigger" + +# A close swipe over a semantic header row must close without navigating. +- swipe: + direction: RIGHT + duration: 300 +- assertVisible: + id: "sidebar-schedules" +- swipe: + start: "85%,19%" + end: "10%,19%" + duration: 300 +- assertNotVisible: + id: "sidebar-schedules" +- assertVisible: + id: "sessions-host-filter-trigger" + +# A close swipe over the nested workspace list must beat horizontal list intent. +- swipe: + direction: RIGHT + duration: 300 +- assertVisible: + id: "sidebar-project-workspace-list-scroll" +- swipe: + start: "85%,46%" + end: "10%,46%" + duration: 300 +- assertNotVisible: + id: "sidebar-project-workspace-list-scroll" +- assertVisible: + id: "sessions-host-filter-trigger" diff --git a/packages/app/maestro/sidebar-drag-cancellation-regression.yaml b/packages/app/maestro/sidebar-drag-cancellation-regression.yaml new file mode 100644 index 000000000..af927dd84 --- /dev/null +++ b/packages/app/maestro/sidebar-drag-cancellation-regression.yaml @@ -0,0 +1,105 @@ +appId: ${PASEO_MAESTRO_APP_ID} +--- +- launchApp: + clearState: true + +- runFlow: flows/dev-client.yaml +- runFlow: flows/connect-direct-if-welcome.yaml + +- extendedWaitUntil: + visible: + id: "menu-button" + timeout: 60000 + +- tapOn: + id: "menu-button" + +- extendedWaitUntil: + visible: + id: "sidebar-project-list" + timeout: 10000 + +- extendedWaitUntil: + visible: + text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions" + timeout: 30000 +- extendedWaitUntil: + visible: + text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions" + timeout: 30000 + +# A slow swipe stays inside the stationary slop long enough to arm the native +# long-press drag before it moves the second project above the first. +- assertVisible: + text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions" + above: + text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions" +- swipe: + from: + text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions" + direction: UP + duration: 20000 + waitToSettleTimeoutMs: 0 + +# A second touch during the drop spring must not cancel the committed reorder. +- tapOn: + text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions" +- assertVisible: + text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions" + above: + text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions" + +# Horizontal intent after the lift belongs to panel close. It must cancel the +# drag without committing another reorder or leaving the outer list locked. +- swipe: + from: + text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions" + direction: LEFT + duration: 20000 +- assertNotVisible: + id: "sidebar-project-list" + +- tapOn: + id: "menu-button" +- extendedWaitUntil: + visible: + id: "sidebar-project-list" + timeout: 10000 +- assertVisible: + text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions" + above: + text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions" + +# Reaching a fixture at the bottom proves the nestable outer scroll was +# unlocked by cancellation. +- scrollUntilVisible: + element: + text: ".*${PASEO_MAESTRO_DRAG_Z_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_Z_NAME}, Project actions" + direction: DOWN + timeout: 30000 + speed: 40 + visibilityPercentage: 50 + centerElement: true +- assertVisible: + text: ".*${PASEO_MAESTRO_DRAG_Z_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_Z_NAME}, Project actions" + +- scrollUntilVisible: + element: + text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions" + direction: UP + timeout: 30000 + speed: 40 + visibilityPercentage: 50 + centerElement: true + +# A real reorder after cancellation proves the list is not merely scrollable; +# its drag recognizer and semantic order update both recovered. +- swipe: + from: + text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions" + direction: UP + duration: 20000 +- assertVisible: + text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions" + above: + text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions" diff --git a/packages/app/maestro/test-sidebar-drag-cancellation-ios.sh b/packages/app/maestro/test-sidebar-drag-cancellation-ios.sh new file mode 100755 index 000000000..2309f8430 --- /dev/null +++ b/packages/app/maestro/test-sidebar-drag-cancellation-ios.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# iOS native regression harness for sidebar drag cancellation and recovery. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/sidebar-drag-cancellation-regression.yaml" +FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro" +OUT_DIR="/tmp/paseo-sidebar-drag-cancellation-$(date +%s)" +CLIENT_EXPORTS="$REPO_ROOT/packages/client/dist/daemon-client.js" +RELAY_EXPORTS="$REPO_ROOT/node_modules/@getpaseo/relay/dist/e2ee.js" +FIXTURE_ROOT="" +PROJECT_IDS_FILE="$OUT_DIR/project-ids.json" + +export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}" +export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}" +export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}" +export PASEO_MAESTRO_DAEMON_HEALTH_URL="${PASEO_MAESTRO_DAEMON_HEALTH_URL:-http://127.0.0.1:6767/api/health}" + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +require_command git +require_command curl +require_command maestro +require_command node +require_command perl +require_command xcrun + +if [ ! -f "$CLIENT_EXPORTS" ] || [ ! -f "$RELAY_EXPORTS" ]; then + echo "Missing client or relay build artifacts." >&2 + echo "Run: npm run build:server" >&2 + exit 1 +fi + +if ! curl --fail --silent --show-error --max-time 3 "$PASEO_MAESTRO_DAEMON_HEALTH_URL" >/dev/null; then + echo "Paseo daemon is unavailable at $PASEO_MAESTRO_DAEMON_HEALTH_URL" >&2 + exit 1 +fi + +FIXTURE_ROOT="$(mktemp -d /tmp/paseo-sidebar-drag-fixture-XXXXXX)" +export PASEO_MAESTRO_DRAG_A_NAME="000-paseo-drag-a-$(basename "$FIXTURE_ROOT")" +export PASEO_MAESTRO_DRAG_B_NAME="001-paseo-drag-b-$(basename "$FIXTURE_ROOT")" +export PASEO_MAESTRO_DRAG_Z_NAME="zzz-paseo-drag-z-$(basename "$FIXTURE_ROOT")" + +mkdir -p "$OUT_DIR/flows" + +if [ -z "${PASEO_MAESTRO_IOS_UDID:-}" ]; then + export PASEO_MAESTRO_IOS_UDID + PASEO_MAESTRO_IOS_UDID="$({ xcrun simctl list devices booted -j || true; } | node -e ' + let input = ""; + process.stdin.on("data", (chunk) => (input += chunk)); + process.stdin.on("end", () => { + const devices = Object.values(JSON.parse(input).devices ?? {}).flat(); + const booted = devices.find((device) => device.state === "Booted"); + if (booted?.udid) process.stdout.write(booted.udid); + }); + ')" +fi + +if [ -z "$PASEO_MAESTRO_IOS_UDID" ]; then + echo "No booted iOS simulator found." >&2 + exit 1 +fi + +render_flow() { + local source="$1" + local target="$2" + perl -0pe ' + s/^appId: sh\.paseo$/appId: $ENV{PASEO_MAESTRO_APP_ID}/m; + s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g; + s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g; + s/\$\{PASEO_MAESTRO_DRAG_A_NAME\}/$ENV{PASEO_MAESTRO_DRAG_A_NAME}/g; + s/\$\{PASEO_MAESTRO_DRAG_B_NAME\}/$ENV{PASEO_MAESTRO_DRAG_B_NAME}/g; + s/\$\{PASEO_MAESTRO_DRAG_Z_NAME\}/$ENV{PASEO_MAESTRO_DRAG_Z_NAME}/g; + ' "$source" > "$target" +} + +for project_name in \ + "$PASEO_MAESTRO_DRAG_A_NAME" \ + "$PASEO_MAESTRO_DRAG_B_NAME" \ + "$PASEO_MAESTRO_DRAG_Z_NAME"; do + project_path="$FIXTURE_ROOT/$project_name" + mkdir -p "$project_path" + git -C "$project_path" init >/dev/null + git -C "$project_path" checkout -b main >/dev/null 2>&1 || true + git -C "$project_path" config user.name "Paseo Maestro" + git -C "$project_path" config user.email "maestro@getpaseo.local" + printf '# Sidebar drag cancellation fixture\n' > "$project_path/README.md" + git -C "$project_path" add README.md + git -C "$project_path" commit -m "Initial commit" >/dev/null +done + +cleanup() { + if [ -s "$PROJECT_IDS_FILE" ]; then + REPO_ROOT="$REPO_ROOT" PROJECT_IDS_FILE="$PROJECT_IDS_FILE" node --input-type=module <<'NODE' || true +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import WebSocket from "ws"; + +const moduleUrl = pathToFileURL(`${process.env.REPO_ROOT}/packages/client/dist/daemon-client.js`).href; +const { DaemonClient } = await import(moduleUrl); +const projectIds = JSON.parse(await readFile(process.env.PROJECT_IDS_FILE, "utf8")); +const client = new DaemonClient({ + url: process.env.PASEO_MAESTRO_DAEMON_WS_URL, + clientId: `maestro-sidebar-drag-cleanup-${Date.now()}`, + clientType: "cli", + webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }), +}); + +try { + await client.connect(); + for (const projectId of projectIds) { + await client.removeProject(projectId).catch(() => undefined); + } +} finally { + await client.close().catch(() => undefined); +} +NODE + fi + rm -rf "$FIXTURE_ROOT" +} +trap cleanup EXIT + +REPO_ROOT="$REPO_ROOT" FIXTURE_ROOT="$FIXTURE_ROOT" PROJECT_IDS_FILE="$PROJECT_IDS_FILE" node --input-type=module <<'NODE' +import { writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import WebSocket from "ws"; + +const moduleUrl = pathToFileURL(`${process.env.REPO_ROOT}/packages/client/dist/daemon-client.js`).href; +const { DaemonClient } = await import(moduleUrl); +const projectNames = [ + process.env.PASEO_MAESTRO_DRAG_A_NAME, + process.env.PASEO_MAESTRO_DRAG_B_NAME, + process.env.PASEO_MAESTRO_DRAG_Z_NAME, +]; +const client = new DaemonClient({ + url: process.env.PASEO_MAESTRO_DAEMON_WS_URL, + clientId: `maestro-sidebar-drag-setup-${Date.now()}`, + clientType: "cli", + webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }), +}); + +try { + await client.connect(); + const projectIds = []; + for (const projectName of projectNames) { + const payload = await client.addProject(`${process.env.FIXTURE_ROOT}/${projectName}`); + if (payload.error || !payload.project) { + throw new Error(payload.error ?? `addProject returned no project for ${projectName}`); + } + projectIds.push(payload.project.id); + await writeFile(process.env.PROJECT_IDS_FILE, JSON.stringify(projectIds)); + } +} finally { + await client.close().catch(() => undefined); +} +NODE + +FLOW="$OUT_DIR/sidebar-drag-cancellation-regression.rendered.yaml" +render_flow "$FLOW_TEMPLATE" "$FLOW" +render_flow "$FLOW_TEMPLATE_DIR/flows/dev-client.yaml" "$OUT_DIR/flows/dev-client.yaml" +render_flow "$FLOW_TEMPLATE_DIR/flows/connect-direct-if-welcome.yaml" "$OUT_DIR/flows/connect-direct-if-welcome.yaml" + +echo "Running sidebar drag cancellation regression on $PASEO_MAESTRO_IOS_UDID" +echo "Artifacts: $OUT_DIR" +(cd "$OUT_DIR" && maestro test "$FLOW" --udid "$PASEO_MAESTRO_IOS_UDID") diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index 554dcafa3..ee5acb7f5 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -30,6 +30,7 @@ export function DraggableList({ onRefresh, extraData, simultaneousGestureRef, + gestureHostPresented, waitFor, onDragBegin: onDragBeginProp, nestable = false, @@ -118,6 +119,7 @@ export function DraggableList({ scrollEnabled={scrollEnabled} extraData={extraData} simultaneousHandlers={simultaneousHandlers} + dragGestureHostPresented={gestureHostPresented} // Higher activation distance reduces accidental drag capture while nested // lists are inside a scroll container. activationDistance={20} diff --git a/packages/app/src/components/draggable-list.types.ts b/packages/app/src/components/draggable-list.types.ts index 74b365ac6..d541b5d73 100644 --- a/packages/app/src/components/draggable-list.types.ts +++ b/packages/app/src/components/draggable-list.types.ts @@ -49,6 +49,8 @@ export interface DraggableListProps { extraData?: unknown; /** Gesture ref for simultaneous handling with parent gestures (e.g., sidebar close) */ simultaneousGestureRef?: MutableRefObject; + /** Whether the retained native gesture host is currently presented. */ + gestureHostPresented?: boolean; /** Gesture ref(s) that the list should wait for before handling scroll */ waitFor?: MutableRefObject | MutableRefObject[]; /** Called when a drag gesture begins (before items are reordered) */ diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index d64a695b8..d80046695 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -55,6 +55,7 @@ import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; +import { useIsMobilePanelPresented } from "@/mobile-panels/provider"; import { buildOpenProjectRoute, buildNewWorkspaceRoute, @@ -619,6 +620,7 @@ function MobileSidebar({ const isSessionsActive = pathname.includes("/sessions"); const isSchedulesActive = pathname.includes("/schedules"); const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture(); + const dragGestureHostPresented = useIsMobilePanelPresented("agent-list"); const handleViewMore = useCallback(() => { closeSidebar(); @@ -714,6 +716,7 @@ function MobileSidebar({ onWorkspacePress={handleWorkspacePress} onAddProject={handleOpenProject} parentGestureRef={closeGestureRef} + dragGestureHostPresented={dragGestureHostPresented} listHeaderComponent={workspacesSectionHeaderElement} /> )} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 8f6f0ef34..87d3c367c 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -249,6 +249,7 @@ interface SidebarWorkspaceListProps { listHeaderComponent?: ReactElement | null; /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ parentGestureRef?: MutableRefObject; + dragGestureHostPresented?: boolean; } interface ProjectHeaderRowProps { @@ -1605,6 +1606,7 @@ function ProjectBlock({ isDragging, dragHandleProps, useNestable, + dragGestureHostPresented, creatingWorkspaceIds, activeWorkspaceSelection, hostLabelByServerId, @@ -1630,6 +1632,7 @@ function ProjectBlock({ isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; useNestable: boolean; + dragGestureHostPresented?: boolean; creatingWorkspaceIds: ReadonlySet; activeWorkspaceSelection: ActiveWorkspaceSelection | null; hostLabelByServerId: ReadonlyMap; @@ -1806,6 +1809,7 @@ function ProjectBlock({ useDragHandle nestable={useNestable} simultaneousGestureRef={parentGestureRef} + gestureHostPresented={dragGestureHostPresented} containerStyle={styles.workspaceListContainer} /> {canToggleWorkspaces ? ( @@ -1886,6 +1890,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo previous.isDragging === next.isDragging && previous.dragHandleProps === next.dragHandleProps && previous.useNestable === next.useNestable && + previous.dragGestureHostPresented === next.dragGestureHostPresented && previous.creatingWorkspaceIds === next.creatingWorkspaceIds && areProjectBlockSelectionsEqual(previous, next) ); @@ -1936,6 +1941,7 @@ export function SidebarWorkspaceList({ listFooterComponent, listHeaderComponent, parentGestureRef, + dragGestureHostPresented, }: SidebarWorkspaceListProps) { const pathname = usePathname(); const hosts = useHosts(); @@ -1991,6 +1997,7 @@ export function SidebarWorkspaceList({ listFooterComponent={listFooterComponent} listHeaderComponent={listHeaderComponent} parentGestureRef={parentGestureRef} + dragGestureHostPresented={dragGestureHostPresented} pathname={pathname} hostLabelByServerId={hostLabelByServerId} showHostLabels={showHostLabels} @@ -2065,6 +2072,7 @@ function ProjectModeList({ listFooterComponent, listHeaderComponent, parentGestureRef, + dragGestureHostPresented, pathname, hostLabelByServerId, showHostLabels, @@ -2279,6 +2287,7 @@ function ProjectModeList({ isDragging={dragState.isDragging} dragHandleProps={dragState.dragHandleProps} useNestable={platformIsNative} + dragGestureHostPresented={dragGestureHostPresented} creatingWorkspaceIds={creatingWorkspaceIds} activeWorkspaceSelection={activeWorkspaceSelection} hostLabelByServerId={hostLabelByServerId} @@ -2302,6 +2311,7 @@ function ProjectModeList({ onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, + dragGestureHostPresented, projectIconByProjectViewKey, selectionEnabled, shortcutIndexByWorkspaceKey, @@ -2399,6 +2409,7 @@ function ProjectModeList({ useDragHandle nestable={platformIsNative} simultaneousGestureRef={parentGestureRef} + gestureHostPresented={dragGestureHostPresented} containerStyle={styles.projectListContainer} /> )} diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index ca0987756..b050a74a0 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -69,24 +69,33 @@ export function MobilePanelOverlay({ } return ( - - + {/* Fabric needs an always-mounted native host to attach the close handler while the + retained panel content is hidden. nativeID keeps that host registered. */} + - - + + + + - - - {children} - - - + + {children} + + + + ); } diff --git a/patches/react-native-draggable-flatlist+4.0.3.patch b/patches/react-native-draggable-flatlist+4.0.3.patch index d85ed9fc1..87403efb0 100644 --- a/patches/react-native-draggable-flatlist+4.0.3.patch +++ b/patches/react-native-draggable-flatlist+4.0.3.patch @@ -1,8 +1,360 @@ +diff --git a/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts b/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts +index d3a4f1f..083bf19 100644 +--- a/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts ++++ b/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts +@@ -16,12 +16,14 @@ declare const _default: (props: Omit, "keyExtractor" | "data + keyExtractor: (item: T, index: number) => string; + onDragBegin?: ((index: number) => void) | undefined; + onDragEnd?: ((params: import("../types").DragEndParams) => void) | undefined; ++ onDragTerminate?: (() => void) | undefined; + onPlaceholderIndexChange?: ((placeholderIndex: number) => void) | undefined; + onRelease?: ((index: number) => void) | undefined; + onScrollOffsetChange?: ((scrollOffset: number) => void) | undefined; + renderItem: import("../types").RenderItem; + renderPlaceholder?: import("../types").RenderPlaceholder | undefined; + simultaneousHandlers?: React.Ref | React.Ref[] | undefined; ++ dragGestureHostPresented?: boolean | undefined; + outerScrollOffset?: Animated.SharedValue | undefined; + onAnimValInit?: ((animVals: { + activeCellOffset: import("react-native-reanimated").SharedValue; +diff --git a/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts b/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts +index d3c6cbb..36e9645 100644 +--- a/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts ++++ b/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts +@@ -15,12 +15,14 @@ export declare const NestableDraggableFlatList: (props: Omit string; + onDragBegin?: ((index: number) => void) | undefined; + onDragEnd?: ((params: import("../types").DragEndParams) => void) | undefined; ++ onDragTerminate?: (() => void) | undefined; + onPlaceholderIndexChange?: ((placeholderIndex: number) => void) | undefined; + onRelease?: ((index: number) => void) | undefined; + onScrollOffsetChange?: ((scrollOffset: number) => void) | undefined; + renderItem: import("../types").RenderItem; + renderPlaceholder?: import("../types").RenderPlaceholder | undefined; + simultaneousHandlers?: React.Ref | React.Ref[] | undefined; ++ dragGestureHostPresented?: boolean | undefined; + outerScrollOffset?: Animated.SharedValue | undefined; + onAnimValInit?: ((animVals: { + activeCellOffset: import("react-native-reanimated").SharedValue; +diff --git a/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts b/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts +index 2abb4a2..8ca967f 100644 +--- a/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts ++++ b/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts +@@ -23,12 +23,16 @@ export declare type DraggableFlatListProps = Modify, { + keyExtractor: (item: T, index: number) => string; + onDragBegin?: (index: number) => void; + onDragEnd?: (params: DragEndParams) => void; ++ /** @internal Used by the nestable wrapper to release its outer-scroll lock on cancellation. */ ++ onDragTerminate?: () => void; + onPlaceholderIndexChange?: (placeholderIndex: number) => void; + onRelease?: (index: number) => void; + onScrollOffsetChange?: (scrollOffset: number) => void; + renderItem: RenderItem; + renderPlaceholder?: RenderPlaceholder; + simultaneousHandlers?: React.Ref | React.Ref[]; ++ /** @internal Forces handler reattachment when a retained native host is presented. */ ++ dragGestureHostPresented?: boolean; + outerScrollOffset?: Animated.SharedValue; + onAnimValInit?: (animVals: ReturnType) => void; + itemEnteringAnimation?: AnimateProps["entering"]; diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx -index 7c88afc..b630940 100644 +index 7c88afc..b2f103e 100644 --- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx +++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx -@@ -332,6 +332,13 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -19,6 +19,7 @@ import { + } from "react-native-gesture-handler"; + import Animated, { + runOnJS, ++ runOnUI, + useAnimatedReaction, + useAnimatedScrollHandler, + useSharedValue, +@@ -92,6 +93,17 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + activeCellOffset.value = -1; + setActiveKey(null); + }); ++ const terminalHandled = useSharedValue(false); ++ const gestureSessionId = useSharedValue(0); ++ const gestureSessionActive = useSharedValue(false); ++ const currentGestureSessionRef = useRef<{ ++ id: number; ++ active: boolean; ++ } | null>(null); ++ const activeDragRef = useRef<{ ++ sessionId: number; ++ index: number; ++ } | null>(null); + + const { + dragHitSlop = DEFAULT_PROPS.dragHitSlop, +@@ -144,19 +156,88 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + }); + }, [props.data, keyExtractor, keyToIndexRef]); + ++ const onDragCancel = useStableCallback((sessionId: number, index: number) => { ++ if (activeDragRef.current?.sessionId !== sessionId) return; ++ activeDragRef.current = null; ++ reset(); ++ props.onRelease?.(index); ++ props.onDragTerminate?.(); ++ }); ++ ++ useEffect(() => { ++ return () => { ++ const activeDrag = activeDragRef.current; ++ if (!activeDrag) return; ++ activeDragRef.current = null; ++ propsRef.current.onRelease?.(activeDrag.index); ++ propsRef.current.onDragTerminate?.(); ++ }; ++ }, [propsRef]); ++ ++ const onGestureBegin = useStableCallback((sessionId: number) => { ++ currentGestureSessionRef.current = { id: sessionId, active: true }; ++ }); ++ ++ const onGestureFinalize = useStableCallback( ++ (sessionId: number, committed: boolean) => { ++ if (currentGestureSessionRef.current?.id === sessionId) { ++ currentGestureSessionRef.current.active = false; ++ } ++ if (!committed && activeDragRef.current?.sessionId === sessionId) { ++ onDragCancel(sessionId, activeDragRef.current.index); ++ } ++ } ++ ); ++ ++ const onDragActivationRejected = useStableCallback((sessionId: number) => { ++ if (activeDragRef.current?.sessionId === sessionId) { ++ onDragCancel(sessionId, activeDragRef.current.index); ++ } ++ }); ++ ++ const activateDragOnUI = ( ++ sessionId: number, ++ index: number, ++ cellOffset: number, ++ cellSize: number, ++ hasCellMeasurements: boolean ++ ) => { ++ "worklet"; ++ if ( ++ gestureSessionId.value !== sessionId || ++ !gestureSessionActive.value || ++ terminalHandled.value ++ ) { ++ runOnJS(onDragActivationRejected)(sessionId); ++ return; ++ } ++ if (hasCellMeasurements) { ++ activeCellOffset.value = cellOffset; ++ activeCellSize.value = cellSize; ++ } ++ spacerIndexAnim.value = index; ++ activeIndexAnim.value = index; ++ }; ++ + const drag = useStableCallback((activeKey: string) => { +- if (disabled.value) return; ++ const gestureSession = currentGestureSessionRef.current; ++ if (disabled.value || !gestureSession?.active) return; + const index = keyToIndexRef.current.get(activeKey); + const cellData = cellDataRef.current.get(activeKey); +- if (cellData) { +- activeCellOffset.value = cellData.measurements.offset; +- activeCellSize.value = cellData.measurements.size; +- } + + const { onDragBegin } = propsRef.current; + if (index !== undefined) { +- spacerIndexAnim.value = index; +- activeIndexAnim.value = index; ++ runOnUI(activateDragOnUI)( ++ gestureSession.id, ++ index, ++ cellData?.measurements.offset ?? -1, ++ cellData?.measurements.size ?? -1, ++ Boolean(cellData) ++ ); ++ activeDragRef.current = { ++ sessionId: gestureSession.id, ++ index, ++ }; + setActiveKey(activeKey); + onDragBegin?.(index); + } +@@ -218,8 +299,25 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + props.onRelease?.(index); + }); + ++ const finalizeCancelledDrag = () => { ++ "worklet"; ++ const activeIndex = activeIndexAnim.value; ++ if (terminalHandled.value || activeIndex < 0) return; ++ ++ terminalHandled.value = true; ++ isTouchActiveNative.value = false; ++ touchTranslate.value = 0; ++ activeIndexAnim.value = -1; ++ spacerIndexAnim.value = -1; ++ activeCellSize.value = -1; ++ activeCellOffset.value = -1; ++ disabled.value = false; ++ }; ++ + const onDragEnd = useStableCallback( +- ({ from, to }: { from: number; to: number }) => { ++ ({ from, to, sessionId }: { from: number; to: number; sessionId: number }) => { ++ if (activeDragRef.current?.sessionId !== sessionId) return; ++ activeDragRef.current = null; + const { onDragEnd, data } = props; + + const newData = [...data]; +@@ -238,26 +336,6 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + props.onPlaceholderIndexChange?.(index); + }); + +- // Handle case where user ends drag without moving their finger. +- useAnimatedReaction( +- () => { +- return isTouchActiveNative.value; +- }, +- (cur, prev) => { +- if (cur !== prev && !cur) { +- const hasMoved = !!touchTranslate.value; +- if (!hasMoved && activeIndexAnim.value >= 0 && !disabled.value) { +- runOnJS(onRelease)(activeIndexAnim.value); +- runOnJS(onDragEnd)({ +- from: activeIndexAnim.value, +- to: spacerIndexAnim.value, +- }); +- } +- } +- }, +- [isTouchActiveNative, onDragEnd, onRelease] +- ); +- + useAnimatedReaction( + () => { + return spacerIndexAnim.value; +@@ -271,11 +349,19 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + ); + + const gestureDisabled = useSharedValue(false); ++ const touchStartX = useSharedValue(0); ++ const touchStartY = useSharedValue(0); ++ const coordinatesDragWithExternalGesture = Boolean(props.simultaneousHandlers); + +- const panGesture = Gesture.Pan() ++ let panGesture = Gesture.Pan() + .onBegin((evt) => { + gestureDisabled.value = disabled.value; + if (gestureDisabled.value) return; ++ const sessionId = gestureSessionId.value + 1; ++ gestureSessionId.value = sessionId; ++ gestureSessionActive.value = true; ++ terminalHandled.value = false; ++ runOnJS(onGestureBegin)(sessionId); + panGestureState.value = evt.state; + }) + .onUpdate((evt) => { +@@ -286,8 +372,15 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + : evt.translationY; + touchTranslate.value = translation; + }) +- .onEnd((evt) => { ++ .onEnd((evt, success) => { + if (gestureDisabled.value) return; ++ if ( ++ !success || ++ terminalHandled.value || ++ !gestureSessionActive.value ++ ) ++ return; ++ + // Set touch val to current translate val + isTouchActiveNative.value = false; + const translation = horizontalAnim.value +@@ -299,6 +392,7 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + + // Only call onDragEnd if actually dragging a cell + if (activeIndexAnim.value === -1 || disabled.value) return; ++ terminalHandled.value = true; + disabled.value = true; + runOnJS(onRelease)(activeIndexAnim.value); + const springTo = placeholderOffset.value - activeCellOffset.value; +@@ -309,14 +403,58 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + runOnJS(onDragEnd)({ + from: activeIndexAnim.value, + to: spacerIndexAnim.value, ++ sessionId: gestureSessionId.value, + }); + disabled.value = false; + } + ); + }) +- .onTouchesDown(() => { ++ .onFinalize((evt) => { ++ panGestureState.value = evt.state; ++ runOnJS(onContainerTouchEnd)(); ++ if (gestureDisabled.value) return; ++ ++ const sessionId = gestureSessionId.value; ++ const committed = terminalHandled.value; ++ gestureSessionActive.value = false; ++ if (!committed) finalizeCancelledDrag(); ++ runOnJS(onGestureFinalize)(sessionId, committed); ++ }) ++ .onTouchesDown((event) => { ++ const touch = event.changedTouches[0]; ++ if (touch) { ++ touchStartX.value = touch.absoluteX; ++ touchStartY.value = touch.absoluteY; ++ } + runOnJS(onContainerTouchStart)(); + }) ++ .onTouchesMove((event, stateManager) => { ++ if (!coordinatesDragWithExternalGesture) return; ++ ++ const touch = event.changedTouches[0]; ++ if (!touch || event.numberOfTouches !== 1) { ++ stateManager.fail(); ++ return; ++ } ++ ++ const deltaX = touch.absoluteX - touchStartX.value; ++ const deltaY = touch.absoluteY - touchStartY.value; ++ const absDeltaX = Math.abs(deltaX); ++ const absDeltaY = Math.abs(deltaY); ++ ++ if (activeIndexAnim.value < 0) { ++ if (absDeltaX > 6 || absDeltaY > 6) { ++ stateManager.fail(); ++ } ++ return; ++ } ++ ++ if (absDeltaX > 10 && absDeltaX > absDeltaY) { ++ stateManager.fail(); ++ } else if (absDeltaY >= activationDistanceProp && absDeltaY > absDeltaX) { ++ stateManager.activate(); ++ } ++ }) + .onTouchesUp(() => { + // Turning this into a worklet causes timing issues. We want it to run + // just after the finger lifts. +@@ -324,7 +462,9 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + }); + + if (dragHitSlop) panGesture.hitSlop(dragHitSlop); +- if (activationDistanceProp) { ++ if (coordinatesDragWithExternalGesture) { ++ panGesture = panGesture.manualActivation(true); ++ } else if (activationDistanceProp) { + const activeOffset = [-activationDistanceProp, activationDistanceProp]; + if (props.horizontal) { + panGesture.activeOffsetX(activeOffset); +@@ -332,6 +472,21 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { panGesture.activeOffsetY(activeOffset); } } @@ -13,11 +365,30 @@ index 7c88afc..b630940 100644 + : [props.simultaneousHandlers]) + ); + } - ++ ++ // GestureDetector only updates handler config when a retained host reappears ++ // with the same native tag. Changing the gesture count forces a native ++ // reattachment without remounting the list subtree. ++ const detectorGesture = ++ props.dragGestureHostPresented === false ++ ? Gesture.Simultaneous(panGesture, Gesture.Manual().enabled(false)) ++ : panGesture; + const onScroll = useStableCallback((scrollOffset: number) => { props.onScrollOffsetChange?.(scrollOffset); +@@ -373,8 +528,9 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + horizontal={!!props.horizontal} + layoutAnimationDisabled={layoutAnimationDisabled} + > +- ++ + { listVerticalOffset.value = y; +@@ -74,6 +74,10 @@ function NestableDraggableFlatListInner( + } + ); + ++ const onDragTerminate = useStableCallback(() => { ++ setOuterScrollEnabled(true); ++ }); ++ + const onAnimValInit: DraggableFlatListProps["onAnimValInit"] = useStableCallback( + (params) => { + setListHoverOffset(params.hoverOffset); +@@ -95,6 +99,7 @@ function NestableDraggableFlatListInner( + outerScrollOffset={outerScrollOffset} + onDragBegin={onDragBegin} + onDragEnd={onDragEnd} ++ onDragTerminate={onDragTerminate} + onAnimValInit={onAnimValInit} + /> + ); +diff --git a/node_modules/react-native-draggable-flatlist/src/types.ts b/node_modules/react-native-draggable-flatlist/src/types.ts +index d6755c8..1b244a6 100644 +--- a/node_modules/react-native-draggable-flatlist/src/types.ts ++++ b/node_modules/react-native-draggable-flatlist/src/types.ts +@@ -36,12 +36,16 @@ export type DraggableFlatListProps = Modify< + keyExtractor: (item: T, index: number) => string; + onDragBegin?: (index: number) => void; + onDragEnd?: (params: DragEndParams) => void; ++ /** @internal Used by the nestable wrapper to release its outer-scroll lock on cancellation. */ ++ onDragTerminate?: () => void; + onPlaceholderIndexChange?: (placeholderIndex: number) => void; + onRelease?: (index: number) => void; + onScrollOffsetChange?: (scrollOffset: number) => void; + renderItem: RenderItem; + renderPlaceholder?: RenderPlaceholder; + simultaneousHandlers?: React.Ref | React.Ref[]; ++ /** @internal Forces handler reattachment when a retained native host is presented. */ ++ dragGestureHostPresented?: boolean; + outerScrollOffset?: Animated.SharedValue; + onAnimValInit?: (animVals: ReturnType) => void; + itemEnteringAnimation?: AnimateProps["entering"]; diff --git a/patches/react-native-gesture-handler+2.28.0.patch b/patches/react-native-gesture-handler+2.28.0.patch index 0686ac835..ada7b3a51 100644 --- a/patches/react-native-gesture-handler+2.28.0.patch +++ b/patches/react-native-gesture-handler+2.28.0.patch @@ -1,5 +1,171 @@ +diff --git a/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +index a532cf6..7b66222 100644 +--- a/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt ++++ b/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +@@ -2,6 +2,7 @@ package com.swmansion.gesturehandler.core + + import android.graphics.Matrix + import android.graphics.PointF ++import android.util.SparseArray + import android.view.MotionEvent + import android.view.View + import android.view.ViewGroup +@@ -24,6 +25,10 @@ class GestureHandlerOrchestrator( + private val awaitingHandlers = arrayListOf() + private val preparedHandlers = arrayListOf() + ++ // Used by `cancelTouchesInInterceptedViews`. ++ private val viewsToCancel = arrayListOf() ++ private val pointerDownPoints = SparseArray() ++ + // In `onHandlerStateChange` method we iterate through `awaitingHandlers`, but calling `tryActivate` may modify this list. + // To avoid `ConcurrentModificationException` we iterate through copy. There is one more problem though - if handler was + // removed from `awaitingHandlers`, it was still present in copy of original list. This hashset helps us identify which handlers +@@ -42,6 +47,7 @@ class GestureHandlerOrchestrator( + fun onTouchEvent(event: MotionEvent): Boolean { + isHandlingTouch = true + val action = event.actionMasked ++ trackPointerDownPoints(event) + if (action == MotionEvent.ACTION_DOWN || + action == MotionEvent.ACTION_POINTER_DOWN || + action == MotionEvent.ACTION_HOVER_MOVE +@@ -562,6 +568,107 @@ class GestureHandlerOrchestrator( + extractGestureHandlers(wrapperView, tempCoords, pointerId, event) + } + ++ private fun trackPointerDownPoints(event: MotionEvent) { ++ val index = event.actionIndex ++ when (event.actionMasked) { ++ MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> ++ pointerDownPoints.put(event.getPointerId(index), PointF(event.getX(index), event.getY(index))) ++ MotionEvent.ACTION_POINTER_UP -> ++ pointerDownPoints.remove(event.getPointerId(index)) ++ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> ++ pointerDownPoints.clear() ++ } ++ } ++ ++ fun cancelTouchesInInterceptedViews(event: MotionEvent) { ++ viewsToCancel.clear() ++ for (i in 0 until pointerDownPoints.size()) { ++ val point = pointerDownPoints.valueAt(i) ++ tempCoords[0] = point.x ++ tempCoords[1] = point.y ++ collectViewsAtPoint(wrapperView, tempCoords, viewsToCancel) ++ } ++ ++ if (viewsToCancel.isEmpty()) { ++ return ++ } ++ ++ val activeHandlers = gestureHandlers.filter { it.isActive } ++ val cancelEvent = MotionEvent.obtain(event).apply { action = MotionEvent.ACTION_CANCEL } ++ ++ for (view in viewsToCancel) { ++ if (view === wrapperView || isViewDrivenByActiveNativeGesture(view, activeHandlers)) { ++ continue ++ } ++ view.onTouchEvent(cancelEvent) ++ } ++ ++ cancelEvent.recycle() ++ viewsToCancel.clear() ++ } ++ ++ // Whether the view's touch is still owned by a NativeViewGestureHandler that survived arbitration. ++ // Only those are fed through `onTouchEvent`, so only those break if cancelled. Other handlers are ++ // orchestrator-driven and unaffected. ++ private fun isViewDrivenByActiveNativeGesture(view: View, activeHandlers: List) = ++ handlerRegistry.getHandlersForView(view)?.let { handlers -> ++ synchronized(handlers) { ++ handlers.any { nativeGestureSurvivesArbitration(it, activeHandlers) } ++ } ++ } ?: false ++ ++ // A native handler survives arbitration if it is active, or it does not conflict with any active handler. ++ private fun nativeGestureSurvivesArbitration(handler: GestureHandler, activeHandlers: List) = ++ handler is NativeViewGestureHandler && ++ (handler.isActive || activeHandlers.none { shouldHandlerBeCancelledBy(handler, it) }) ++ ++ // Collects the view path under the point (topmost child first, like touch dispatch), leaf to root. ++ private fun collectViewsAtPoint(view: View, coords: FloatArray, out: MutableList): Boolean { ++ val pointerEvents = viewConfigHelper.getPointerEventsConfigForView(view) ++ if (pointerEvents == PointerEventsConfig.NONE) { ++ return false ++ } ++ ++ var found = false ++ if (view is ViewGroup && pointerEvents != PointerEventsConfig.BOX_ONLY) { ++ for (i in view.childCount - 1 downTo 0) { ++ val child = viewConfigHelper.getChildInDrawingOrderAtIndex(view, i) ++ if (!canReceiveEvents(child)) { ++ continue ++ } ++ val childPoint = tempPoint ++ transformPointToChildViewCoords(coords[0], coords[1], view, child, childPoint) ++ if (isClipping(child) && !isTransformedTouchPointInView(childPoint.x, childPoint.y, child)) { ++ continue ++ } ++ val restoreX = coords[0] ++ val restoreY = coords[1] ++ coords[0] = childPoint.x ++ coords[1] = childPoint.y ++ found = collectViewsAtPoint(child, coords, out) ++ coords[0] = restoreX ++ coords[1] = restoreY ++ ++ if (found) { ++ break ++ } ++ } ++ } ++ ++ // BOX_NONE views can't be the target themselves, only their children can. ++ val selfIsTarget = pointerEvents != PointerEventsConfig.BOX_NONE && ++ isTransformedTouchPointInView(coords[0], coords[1], view) ++ ++ if (found || selfIsTarget) { ++ // `out` may already contain this view when several pointers share part of their path. ++ if (!out.contains(view)) { ++ out.add(view) ++ } ++ return true ++ } ++ return false ++ } ++ + private fun extractGestureHandlers( + viewGroup: ViewGroup, + coords: FloatArray, +diff --git a/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt b/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +index d8f8fd6..ed101b9 100644 +--- a/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt ++++ b/node_modules/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +@@ -19,6 +19,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: + private val jsGestureHandler: GestureHandler? + val rootView: ViewGroup + private var shouldIntercept = false ++ private var wasIntercepting = false + private var passingTouch = false + + init { +@@ -117,6 +118,14 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: + passingTouch = true + orchestrator!!.onTouchEvent(ev) + passingTouch = false ++ ++ // On the transition into interception, cancel the native views the pointers landed on - the ++ // framework's ACTION_CANCEL never reaches them since RNGH ignores `onInterceptTouchEvent`. ++ if (shouldIntercept && !wasIntercepting) { ++ orchestrator!!.cancelTouchesInInterceptedViews(ev) ++ } ++ wasIntercepting = shouldIntercept ++ + return shouldIntercept + } + diff --git a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js -index 76eb808..0debe83 100644 +index 76eb808..7c40526 100644 --- a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js +++ b/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js @@ -58,7 +58,14 @@ class PointerEventManager extends _EventManager.default { @@ -19,7 +185,7 @@ index 76eb808..0debe83 100644 this.markAsOutOfBounds(adaptedEvent.pointerId); this.trackedPointers.delete(adaptedEvent.pointerId); diff --git a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js -index 6ce3b8f..3976f3f 100644 +index 6ce3b8f..8fe28bc 100644 --- a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js +++ b/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js @@ -53,7 +53,14 @@ export default class PointerEventManager extends EventManager {