Skip to content

fix(mobile): swap expo-clipboard for @react-native-clipboard/clipboard — kills Android-12 ANR (#16) - #329

Merged
thomasluizon merged 2 commits into
mainfrom
fix/clipboard-anr
Jun 27, 2026
Merged

thomasluizon merged 2 commits into
mainfrom
fix/clipboard-anr

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

Kill the Android-12 clipboard ANR (Sentry ORBIT-MOBILE-3)

expo-clipboard registers an always-on native clipboard listener that, on Android 12, calls getPrimaryClipDescription on the main thread → ANR. The fix is to remove the package entirely (merely not using it isn't enough — the listener is in native module init) and use @react-native-clipboard/clipboard, which has no such listener.

Change

  • Removed expo-clipboard, added @react-native-clipboard/clipboard@1.16.3 (peer range react-native >= 0.61.5, satisfied by RN 0.83.4).
  • Migrated the 3 call sites (all used only setStringAsync) to the sync Clipboard.setString(...) — advanced.tsx, referral-drawer.tsx, create-api-key-modal.tsx. The handlers became synchronous, so the vestigial async/void markers were dropped.
  • No clipboard listener was ever registered in JS, and none was added on the new module.
  • Updated the one inline vi.mock for the new default-export module.

Notes

@react-native-clipboard/clipboard is a native module — autolinking activates it on the next dev-client/EAS build (not Expo Go). Mobile-only (web uses the browser clipboard API). No backend change.

Validation

type-check clean, lint 0 errors, mobile 635 tests passed. Zero remaining expo-clipboard references (code + lockfile).

🤖 Generated with Claude Code

#16)

expo-clipboard registers an always-on native clipboard listener that calls getPrimaryClipDescription on the main thread on Android 12 -> ANR (Sentry ORBIT-MOBILE-3). Removing the package entirely (the listener lives in native module init, so non-use is not enough) and using @react-native-clipboard/clipboard@1.16.3 eliminates it. Migrated the 3 setStringAsync call sites to the sync setString API; dropped the now-vestigial async/void markers. Native autolinking activates on the next dev-client/EAS build. Mobile-only; no backend change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
orbit-ui-mobile-web Ignored Ignored Jun 27, 2026 4:35pm

Request Review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review: PR #329

Recommendation: NEEDS WORK

Summary

This PR removes expo-clipboard (which registers a main-thread native listener that triggers an ANR on Android 12) and replaces it with @react-native-clipboard/clipboard@1.16.3. Three call sites are migrated from async setStringAsync to sync Clipboard.setString. The fix is correct and complete for the mobile app itself. However, one stale reference to expo-clipboard survives in the root package.json overrides block, which keeps the package resolved by npm and defeats the purpose of this PR.


[HIGH] Stale expo-clipboard override in root package.json

dimension: Dead / stale code (rubric #2)
location: package.json:45

issue: "expo-clipboard": "55.0.12" remains in the root overrides block after the package was removed from apps/mobile/package.json. npm overrides pins a transitive resolution — leaving it means npm still resolves the package at that version for any transitive consumer, keeps expo-clipboard in node_modules, and the native module initialises on startup.

risk: The ANR the PR is designed to eliminate may still occur on Android 12 because the native listener is registered at init time by whichever package resolves expo-clipboard — even if no JS ever imports it. The fix is silently incomplete.

fix: Remove the "expo-clipboard": "55.0.12" line from the overrides block in root package.json, then re-run npm install to regenerate the lockfile without expo-clipboard.

reference: CLAUDE.md rule 2 (delete unused code immediately)


Subagents

Agent Verdict
parity-checker PAIRED — web mirrors use navigator.clipboard natively; no web change required
i18n-syncer N/A — no new user-facing strings touched
contract-aligner N/A — no shared types or orbit-api changes
security-reviewer N/A — no orbit-api code changed

Validation

Check Result
Lint PASS (per PR body)
Type check PASS (per PR body)
Tests PASS (635 passed, per PR body)
Build (api) N/A

What's good

  • Root-cause diagnosis is correct: the ANR is in native module init, not the JS call site — removing the package entirely is the right fix.
  • All three JS call sites cleanly migrated; the async/void cruft properly stripped.
  • Test mock updated to match the new default-export module shape ({ default: { setString: vi.fn() } }).
  • Parity correctly scoped to mobile only — web already uses navigator.clipboard natively.
  • PR body is clear about peer-dependency ranges and autolinking implications.

Recommendation

Remove "expo-clipboard": "55.0.12" from the overrides block in root package.json (line 45) and regenerate the lockfile (npm install). That one-line removal completes the ANR fix.


Generated with Claude Code

expo-clipboard was removed from apps/mobile/package.json, but a stale "expo-clipboard": "55.0.12" remained in the root overrides block. npm install confirms the lockfile is unchanged (the override was inert: expo-clipboard already absent from the lockfile + both node_modules trees), so this is a dead-config cleanup (rule 2) that also removes the latent re-introduction risk. RN hoisting junction verified intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thomasluizon

Copy link
Copy Markdown
Owner Author

Addressed in c7a5523: removed the stale expo-clipboard override from root package.json. npm install leaves the lockfile unchanged — the override was inert (expo-clipboard was already absent from the lockfile and both node_modules trees, since nothing depends on it), so this completes the cleanup and removes the latent re-introduction risk you flagged. RN hoisting junction verified intact.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review: PR #329

Scope: PR #329 — fix(mobile): swap expo-clipboard for @react-native-clipboard/clipboard — kills Android-12 ANR (#16)
Recommendation: APPROVE

Summary

This PR replaces expo-clipboard with @react-native-clipboard/clipboard across all three mobile call sites (advanced.tsx, referral-drawer.tsx, create-api-key-modal.tsx) to eliminate the Android-12 ANR caused by expo-clipboard's always-on native clipboard listener calling getPrimaryClipDescription on the main thread. The swap is clean, complete, and correct: every setStringAsync becomes synchronous setString, every async/void marker is dropped, the test mock is updated, and zero expo-clipboard references survive in source or lockfile. No web-side change is required because apps/web already uses navigator.clipboard natively.

Findings

Critical

None

High

None

Medium

None

Low / Info

None

Subagents

Agent Verdict
parity-checker PAIRED — all three mobile files have web mirrors; web mirrors use navigator.clipboard (platform adapter — no sync needed per root CLAUDE.md "Allowed differences: platform adapters only")
i18n-syncer N/A — no user-facing strings added or removed
contract-aligner N/A — no packages/shared/src/types/* or endpoints.ts changes; no orbit-api changes
security-reviewer N/A — no orbit-api code changed

Validation

Check Result
Lint PASS (author-reported: 0 errors)
Type check PASS (author-reported: clean)
Tests PASS (author-reported: 635 passed)
Build (api) N/A — mobile-only change

Deferred — N/A dimensions & files not verdicted

  • DESIGN.md / AI-slop (#8): no new UI markup added; the changed lines are all import + function-body plumbing with no style/layout code.
  • Contract drift + backward-compat guard (#11): no packages/shared Zod schemas or orbit-api DTOs touched; guard is N/A.
  • Backend hard rules (#13): no orbit-api code in diff.
  • Security (#12, API side): no orbit-api code in diff; frontend-side clipboard access carries no injection or auth risk.
  • i18n (#10): no user-facing string keys added or removed.
  • Native module install: @react-native-clipboard/clipboard is a native module activated via autolinking on the next EAS/dev-client build — not verifiable in this CI context (expected for any native module swap).

What's good

  • Root cause fixed correctly: removing the package entirely rather than merely not calling it (the ANR fires at native module init, not at the call site — the PR body correctly identifies this).
  • All three call sites updated atomically in a single PR; no stragglers.
  • Sync setString is the right API for fire-and-forget copy — no reason to hold a promise.
  • void operators and async markers cleaned up consistently across all four handlers.
  • Test mock updated to match the new default-export shape ({ default: { setString: vi.fn() } }).
  • Peer dependency range (react-native >= 0.61.5) is satisfied by the project's RN 0.83.4.
  • Lockfile integrity hash is well-formed (88-char base64, matching the sha512 standard used throughout the lockfile).
  • Zero expo-clipboard references remain in source, config, or lockfile (confirmed by grep).

Recommendation

No issues found. The change is minimal, targeted, and correctly executed.

🤖 Generated with Claude Code

@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit eb04df9 into main Jun 27, 2026
10 checks passed
@thomasluizon
thomasluizon deleted the fix/clipboard-anr branch June 27, 2026 17:07
thomasluizon added a commit that referenced this pull request Sep 6, 2026
A bare #329 in this repository resolves to orbit-ui-mobile PR 329, the
clipboard pull request, not the private Progresso ticket. Qualified all three
occurrences.

This is the same cross-repo ambiguity recorded in orbit-tickets #438, seen from
the other side: verify-delivery accepts only a bare #N, which is precisely the
form that points at the wrong repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVEELVD8mRz6BQYZmqZkvU
thomasluizon added a commit that referenced this pull request Sep 6, 2026
* docs: record the Progresso multi-day gap question and its API blocker (#329)

The #329 worker stopped rather than guess: the existing repair endpoint only
repairs yesterday and spends exactly one freeze, so the canvas's Spend N freezes
action for a multi-day gap has no endpoint to call.

Filed the API work as orbit-tickets #442 and recorded #329 as blocked by it. The
question in this file is what Progresso should show for a multi-day gap it cannot
repair, because the drawing's no-freeze line would blame an empty bank that is
not empty, and the alternative is a state nobody drew.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVEELVD8mRz6BQYZmqZkvU

* docs: qualify the ticket references so they resolve to orbit-tickets

A bare #329 in this repository resolves to orbit-ui-mobile PR 329, the
clipboard pull request, not the private Progresso ticket. Qualified all three
occurrences.

This is the same cross-repo ambiguity recorded in orbit-tickets #438, seen from
the other side: verify-delivery accepts only a bare #N, which is precisely the
form that points at the wrong repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVEELVD8mRz6BQYZmqZkvU

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
thomasluizon added a commit that referenced this pull request Sep 7, 2026
* fix: complete Progresso global states for stage 1 (#329)

* fix: preserve Progresso accessibility in global states (#329)

* fix(progress): share global view state derivation
thomasluizon added a commit that referenced this pull request Sep 8, 2026
* feat(progress): build the stage 2 account streak block (#329)

* fix(progress): retain account stats behind the legacy bank gate

* fix(progress): validate protected dates before formatting

* fix(progress): fit all fourteen streak days within the row

* fix(progress): trust account freeze dates and stage announcements

* fix(progress): match protected today using account timezone

* fix(progress): use UTC for unavailable account timezones

* fix: anchor streak timeline to account date

* fix: serialize account dates from named parts

* fix: keep streak timezone snapshots coherent

* fix(progress): load streaks for UTC fallback accounts

* fix(progress): reconcile streak checks after base merge
thomasluizon added a commit that referenced this pull request Sep 8, 2026
* feat(progress): build the stage 2 account streak block (#329)

* fix(progress): retain account stats behind the legacy bank gate

* fix(progress): validate protected dates before formatting

* fix(progress): fit all fourteen streak days within the row

* fix(progress): trust account freeze dates and stage announcements

* fix(progress): match protected today using account timezone

* fix(progress): use UTC for unavailable account timezones

* feat(progress): align stage 4 goal cards and reordering with canvas

* fix(progress): consume mobile drag press suppression once

* fix(progress): clear press suppression when goal drag is cancelled
thomasluizon added a commit that referenced this pull request Sep 8, 2026
* feat(progress): build the stage 2 account streak block (#329)

* fix(progress): retain account stats behind the legacy bank gate

* fix(progress): validate protected dates before formatting

* fix(progress): fit all fourteen streak days within the row

* fix(progress): trust account freeze dates and stage announcements

* fix(progress): match protected today using account timezone

* fix(progress): use UTC for unavailable account timezones

* feat(progress): align stage 4 goal cards and reordering with canvas

* fix(progress): consume mobile drag press suppression once

* feat(progress): rebuild goal detail for stage 5 of ticket 329

* test(web): scope SVG geometry mock to DOM environments

* fix(progress): clear press suppression when goal drag is cancelled

* fix(progress): align goal completion with server behavior
thomasluizon added a commit that referenced this pull request Sep 8, 2026
* feat(progress): finish XP and achievements

* chore(progress): refresh the generated architecture map

Stage 7 changed module structure without regenerating the map, so the drift
gate went red. Output of node tools/arch-map.mjs, nothing hand edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBADebHkHd4QMHhTqFzsm3

* test(progress): pin achievement states

* fix(progress): make achievement groups accessible

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
thomasluizon added a commit that referenced this pull request Sep 9, 2026
Three conflicts, two mechanical and one that needed both sides read.

`architecture.json` and `architecture.html` are DELETED on `redesign/main` and
modified here. thomasluizon/orbit-tickets#470 stopped committing the generated
map and `.gitignore` now carries both paths citing it, so the deletion is the
incoming intent and this branch's edits are a regenerated artifact. Taken as
deleted.

`apps/mobile/__tests__/screens/progress-screen.test.tsx` is the real one, and the
two sides are INCOMPATIBLE rather than composable. `redesign/main` (8cb6bb1,
Progresso #329) mocks `@/lib/theme` so `createTokensV2` returns a Proxy handing
back `#status-done`, `#fg-1` and `#111111`. This branch (bd8bb43, #482) DELETED
that mock on purpose and switched to the real `createTokensV2('purple', mode)`
with a hoisted `theme.mode`, because a contrast assertion measured against a
Proxy that returns one constant for every token proves nothing.

Resolved toward this branch's intent, which is also the merge's goal: no theme
mock. The one assertion that depended on the mock's literal now reads the REAL
token instead of `'#status-done'`, so Progresso's intent survives too and is
strictly stronger: it still proves the earned mark is painted with the
`statusDone` role, now against the value the app actually renders.

One piece of merge fallout, not a conflict. `progressScreen.goals.progress`
gained interpolation params on `redesign/main`, so the mocked `t` returns
`key:{json}` and an equality selector stopped matching. Switched to the
`startsWith` idiom this file already uses two tests below.

  npx vitest run __tests__/screens/progress-screen.test.tsx
  Test Files  1 passed (1)
  Tests       51 passed (51)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LguAmjM5WjvsPonsJh4tJ
thomasluizon added a commit that referenced this pull request Sep 15, 2026
* feat(progress): add atomic streak gap repair

* fix(progress): align gap repair presentation

* fix(progress): defer multi-day repairs to server data

* Restore streak repair error status color

* test(progress): cover goal list stage 4

* test(progress): prove reorder release and filter guards

Assert filtered goal cards expose no drag semantics and release writes one order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VrpPfdSqgp5twko5kzTvf8

* test: detect goal card adornments

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant