Skip to content

Commit 9e6e455

Browse files
authored
Recover compact sidebar swipes after interrupted drags (#4112)
## Human comments ## What was wrong A compact sidebar opening swipe sets `openMobile=true` at drag intent. If its terminal event is lost, the page can remain only partly translated while the drawer is logically open. Both touch and pointer start handlers then reject every later opening swipe until reload. Text-selection cancellation could clear an active drag without resetting that state. The end handlers also ignored the release coordinate, so sparse move events could settle a sufficient swipe closed. Fixes #4110. ## What changed - Reset an interrupted active drag when the next eligible touch or primary pointer gesture starts. Keep normally opened drawers unaffected. - Reset an active drag when native text selection takes over. - Include the release position in the final swipe decision and match touch endings using `changedTouches`, so another finger's end does not terminate the tracked gesture. - Add regressions for lost terminal events on touch, pointer, and the iOS pointer-then-touch sequence; selection cleanup; sparse release coordinates; and another finger ending. This changes only `apps/app/src/components/ui/sidebar.tsx` and its test. There are no server/daemon wire, CLI, SDK, configuration, or protocol changes. ## How you verified - New interrupted-drag and release-position cases failed before the fix and pass after it. - `pnpm exec turbo run test --filter=@bb/app -- src/components/ui/sidebar.test.tsx`: 40 passed on the rebased head. - `pnpm exec turbo run typecheck --filter=@bb/app`: passed. - `pnpm start:worktree --dryrun` twice, then a persistent source server at [the review app](https://ymichael--19220.getbb.app); server and daemon health passed. - A deterministic 390 × 844 Chromium touch procedure against that build omitted a `touchend`, observed `data-state="open"` with `translate="20px"`, then confirmed the next swipe opened fully with no inline translation. It also passed text-selection cleanup, a tap without a swipe, normal opening and closing swipes, a trigger tap, and the desktop trigger. The procedure and output are in the originating BB thread's storage, outside this commit. - The intermittent phone report is a strong match for the reproduced stuck state; I did not reproduce it on a physical device. Related PR #3544 recovered interrupted sessions before drag intent. This PR covers interruption after the drawer has become logically open. BB-Thread-ID: thr_e2bgmgxf8g > AGENT GENERATED
1 parent cfc8d68 commit 9e6e455

2 files changed

Lines changed: 193 additions & 10 deletions

File tree

apps/app/src/components/ui/sidebar.test.tsx

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,105 @@ describe("mobile sidebar swipe-open touch listener scoping", () => {
779779
});
780780

781781
describe("mobile sidebar interrupted swipe sessions", () => {
782+
it.each(["touch", "pointer"] as const)(
783+
"recovers a %s swipe after an earlier drag loses its end event",
784+
(kind) => {
785+
vi.useFakeTimers();
786+
renderSelectableSwipeHarness();
787+
const prose = screen.getByText("Selectable message prose");
788+
const inset = document.querySelector('[data-sidebar="inset"]');
789+
if (!(inset instanceof HTMLElement)) {
790+
throw new Error("Expected a page inset");
791+
}
792+
793+
if (kind === "touch") {
794+
fireTouch(prose, "touchstart", createTouch(40, 160));
795+
fireTouch(window, "touchmove", createTouch(60, 160));
796+
fireTouch(prose, "touchstart", createTouch(40, 160, 2));
797+
fireTouch(window, "touchmove", createTouch(190, 160, 2));
798+
fireTouchEnd(window, createTouch(190, 160, 2));
799+
} else {
800+
firePointer(prose, "pointerdown", 40, 160);
801+
firePointer(window, "pointermove", 60, 160);
802+
firePointer(prose, "pointerdown", 40, 160, 2);
803+
firePointer(window, "pointermove", 190, 160, 2);
804+
firePointer(window, "pointerup", 190, 160, 2);
805+
}
806+
807+
settleMobileToggle();
808+
expect(getMobilePanel()?.dataset.state).toBe("open");
809+
expect(inset.style.translate).toBe("");
810+
},
811+
);
812+
813+
it("recovers a dragged pointer session when the next iOS gesture emits pointer and touch starts", () => {
814+
vi.useFakeTimers();
815+
renderSelectableSwipeHarness();
816+
const prose = screen.getByText("Selectable message prose");
817+
const inset = document.querySelector('[data-sidebar="inset"]');
818+
if (!(inset instanceof HTMLElement)) {
819+
throw new Error("Expected a page inset");
820+
}
821+
822+
firePointer(prose, "pointerdown", 40, 160);
823+
fireTouch(prose, "touchstart", createTouch(40, 160));
824+
fireTouch(window, "touchmove", createTouch(60, 160));
825+
826+
firePointer(prose, "pointerdown", 40, 160, 2);
827+
fireTouch(prose, "touchstart", createTouch(40, 160, 2));
828+
fireTouch(window, "touchmove", createTouch(190, 160, 2));
829+
fireTouchEnd(window, createTouch(190, 160, 2));
830+
831+
settleMobileToggle();
832+
expect(getMobilePanel()?.dataset.state).toBe("open");
833+
expect(inset.style.translate).toBe("");
834+
});
835+
836+
it.each(["touch", "pointer"] as const)(
837+
"opens when a %s swipe travels most of its distance before release",
838+
(kind) => {
839+
vi.useFakeTimers();
840+
renderSelectableSwipeHarness();
841+
const prose = screen.getByText("Selectable message prose");
842+
843+
if (kind === "touch") {
844+
fireTouch(prose, "touchstart", createTouch(40, 160));
845+
fireTouch(window, "touchmove", createTouch(60, 160));
846+
fireTouchEnd(window, createTouch(190, 160));
847+
} else {
848+
firePointer(prose, "pointerdown", 40, 160);
849+
firePointer(window, "pointermove", 60, 160);
850+
firePointer(window, "pointerup", 190, 160);
851+
}
852+
853+
settleMobileToggle();
854+
expect(getMobilePanel()?.dataset.state).toBe("open");
855+
},
856+
);
857+
858+
it("keeps tracking when another finger ends during the swipe", () => {
859+
vi.useFakeTimers();
860+
renderSelectableSwipeHarness();
861+
const prose = screen.getByText("Selectable message prose");
862+
fireTouch(prose, "touchstart", createTouch(40, 160));
863+
fireTouch(window, "touchmove", createTouch(80, 160));
864+
865+
const otherFingerEnd = new Event("touchend", {
866+
bubbles: true,
867+
cancelable: true,
868+
});
869+
Object.defineProperties(otherFingerEnd, {
870+
touches: { value: createTouchList(createTouch(80, 160)) },
871+
changedTouches: { value: createTouchList(createTouch(200, 160, 2)) },
872+
});
873+
fireEvent(window, otherFingerEnd);
874+
875+
fireTouch(window, "touchmove", createTouch(190, 160));
876+
fireTouchEnd(window, createTouch(190, 160));
877+
settleMobileToggle();
878+
expect(getMobilePanel()?.dataset.state).toBe("open");
879+
});
880+
782881
it.each([1, 2])(
783882
"opens after Send detaches the touch target with next identifier %s",
784883
(identifier) => {
@@ -925,4 +1024,41 @@ describe("mobile sidebar text-selection arbitration", () => {
9251024

9261025
expect(getMobilePanel()?.dataset.state).toBe("closed");
9271026
});
1027+
1028+
it("clears an active drag when native text selection begins", () => {
1029+
vi.useFakeTimers();
1030+
let hasSelection = false;
1031+
let selectionNode: Node | null = null;
1032+
vi.spyOn(document, "getSelection").mockImplementation(() =>
1033+
hasSelection
1034+
? ({
1035+
anchorNode: selectionNode,
1036+
focusNode: selectionNode,
1037+
isCollapsed: false,
1038+
} as Selection)
1039+
: null,
1040+
);
1041+
renderSelectableSwipeHarness();
1042+
const prose = screen.getByText("Selectable message prose");
1043+
const inset = document.querySelector('[data-sidebar="inset"]');
1044+
if (!(inset instanceof HTMLElement)) {
1045+
throw new Error("Expected a page inset");
1046+
}
1047+
selectionNode = prose.firstChild;
1048+
1049+
fireTouch(prose, "touchstart", createTouch(40, 160));
1050+
fireTouch(window, "touchmove", createTouch(60, 160));
1051+
hasSelection = true;
1052+
fireEvent(document, new Event("selectionchange"));
1053+
1054+
expect(getMobilePanel()?.dataset.state).toBe("closed");
1055+
expect(inset.style.translate).toBe("");
1056+
1057+
hasSelection = false;
1058+
fireTouch(prose, "touchstart", createTouch(40, 160, 2));
1059+
fireTouch(window, "touchmove", createTouch(190, 160, 2));
1060+
fireTouchEnd(window, createTouch(190, 160, 2));
1061+
settleMobileToggle();
1062+
expect(getMobilePanel()?.dataset.state).toBe("open");
1063+
});
9281064
});

apps/app/src/components/ui/sidebar.tsx

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,21 @@ const SidebarInset = React.forwardRef<
10431043
}
10441044
}, []);
10451045

1046+
const recoverInterruptedMobileSwipe = React.useCallback(() => {
1047+
clearSwipeSession();
1048+
clearMobileDragSettleTimeout();
1049+
flushSync(() => {
1050+
setSuppressMobileCloseAnimation(true);
1051+
setOpenMobile(false);
1052+
});
1053+
clearSidebarMobileDragStyles();
1054+
}, [
1055+
clearMobileDragSettleTimeout,
1056+
clearSwipeSession,
1057+
setOpenMobile,
1058+
setSuppressMobileCloseAnimation,
1059+
]);
1060+
10461061
const clearWheelSwipe = React.useCallback(() => {
10471062
wheelSwipeDeltaRef.current = 0;
10481063
if (wheelSwipeResetTimeoutRef.current !== null) {
@@ -1241,9 +1256,12 @@ const SidebarInset = React.forwardRef<
12411256
return;
12421257
}
12431258

1259+
if (event.type === "pointerup") {
1260+
continueSwipe(event.clientX, event.clientY, event);
1261+
}
12441262
finishMobileSwipe(event);
12451263
},
1246-
[finishMobileSwipe],
1264+
[continueSwipe, finishMobileSwipe],
12471265
);
12481266

12491267
const handleTouchMove = React.useCallback(
@@ -1270,21 +1288,24 @@ const SidebarInset = React.forwardRef<
12701288
return;
12711289
}
12721290

