Skip to content

Fix regressions in gesture input (#4521 & #4536)#4711

Open
fbmcipher wants to merge 11 commits into
cybersemics:mainfrom
fbmcipher:fix/gesture-abandon-regressions
Open

Fix regressions in gesture input (#4521 & #4536)#4711
fbmcipher wants to merge 11 commits into
cybersemics:mainfrom
fbmcipher:fix/gesture-abandon-regressions

Conversation

@fbmcipher

@fbmcipher fbmcipher commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Fixes #4521 & #4536

Summary

Fixes regressions introduced by #4038 where gestures could activate after a touch began in an abandoned region. Also includes regression tests for #4521, #4536 and the original #3887.

Root cause

A touch beginning near native text selection or in the scroll zone correctly set abandon = true.

However, if React Native Web terminated the responder while the physical touch remained active, onPanResponderTerminate called reset(). This unconditionally cleared abandon.

The responder could then be granted again during the same touch, allowing subsequent movement to activate the gesture menu or execute a command.

Fix

Remove onPanResponderTerminate from MultiGesture. The original #4038 behavior remains protected by its pointercancel fallback and onPanResponderTerminationRequest logic.

Regression tests

iOS: keeps native text selection active without opening the gesture menu when dragging an end handle (#4521)

Types a sentence, selects the first word by DOM range (setSelection, the sanctioned driver backdoor — WebKit shows real selection handles for it), then drags the native end handle downward with a W3C touch action. Asserts the gesture menu did not appear and the selection extended (one two…) rather than collapsing.

The menu check runs after the touch is released, so it can only catch a menu stuck open (the #3887 failure mode); the selection assertion is what detects this regression — pre-fix, the hijacked handle drag collapses the selection to "" while the menu check still passes. This is documented in the test.

Red/green proof: verified on a local iOS Simulator (iPhone 16, iOS 26.5, Appium/XCUITest): fails pre-fix at the selection assertion (Expected substring: "one two" / Received: "", consistent across 6 runs), passes on this branch. Note: the TDD workflow's iOS job on this PR reported validation but never actually ran the test — fork PRs receive no BrowserStack credentials and the config crash was miscounted as a red failure. That workflow defect is filed as #4744; the local simulator runs above stand in as the red-side evidence.

Puppeteer: does not activate a gesture that starts in the scroll zone (#4536)

Seeds 30 thoughts with paste, scrolls partway down, then performs a single touch that starts in the right-edge scroll zone: swipes up (and asserts the page actually scrolls — this wait also guarantees the browser has taken over the touch, the precondition for the bug), then draws New Thought (rd) with the rest of the touch, and releases inside the test body. Three assertions, each on user-visible state:

  1. The gesture trace stays hidden (computed opacity of [data-testid=gesture-trace] is 0). Any wrongful re-activation updates the gesture store and shows the trace, even when the re-tracked swipe fragment doesn't form a command — this is the deterministic mid-touch signal.
  2. The gesture menu never appears.
  3. The exported outline is unchanged after release — commands execute on release, so releasing is part of the behavior under test; pre-fix, the re-tracked rd creates a thought and this comparison fails with a readable outline diff.

Red/green proof: red side verified deterministic — 3/3 pre-fix runs fail at the trace-visibility assertion (expected '1' to be '0'); when the re-tracked fragment forms a command, the export comparison additionally shows the created thought. Green 3/3 on this branch. Every intermediate reshaping of this test was also matrix-checked red/green per commit.

Puppeteer: releases a gesture when its loading target unmounts (#3887)

This one is a guard, not a regression test — it passes on the base branch too. The onPanResponderTerminate handler this PR removes was originally added for #3887 (gesture menu stuck when the touched element unmounts mid-gesture). This test holds a gesture on the loading indicator with production startup timing, waits for the indicator to unmount, releases, and asserts the menu is gone — proving the removal does not reintroduce #3887. The red side of this PR is carried by the two tests above.

Test helper changes

  • src/e2e/puppeteer/helpers/gesture.ts — rewritten around a new startGesture() API returning an ActiveGesture (move, end). This allows one physical touch to be composed in phases — swipe, wait for an observed condition (e.g. real scrolling), swipe again, release — which the scroll-zone test requires and the old single-shot gesture() could not express. move() accepts per-call segmentLength; startGesture accepts target/xStart/yStart. The existing gesture() remains as a thin wrapper, unchanged for existing tests.
  • src/e2e/puppeteer/helpers/reloadWithProductionTiming.ts (new) — the app zeroes animation durations when navigator.webdriver is present, which makes loading states too short-lived to interact with. This helper spoofs navigator.webdriver and reloads so the [Mobile] Transparent layer is shown when user dismisses a stuck Gesture Menu with swipes up #3887 test can hold a gesture on the loading indicator. It is an arrange-phase backdoor; the spoof needs no cleanup because every test runs in a fresh incognito context. Justification and constraints are in its doc comment.
  • src/e2e/iOS/helpers/getSelectionEndHandlePosition.ts (new) — computes the native screen coordinates of WebKit's selection end grab handle from the DOM range's client rect plus the native content / visual viewport origin, so the iOS test can grab the real handle. Throws descriptively if there is no selection.
  • src/e2e/iOS/helpers/keyboard.tstype() switched from WDIO sendKeys to W3C key actions (per-character keyDown/keyUp), so typed text goes through real key events while editing.
  • src/e2e/browserEnvironment/helpers/getSelection.ts — added toString() to read the currently selected text (used by the iOS selection assertion).

CI: TDD workflow

tdd.yml's base-branch (red) runs now check out changed e2e helper/support files from the PR head along with the test files. Test support is part of the test: without this, the base-branch run fails to compile against the new startGesture API and the red phase proves nothing about behavior. (Broader hardening of the red-phase validation itself — it currently trusts exit codes — is tracked separately in #4744.)

Demonstration videos

Recorded from the Puppeteer scroll-zone test session (emulated iPhone 15 Pro), same steps in both: swipe up from the right-edge scroll zone, then draw New Thought (rd) with the same touch, then release.

🔴 Before the fix — the swipe scrolls, then wrongly re-activates and creates a thought on release
scroll-zone-before-fix.mp4

What to watch for: the page scrolls (correct), then after the scroll steals the touch the gesture re-activates — and on finger-lift an empty thought appears under "thought 30".

🟢 After the fix — the same swipe only scrolls
scroll-zone-after-fix.mp4

The page scrolls and nothing else happens: no trace, no menu, no thought created on release.

@fbmcipher fbmcipher changed the title Fix regressions in gesture input (# Fix regressions in gesture input (#4521 & #4536) Jul 23, 2026
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Vercel preview: https://em-hb0dg3ozr-cybersemics.vercel.app

fbmcipher and others added 7 commits July 26, 2026 02:22
Arrange no longer performs a New Thought gesture and 20 keystroke-typed
thoughts in a test that is itself about gestures. Seed the outline with the
sanctioned paste helper and scroll with scrollTo, so setup cannot be broken
by the input features the test is not covering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Commands execute when the touch is released, but the test previously did all
of its checking mid-drag and released the touch in cleanup, after the last
assertion. A gesture that wrongly re-activates and fires a command on release
was therefore invisible to it.

Release inside the test body and assert that the exported outline is
unchanged. The post-scroll tail of the touch now draws New Thought (rd) so
that a wrongly re-activated gesture executes a command with a user-visible
effect: pre-fix, an empty thought is created and the export comparison fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comparing the trace element's className couples the test to generated
styling classes: any styling refactor fails the test with no change in
behavior, and the failure message is two long, visually identical class
strings. Assert what the user would see instead: the gesture menu does not
appear while the touch is held, and (from the previous commit) no thought is
created on release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two requestAnimationFrame waits named no condition. The first one is
replaced by waiting for what it was actually for: the page has visibly
scrolled after the upward move, which both asserts the desired scroll-zone
behavior and guarantees the browser has taken over the touch. The second is
removed: the assertions that follow no longer read render output mid-touch —
the gesture menu check needs no settling (pre-fix the menu renders during the
multi-segment moves; post-fix no state change is ever initiated), and the
export comparison runs after release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ment it

Helper files are named after what they export. Expand the doc comment to say
why the navigator.webdriver spoof is justified (loading states cannot exist
under test timing), what phase it belongs to (arrange), and why it needs no
explicit cleanup (each test gets a fresh incognito context and page).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gesture menu check runs after the touch is released, so it can only
catch a menu that is stuck open; the selection assertion is what detects
the cybersemics#4521 regression. Verified against the pre-fix implementation on a
local simulator: the hijacked handle drag collapses the selection to an
empty string while the menu check still passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-commit red validation showed the pre-fix failure had become timing
dependent: re-activation after responder termination always updates the
gesture store (showing the trace), but whether the re-tracked sequence forms
an executable command or raises the gesture menu varies from run to run.
Assert the trace's computed visibility — the user-visible form of the one
signal that fires on every re-activation — alongside the release-time export
comparison that catches command execution when it occurs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@fbmcipher
fbmcipher marked this pull request as ready for review July 26, 2026 01:57

@raineorshine raineorshine left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the submission!

The iOS gesture test looks good to me.

I'm challenged a bit by the puppeteer test. Maybe finding a narrower way to stub the loading timing will make it okay. Still, there is a very complex relationship between the loading indicator, timing, and MultiGesture.

I thought that the pointerEvents: 'none' fix decoupled the MultiGesture from the EmptyThoughtspace component, but this test shows that gestures still have a real dependency on the LoadingEllipsis. These components are totally unrelated, so I wish this wasn't the case.

I don't have a clear solution to the coupling here, but wanted to articulate my thoughts.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems overly broad. Exactly which loading states does this test depend on? Maybe there is a specific testFlag that can be used to add a delay or override an animation duration. Need more of a scalpel than a hammer here. By disabling navigating.webdriver completely it is affecting many other testing behaviors.

Forcing navigator.webdriver to false in a test feels wrong conceptually anyway. It's not like mocking a value or function with a dummy value for a test. It is making it the exact opposite of its intent. This could lead to some extremely confusing debugging scenarios.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Mobile] Gesture activated when adjusting text selection handles

2 participants