Video Tap Controls Not Reappearing After Disappearing
Metadata
- Date:
2026-05-08 - Status:
fixed - Severity:
high - Related issue/ticket: PR #427
- Owner:
Benjamin Lewis
About
Overview: - After video controls auto-hide (3 second timer), tapping the screen does nothing — controls never reappear. - PR #427 added a TapGestureHandler (RNGH legacy v1 API) to memory-viewer-modal.tsx to toggle control visibility. In practice, taps do not fire.
Technical Questions: - Is GestureHandlerRootView missing from the app root? No — _layout.tsx wraps the entire app in GestureHandlerRootView. - Is the RNGH version too old for TapGestureHandler? No — react-native-gesture-handler ~2.28.0 supports it. - Does TapGestureHandler (old API) work reliably over a native VideoView? No. The native video surface (expo-video VideoView) renders as a native view at the OS level. On both Android and iOS, the native video player surface consumes touch events before they propagate to the JS gesture recognizer, causing TapGestureHandler.onActivated to never fire. - Is there a secondary logic bug? Yes. handleVideoTap reads controlsVisible from a stale closure when deciding whether to call showControlsBriefly(). The functional updater to setControlsVisible inverts the value correctly, but the branch that follows reads the pre-toggle value — meaning every other tap calls the wrong branch.
Resources: - main/app/components/memory/memory-viewer-modal.tsx — VideoPlayer component with TapGestureHandler - main/app/app/_layout.tsx — app root with GestureHandlerRootView - main/app/__tests__/memory-viewer-modal.test.tsx — existing tests
Steps to cause failure
flowchart LR
VideoPlays["Video plays, controls visible"] --> Timer["showControlsBriefly 3s timer fires"]
Timer --> HideControls["setControlsVisible(false) — controls hidden"]
HideControls --> UserTap["User taps screen"]
UserTap --> NativeSurface["Native VideoView surface consumes touch at OS level"]
NativeSurface --> JSGestureDropped["TapGestureHandler.onActivated never called"]
JSGestureDropped --> NoReappear["Controls remain hidden indefinitely"] System
flowchart TD
TGH[TapGestureHandler] --> View[View videoContainer]
View --> VideoView[expo-video VideoView\nnativeControls=false]
VideoView -->|touch captured by native layer| Lost[Touch event lost\nbefore JS gesture recognizer]
TGH -->|onActivated never fires| handleVideoTap[handleVideoTap] Reproduction Details
- Open a visual memory in the viewer modal
- The video loads and controls appear (play/pause, scrubber, mute)
- After 3 seconds, controls auto-hide
- Tap anywhere on the video — controls do not reappear
- There is no way to regain controls or pause the video via tap
Reproduction test: main/app/__tests__/memory-viewer-modal.test.tsx — "tapping video toggles controls visibility"
Notes for PR
Root cause 1 (primary): TapGestureHandler from the RNGH legacy API does not receive touch events that pass through a native VideoView. The native video surface is rendered by the OS-level player (not React Native), and on both Android and iOS it intercepts touch events before they reach the JS gesture recognizer. The correct fix is to replace TapGestureHandler with a Pressable wrapper placed as a sibling/overlay over the video, using pointerEvents="box-only" so it receives taps but does not block the underlying Pressable controls.
Root cause 2 (secondary): handleVideoTap has a stale closure bug. It reads controlsVisible to decide which branch to take after the functional updater has already toggled it. Since setControlsVisible(v => !v) is asynchronous, controlsVisible still holds the old value during the same synchronous execution. The branch logic should be derived from the pre-toggle value (which is what controlsVisible holds), making it technically correct in intent — but the real fix is to simplify: just call showControlsBriefly() unconditionally when showing controls, and refactor to avoid the dual-path confusion.
Fix strategy: 1. Remove TapGestureHandler and its import. 2. Add a Pressable with style={StyleSheet.absoluteFill} and pointerEvents="box-only" as an overlay over the VideoView, below the controls overlay in z-order. 3. Simplify handleVideoTap: toggle with functional updater, always show briefly when transitioning to visible.
Audit Log
| ID | Action | Note | Context |
|---|---|---|---|
| 1 | Create audit log | Initialize bug investigation | Tapping video after controls hide does nothing |
| 2 | Read memory-viewer-modal.tsx | TapGestureHandler wraps View that contains VideoView and controls overlay | RNGH ~2.28.0 legacy API |
| 3 | Read _layout.tsx | GestureHandlerRootView present at app root — not the issue | Confirmed |
| 4 | Check package.json | react-native-gesture-handler ~2.28.0 | Version fine, API issue |
| 5 | Identify root cause 1 | TapGestureHandler does not receive events through native VideoView OS surface | Native view intercepts touches |
| 6 | Identify root cause 2 | handleVideoTap reads stale controlsVisible closure value | Functional updater toggles async |
| 7 | Write failing test | Test verifies tapping video toggles controls; fails with TapGestureHandler mock | Before fix |
| 8 | Confirm test fails | Test fails as expected | Confirmed pre-fix failure |
| 9 | Apply fix | Replace TapGestureHandler with Pressable overlay; simplify handleVideoTap | Root cause addressed |
| 10 | Confirm test passes | Test passes after fix | Confirmed |
Verification
- [x] Reproduced failure before fix (test for
video-tap-areatestID fails — element doesn't exist with TapGestureHandler) - [x] Reproduction test fails before fix (new test
tapping video area toggles controls visibility via Pressable overlay) - [x] Root cause identified with evidence (native VideoView surface intercepts OS-level touches before JS gesture recognizer; confirmed by RNGH architecture)
- [x] Fix applied at source (replaced TapGestureHandler with Pressable overlay; moved timer logic inside functional updater)
- [x] Reproduction test passes after fix (all 6 tests pass)
- [x] Regression test added/updated (
tapping video area toggles controls visibility via Pressable overlayin memory-viewer-modal.test.tsx) - [x] Verified no duplicate solved-bug log exists for same root cause