Mobile navbar intermittently dead until the PWA is killed (leaked drawer-gesture touch state) #173

Closed
opened 2026-07-18 03:00:56 +02:00 by dominik.polakovics · 1 comment

Symptom

On mobile, the navbar sometimes stops working entirely — the drawer can no longer be opened by swipe (and when it happens mid-drag, every tap on the screen is swallowed). Nothing in-app recovers it; the only fix is killing and restarting the PWA.

Root cause

AppShell's follow-finger drawer gesture (#140) binds touchmove/touchend/touchcancel on window, but touch events are dispatched at the element the finger went DOWN on. When an SSE-driven re-render (chat log, runs rail, streaming tool chips) detaches that node mid-touch, the sequence's touchend/touchcancel fire on the detached node and never bubble to window:

  • activeTouchId is never cleared, so every later touchstart early-returns — the swipe gesture is permanently dead. On /runs/:id there is no top strip, so the swipe IS the nav there.
  • If the orphaned touch had already claimed a drag, dragging also sticks, keeping the full-viewport .shell-scrim (z-index 25, above the topstrip's 20) mounted — an invisible tap-eater with no reset path.

On Android the next finger can reuse the stale identifier and accidentally settle the orphan; iOS WebKit never reuses identifiers, so in an iPhone home-screen PWA the breakage is permanent until the app is killed. A secondary path with the same signature: iOS backgrounding the PWA mid-touch without delivering touchcancel.

ToolPanel's bottom-sheet dismiss gesture (#145) has the identical hole with element-scoped listeners (touch starting on streaming tool output that re-renders away), bounded there by the sheet's unmount cleanup.

Fix

Self-heal at touchstart in both bindings: if the tracked identifier is gone from e.touches, or re-appears in e.changedTouches (a finger cannot go down twice — identifier reuse proves the old sequence ended unheard), settle the orphan as cancelled and start fresh. Plus a visibilitychange→hidden reset in AppShell for the iOS backgrounding path. Regression tests at the binding seam (jsdom-fabricated touches, ToolPanel.test precedent) cover both leak phases, identifier reuse, and the visibility reset.

## Symptom On mobile, the navbar sometimes stops working entirely — the drawer can no longer be opened by swipe (and when it happens mid-drag, every tap on the screen is swallowed). Nothing in-app recovers it; the only fix is killing and restarting the PWA. ## Root cause AppShell's follow-finger drawer gesture (#140) binds touchmove/touchend/touchcancel on `window`, but touch events are dispatched at the element the finger went DOWN on. When an SSE-driven re-render (chat log, runs rail, streaming tool chips) detaches that node mid-touch, the sequence's touchend/touchcancel fire on the detached node and never bubble to window: - `activeTouchId` is never cleared, so every later touchstart early-returns — the swipe gesture is permanently dead. On `/runs/:id` there is no top strip, so the swipe IS the nav there. - If the orphaned touch had already claimed a drag, `dragging` also sticks, keeping the full-viewport `.shell-scrim` (z-index 25, above the topstrip's 20) mounted — an invisible tap-eater with no reset path. On Android the next finger can reuse the stale identifier and accidentally settle the orphan; iOS WebKit never reuses identifiers, so in an iPhone home-screen PWA the breakage is permanent until the app is killed. A secondary path with the same signature: iOS backgrounding the PWA mid-touch without delivering touchcancel. ToolPanel's bottom-sheet dismiss gesture (#145) has the identical hole with element-scoped listeners (touch starting on streaming tool output that re-renders away), bounded there by the sheet's unmount cleanup. ## Fix Self-heal at touchstart in both bindings: if the tracked identifier is gone from `e.touches`, or re-appears in `e.changedTouches` (a finger cannot go down twice — identifier reuse proves the old sequence ended unheard), settle the orphan as cancelled and start fresh. Plus a `visibilitychange`→hidden reset in AppShell for the iOS backgrounding path. Regression tests at the binding seam (jsdom-fabricated touches, ToolPanel.test precedent) cover both leak phases, identifier reuse, and the visibility reset.
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PR #174 fix(web): self-heal leaked drawer/sheet touch sequences

Check Result
State / draft PASS — open, not a draft
Mergeable PASS — no conflict; head already contains main's tip (clean merge)
Title convention PASS — fix(web): … valid Conventional Commits
Issue link PASS — Closes #173 present, will auto-close on merge
Diff scope PASS — 4 files, +289/-0 (AppShell.tsx, ToolPanel.tsx, + 2 test files)
Correctness PASS — self-heal guard precedes the extra-finger early-return, so a leaked sequence clears and the new touch processes fresh while a genuine second finger still falls through to be ignored; both handlers mirror their existing onTouchCancel idioms; findActive matches on activeTouchId as assumed
Test coverage PASS — control swipe + orphan-detach, pinned-scrim, Android id-reuse, backgrounding, and a ToolPanel recovery case
CI signal PASS — ci / native success in 8m55s (run #192); relied on, not re-run

Verdict: PASS. Awaiting human free-text go-ahead before merge.

> *This was generated by AI while landing a PR.* **Landing audit — PR #174** `fix(web): self-heal leaked drawer/sheet touch sequences` | Check | Result | | --- | --- | | State / draft | PASS — open, not a draft | | Mergeable | PASS — no conflict; head already contains `main`'s tip (clean merge) | | Title convention | PASS — `fix(web): …` valid Conventional Commits | | Issue link | PASS — `Closes #173` present, will auto-close on merge | | Diff scope | PASS — 4 files, +289/-0 (`AppShell.tsx`, `ToolPanel.tsx`, + 2 test files) | | Correctness | PASS — self-heal guard precedes the extra-finger early-return, so a leaked sequence clears and the new touch processes fresh while a genuine second finger still falls through to be ignored; both handlers mirror their existing `onTouchCancel` idioms; `findActive` matches on `activeTouchId` as assumed | | Test coverage | PASS — control swipe + orphan-detach, pinned-scrim, Android id-reuse, backgrounding, and a ToolPanel recovery case | | CI signal | PASS — `ci / native` success in 8m55s (run #192); relied on, not re-run | **Verdict: PASS.** Awaiting human free-text go-ahead before merge.
Sign in to join this conversation.
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#173
No description provided.