fix(web): self-heal leaked drawer/sheet touch sequences (mobile nav freeze) #174

Merged
dominik.polakovics merged 1 commit from lab/20260718-0240 into main 2026-07-18 03:25:16 +02:00

Closes #173

On mobile, the navbar sometimes went permanently dead — swipe-to-open never worked again (and after a mid-drag leak, an invisible full-viewport scrim ate every tap) until the PWA was killed.

Root cause: touch events dispatch at the element the finger went down on. An SSE-driven re-render detaching that node mid-touch means the sequence's touchend/touchcancel fire on the detached node and never bubble to AppShell's window-level gesture listeners, so activeTouchId (and, mid-drag, dragging + the scrim) pinned forever. iOS never reuses touch identifiers, so a home-screen PWA never recovered.

Fix:

  • AppShell onTouchStart self-heals: a tracked id gone from e.touches, or re-appearing in e.changedTouches (a finger cannot go down twice — identifier reuse proves the old sequence ended unheard), settles the orphan via gesture.cancel() before the new touch starts.
  • visibilitychange→hidden settles any in-flight drag (iOS can background the PWA mid-touch without a touchcancel).
  • ToolPanel's sheet-dismiss binding gets the same self-heal (identical hole with element-scoped listeners; touch starting on streaming tool output that re-renders away).

Tests: new AppShell.test.tsx exercises the window binding seam with jsdom-fabricated touches — control swipe, both leak phases (tracking + claimed/pinned-scrim), Android identifier reuse, visibility reset — plus a ToolPanel recovery case. All 5 recovery tests fail on the unfixed sources; full web suite (843 tests), tsc, eslint, prettier green.

Note: recovery is lazy by design — the stuck state clears on the next touch anywhere (or on backgrounding), so the first tap after a leak may still land on the pinned scrim; everything works from the second touch on.

🤖 Generated with Claude Code

Closes #173 On mobile, the navbar sometimes went permanently dead — swipe-to-open never worked again (and after a mid-drag leak, an invisible full-viewport scrim ate every tap) until the PWA was killed. **Root cause**: touch events dispatch at the element the finger went down on. An SSE-driven re-render detaching that node mid-touch means the sequence's touchend/touchcancel fire on the detached node and never bubble to AppShell's window-level gesture listeners, so `activeTouchId` (and, mid-drag, `dragging` + the scrim) pinned forever. iOS never reuses touch identifiers, so a home-screen PWA never recovered. **Fix**: - AppShell `onTouchStart` self-heals: a tracked id gone from `e.touches`, or re-appearing in `e.changedTouches` (a finger cannot go down twice — identifier reuse proves the old sequence ended unheard), settles the orphan via `gesture.cancel()` before the new touch starts. - `visibilitychange`→hidden settles any in-flight drag (iOS can background the PWA mid-touch without a touchcancel). - ToolPanel's sheet-dismiss binding gets the same self-heal (identical hole with element-scoped listeners; touch starting on streaming tool output that re-renders away). **Tests**: new `AppShell.test.tsx` exercises the window binding seam with jsdom-fabricated touches — control swipe, both leak phases (tracking + claimed/pinned-scrim), Android identifier reuse, visibility reset — plus a ToolPanel recovery case. All 5 recovery tests fail on the unfixed sources; full web suite (843 tests), tsc, eslint, prettier green. Note: recovery is lazy by design — the stuck state clears on the next touch anywhere (or on backgrounding), so the first tap after a leak may still land on the pinned scrim; everything works from the second touch on. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(web): self-heal leaked touch sequences that froze the mobile nav until PWA restart
All checks were successful
ci / native (pull_request) Successful in 8m55s
4c1b4716dd
Touch events dispatch at the element the finger went DOWN on; when an
SSE-driven re-render detaches that node mid-touch, its touchend/touchcancel
fire on the detached node and never bubble to AppShell's window-level drawer
gesture listeners. The tracked activeTouchId then pinned forever (every
touchstart early-returned — swipe-to-open dead until the PWA was killed),
and a claimed drag also pinned the full-viewport scrim, eating every tap.
iOS never reuses touch identifiers, so a home-screen PWA never recovered;
Android could only heal by accidental identifier reuse.

Fix: at touchstart, a tracked id that is gone from e.touches — or back in
changedTouches (a finger cannot go down twice; reuse proves the old sequence
ended unheard) — settles the orphan via gesture.cancel() before the new
touch starts. A visibilitychange->hidden reset covers iOS backgrounding
mid-touch without touchcancel. ToolPanel's sheet dismiss gesture had the
identical hole (touch on streaming tool output that re-renders away) and
gets the same self-heal.

Regression tests exercise the window binding seam with jsdom-fabricated
touches: both leak phases, identifier reuse, and the visibility reset.

Closes #173

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!174
No description provided.