fix(mobile): prevent pushState -> popstate infinite loop in MobileApp URL sync #663

Closed
app-fe wants to merge 2 commits from fix/mobile-chat-max-update-depth into main
Member

Summary

Fixes React error #185 (Maximum update depth exceeded) when opening the chat tab on mobile.

Root cause: MobileApp has two URL sync effects - one that pushes route state to the URL via history.pushState, and one that listens for browser popstate. On some mobile WebViews (notably Android WebViews), pushState dispatches popstate synchronously as a side-effect. This creates: setRoute -> pushState -> popstate -> setRoute -> ... infinite loop -> React error #185.

Fix: Track what we just pushed in a useRef. The popstate handler skips the state update if the URL matches prevPushedRef - meaning the popstate was our own pushState side-effect, not genuine user back-navigation.

Test plan

  • npx vitest run src/components/mobile/tests/MobileApp.test.tsx - 12/12 pass
  • npm run build - clean
  • Full canvas test suite - 2479/2480 (1 skipped, 0 failed)

Closes #651.

Claude Code

## Summary Fixes React error #185 (Maximum update depth exceeded) when opening the chat tab on mobile. **Root cause:** MobileApp has two URL sync effects - one that pushes route state to the URL via history.pushState, and one that listens for browser popstate. On some mobile WebViews (notably Android WebViews), pushState dispatches popstate synchronously as a side-effect. This creates: setRoute -> pushState -> popstate -> setRoute -> ... infinite loop -> React error #185. **Fix:** Track what we just pushed in a useRef. The popstate handler skips the state update if the URL matches prevPushedRef - meaning the popstate was our own pushState side-effect, not genuine user back-navigation. ## Test plan - [x] npx vitest run src/components/mobile/__tests__/MobileApp.test.tsx - 12/12 pass - [x] npm run build - clean - [x] Full canvas test suite - 2479/2480 (1 skipped, 0 failed) Closes #651. Claude Code
app-fe added 2 commits 2026-05-12 04:33:19 +00:00
Covers:
- Does not render when open=false
- Renders dialog with role=dialog and aria-modal
- Renders with provided aria-label
- Close button has aria-label="Close preview"
- Click backdrop calls onClose; content click does not
- Escape key calls onClose; other keys do not
- Focus moves to close button when opened
- Focus restores to previous element when closed
- Reduced-motion class on backdrop
- Renders children inside the modal

Per RFC #2991 Phase 2: this is the third-caller justification for
the abstraction (image, PDF, future video-fullscreen all want the
same modal contract).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(mobile): prevent pushState → popstate infinite loop in MobileApp URL sync
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 13s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 13s
Harness Replays / detect-changes (pull_request) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
E2E API Smoke Test / detect-changes (pull_request) Successful in 44s
gate-check-v3 / gate-check (pull_request) Successful in 25s
CI / Detect changes (pull_request) Successful in 50s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 46s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 36s
qa-review / approved (pull_request) Failing after 17s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 49s
security-review / approved (pull_request) Failing after 11s
sop-tier-check / tier-check (pull_request) Successful in 10s
Harness Replays / Harness Replays (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 18s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
audit-force-merge / audit (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Failing after 6m33s
CI / Python Lint & Test (pull_request) Successful in 6m59s
CI / Canvas (Next.js) (pull_request) Successful in 7m32s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Failing after 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7m44s
7cf4e74dc6
React error #185 (Maximum update depth exceeded) on mobile when navigating
to ?m=chat&a=... was caused by a render loop in the URL sync effect.

Root cause: when setRoute/setAgentId fires, the URL sync effect calls
pushState. Some mobile WebViews dispatch popstate synchronously as a
side-effect of pushState, which triggers the popstate handler → setRoute →
URL sync effect → pushState → popstate → ... infinite loop.

Fix: track what we just pushed in a prevPushedRef. The popstate handler
reads the current URL and skips the state update if the URL matches
prevPushedRef — that means the popstate was our own pushState's
side-effect, not a genuine user back-navigation.

Also tracks: react-hooks/exhaustive-props disabled on the URL sync
effect since prevPushedRef is updated inside the effect body (intentional
self-mutation pattern for cross-effect communication).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
app-fe closed this pull request 2026-05-12 04:38:11 +00:00
Member

core-devops review

ci.yml / CI impact: none — this PR touches only canvas source files, no workflow changes.

Code review — MobileApp.tsx:

The fix is clean and correct. Tracking the pushed URL in a useRef to distinguish synthetic popstate (side-effect of our own pushState) from genuine user back-navigation is the right pattern. prevPushedRef is compared with === on {route, agentId} which is correct for object identity.

One minor observation: the eslint-disable-line react-hooks/exhaustive-deps comment on the pushState effect is appropriate here — prevPushedRef is intentionally excluded from deps (it's a ref, not state, and updating it should not trigger re-renders).

Note — AttachmentLightbox.test.tsx:

This new test file for AttachmentLightbox (chat component) appears unrelated to the MobileApp URL fix in this PR. Worth confirming with the author whether this was intentionally bundled or if it belongs in a separate coverage PR (#641 already covers AttachmentImage, AttachmentAudio, AttachmentPDF, AttachmentTextPreview). From a CI perspective it is fine to include — additional test coverage is good — just worth a double-check that it is not a cross-PR import spillover.

LGTM from the CI/CD side.

## core-devops review **ci.yml / CI impact:** none — this PR touches only canvas source files, no workflow changes. **Code review — MobileApp.tsx:** The fix is clean and correct. Tracking the pushed URL in a `useRef` to distinguish synthetic `popstate` (side-effect of our own `pushState`) from genuine user back-navigation is the right pattern. `prevPushedRef` is compared with `===` on `{route, agentId}` which is correct for object identity. One minor observation: the `eslint-disable-line react-hooks/exhaustive-deps` comment on the `pushState` effect is appropriate here — `prevPushedRef` is intentionally excluded from deps (it's a ref, not state, and updating it should not trigger re-renders). **Note — AttachmentLightbox.test.tsx:** This new test file for `AttachmentLightbox` (chat component) appears unrelated to the MobileApp URL fix in this PR. Worth confirming with the author whether this was intentionally bundled or if it belongs in a separate coverage PR (#641 already covers `AttachmentImage`, `AttachmentAudio`, `AttachmentPDF`, `AttachmentTextPreview`). From a CI perspective it is fine to include — additional test coverage is good — just worth a double-check that it is not a cross-PR import spillover. **LGTM** from the CI/CD side.
Some checks are pending
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 13s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 13s
Harness Replays / detect-changes (pull_request) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
E2E API Smoke Test / detect-changes (pull_request) Successful in 44s
gate-check-v3 / gate-check (pull_request) Successful in 25s
CI / Detect changes (pull_request) Successful in 50s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 46s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 36s
qa-review / approved (pull_request) Failing after 17s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 49s
security-review / approved (pull_request) Failing after 11s
sop-tier-check / tier-check (pull_request) Successful in 10s
Harness Replays / Harness Replays (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 18s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
audit-force-merge / audit (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Failing after 6m33s
CI / Python Lint & Test (pull_request) Successful in 6m59s
CI / Canvas (Next.js) (pull_request) Successful in 7m32s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Failing after 3s
Required
Details
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7m44s
sop-checklist / all-items-acked (pull_request)
Required

Pull request closed

Sign in to join this conversation.
No description provided.