1273-
if (getTrackedSwipeTouch(event, session.id) === null) {
1291+
const touch = findTouchById(event.changedTouches, session.id);
1292+
if (touch === null) {
12741293
return;
12751294
}
12761295

1296+
if (event.type === "touchend") {
1297+
continueSwipe(touch.clientX, touch.clientY, event);
1298+
}
12771299
finishMobileSwipe(event);
12781300
},
1279-
[finishMobileSwipe],
1301+
[continueSwipe, finishMobileSwipe],
12801302
);
12811303

12821304
const startTouchSwipe = React.useCallback(
12831305
(event: TouchEvent) => {
12841306
if (
12851307
event.defaultPrevented ||
12861308
!isCompactViewport ||
1287-
openMobile ||
12881309
event.touches.length !== 1 ||
12891310
!isSidebarInsetSwipeTarget(event.target) ||
12901311
shouldIgnoreSidebarSwipeTarget(event.target)
@@ -1300,7 +1321,14 @@ const SidebarInset = React.forwardRef<
13001321
return;
13011322
}
13021323

1303-
clearSwipeSession();
1324+
if (openMobile) {
1325+
if (!swipeSessionRef.current?.isDragging) {
1326+
return;
1327+
}
1328+
recoverInterruptedMobileSwipe();
1329+
} else {
1330+
clearSwipeSession();
1331+
}
13041332

13051333
const canPreventDefault = isSidebarSwipeEdgeZoneTouch(touch.clientX);
13061334
swipeSessionRef.current = createSidebarInsetSwipeSession({
@@ -1331,6 +1359,7 @@ const SidebarInset = React.forwardRef<
13311359
handleTouchMove,
13321360
isCompactViewport,
13331361
openMobile,
1362+
recoverInterruptedMobileSwipe,
13341363
],
13351364
);
13361365

@@ -1339,7 +1368,6 @@ const SidebarInset = React.forwardRef<
13391368
if (
13401369
event.defaultPrevented ||
13411370
!isCompactViewport ||
1342-
openMobile ||
13431371
event.pointerType !== "touch" ||
13441372
!event.isPrimary ||
13451373
event.button !== 0 ||
@@ -1350,7 +1378,14 @@ const SidebarInset = React.forwardRef<
13501378
return;
13511379
}
13521380

1353-
clearSwipeSession();
1381+
if (openMobile) {
1382+
if (!swipeSessionRef.current?.isDragging) {
1383+
return;
1384+
}
1385+
recoverInterruptedMobileSwipe();
1386+
} else {
1387+
clearSwipeSession();
1388+
}
13541389
swipeSessionRef.current = createSidebarInsetSwipeSession({
13551390
kind: "pointer",
13561391
id: event.pointerId,
@@ -1379,18 +1414,25 @@ const SidebarInset = React.forwardRef<
13791414
handleSwipeMove,
13801415
isCompactViewport,
13811416
openMobile,
1417+
recoverInterruptedMobileSwipe,
13821418
],
13831419
);
13841420

13851421
React.useEffect(() => {
13861422
const cancelSwipeForTextSelection = () => {
1387-
const selectionRoot = swipeSessionRef.current?.selectionRoot;
1423+
const session = swipeSessionRef.current;
1424+
const selectionRoot = session?.selectionRoot;
13881425
if (
1426+
session !== null &&
13891427
selectionRoot !== null &&
13901428
selectionRoot !== undefined &&
13911429
hasTextSelectionWithin(selectionRoot)
13921430
) {
1393-
clearSwipeSession();
1431+
if (session.isDragging) {
1432+
recoverInterruptedMobileSwipe();
1433+
} else {
1434+
clearSwipeSession();
1435+
}
13941436
}
13951437
};
13961438

@@ -1415,7 +1457,12 @@ const SidebarInset = React.forwardRef<
14151457
cancelSwipeForTextSelection,
14161458
);
14171459
};
1418-
}, [clearSwipeSession, startPointerSwipe, startTouchSwipe]);
1460+
}, [
1461+
clearSwipeSession,
1462+
recoverInterruptedMobileSwipe,
1463+
startPointerSwipe,
1464+
startTouchSwipe,
1465+
]);
14191466

14201467
const handleWheelSwipe = React.useCallback(
14211468
(event: WheelEvent) => {

0 commit comments

Comments
 (0)