Skip to content

perf: fix Android WebView jank (confetti, instrumentation, wrapper) - #2588

Draft
innolope-dev wants to merge 4 commits into
devfrom
perf/android-jank-fixes
Draft

perf: fix Android WebView jank (confetti, instrumentation, wrapper)#2588
innolope-dev wants to merge 4 commits into
devfrom
perf/android-jank-fixes

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

Why

The Android (Capacitor) build feels jittery vs the same app as a PWA — confetti on reward claim stutters on a Pixel 8 Pro, and low-end devices (Realme Narzo 50) see random slowness on navigation and camera open. Investigation found multiple independent causes rather than one:

  1. Confetti: up to 400 star particles rendered on the main thread on the qr-pay success screen (payment-success + perk-claim bursts stack), no worker, no native scaling.
  2. Instrumentation: PostHog rrweb recording was enabled on capable devices (the Pixel class), autocapture walked the DOM on every tap, Sentry BrowserTracing instrumented 100% of sessions (sampling only gates sending), and reactComponentAnnotation stamped every DOM node for a replay product that isn't running (Sentry replay was dead config — replayIntegration() was never registered).
  3. Wrapper: MainActivity replaced Capacitor's WebViewClient with a bare one that dropped onRenderProcessGone (renderer OOM = app death, reads as 'random freeze'), pulltorefreshjs put non-passive touch listeners on body (every touch blocked compositor scrolling on the main thread), release APKs shipped ~23MB of per-read-inflated JS with remote debugging left on, and the QR scanner decoded 8×/sec on the app-shell critical path.

What

  • Confetti: worker-backed OffscreenCanvas rendering, single half-sized burst on native, 1.5s duplicate-burst throttle, reduced-motion support, lazy import.
  • PostHog: recording + autocapture off on native (explicit posthog.capture events unchanged — heads-up to analytics owners). Sentry native: BrowserTracing dropped, console.warn capture dropped; error capture untouched. Sentry web: dead replay config deleted. GA no longer ships into the WebView.
  • Android: MainActivity now extends BridgeWebViewClient (all 8 callbacks restored, incl. renderer-crash recovery; also picks up the OTA-aware findPageHtml from mobile-release so post-OTA navigation can't serve stale APK HTML), noCompress for web assets, NODE_ENV=production in the release script (kills webContentsDebuggingEnabled in release; WEBVIEW_DEBUG=true overrides for profiling), splash backgroundColor, ~8MB of web-only assets pruned from the bundle.
  • UI: pull-to-refresh rewritten with passive listeners + compositor-only indicator (pulltorefreshjs removed), QR scanner lazy-loaded and tuned (4Hz, no outline overlay, 300ms close), Crisp native SDK configured on first support open instead of every launch, Capgo update check deferred past first paint.
  • Testing: NEXT_PUBLIC_PERF_BARE=true builds strip all instrumentation, for A/B-ing a bare APK against a control on the affected devices.

Verification

  • pnpm typecheck ✅, full jest suite 182/182 suites ✅, :app:compileDebugJavaWithJavac
  • Needs on-device regression before release (deep links, dynamic-route _ fallbacks, offline nav, pull-to-refresh feel, QR scan, support chat open) — profiling protocol and before/after metrics tracked internally.

Notes

  • All changes ride the next Play release; the JS-only commits are additionally OTA-able via Capgo afterward.
  • Left alone on purpose: minifyEnabled (R8 shrinks Java only, plugin-reflection risk), flag sprites (runtime-resolved by country code).

…View

- PostHog: disable rrweb session recording and autocapture on all native
  builds — recording serializes the DOM on every mutation and autocapture
  walks the ancestor chain on every tap; explicit capture events keep working.
  The old capability gate enabled recording on exactly the devices reporting
  jank (Pixel 8 Pro passes deviceMemory>=4 && cores>=6).
- Sentry native: drop BrowserTracing (it wraps fetch/XHR, patches history and
  runs PerformanceObservers in 100% of sessions — tracesSampleRate only gates
  sending) and stop capturing console.warn as events. Error capture unchanged.
- Sentry web: delete replaysSessionSampleRate/replaysOnErrorSampleRate — dead
  config, replayIntegration() was never registered.
- reactComponentAnnotation off: stamps data-sentry-* on every DOM node for a
  replay product that isn't running.
- gtag.js no longer ships into the Capacitor WebView.
- NEXT_PUBLIC_PERF_BARE=true builds strip all instrumentation, for A/B-testing
  jank against a control build.
- canvas-confetti now fires through a persistent worker-backed OffscreenCanvas
  (falls back to main thread where unsupported) instead of a fresh main-thread
  canvas per burst
- native builds get one half-sized burst instead of two full ones (400->100
  particles worst case on qr-pay, where payment-success and perk-claim bursts
  can stack)
- 1.5s module-level throttle collapses duplicate celebrations racing on the
  same screen
- respects prefers-reduced-motion; library is now lazy-imported off the
  startup chunk; celebration preset's particleCount is honored instead of
  silently overridden; rAF cancelled on unmount
- MainActivity extends BridgeWebViewClient instead of replacing it with a bare
  WebViewClient that forwarded only 2 of 8 callbacks: onRenderProcessGone is
  back (a renderer OOM/crash recovered instead of killing the app — reads as
  'random freeze' on low-RAM devices), as are plugin page-load listeners.
  findPageHtml now honors an active Capgo OTA bundle via getServerBasePath,
  so post-OTA navigation can't serve stale APK HTML.
- noCompress for js/css/html/json/svg/woff2/map assets: the WebView mmaps
  them instead of inflating ~23MB of chunks per read on every full-document
  load. Play recompresses for transfer, so download size is unaffected.
- native-release.sh exports NODE_ENV=production so release builds stop
  shipping with webContentsDebuggingEnabled=true; WEBVIEW_DEBUG=true
  re-enables it for on-device profiling.
- backgroundColor kills the white flash between splash teardown and first
  paint.
- native-build.js prunes /dev test pages and iOS-PWA install videos (~8MB)
  from the bundled export; KEEP_DEV_PAGES=true retains /dev for test builds.
…work

- pull-to-refresh rewritten without pulltorefreshjs: the library registered
  non-passive touch listeners on body, forcing every touch in the app through
  the main thread before the compositor could scroll — the classic WebView
  scroll-jank pattern. The replacement uses passive listeners and a
  compositor-only transform indicator; same hook API.
- QR scanner: decoder lazy-imported off the app-shell critical path, scan rate
  8->4Hz (halves per-second canvas draws + worker decodes), per-frame code
  outline overlay off, camera released 300ms after close instead of 1.5s
  (it used to keep streaming through the qr-pay success confetti).
- Crisp native SDK configured on first support open instead of every app
  launch; logout reset tolerates never-configured.
- Capgo update check deferred 5s past first paint (notifyAppReady stays
  immediate — it must land within appReadyTimeout).
@innolope-dev innolope-dev self-assigned this Jul 30, 2026
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Jul 30, 2026 12:18pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6bb7cee7-af9f-446d-8b4d-123ab4d41da2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6800.43 → 6798.12 (-2.31)
Findings: +1 net (+53 new, -52 resolved)

🆕 New findings (53)

  • critical complexity — src/app/(mobile-ui)/qr-pay/page.tsx — CC 304, MI 52.49, SLOC 1070
  • critical complexity — src/components/Global/QRScanner/useQRScanner.ts — CC 63, MI 55.56, SLOC 307
  • critical complexity — src/components/Global/SupportDrawer/index.tsx — CC 52, MI 62.5, SLOC 139
  • high hotspot — src/app/(mobile-ui)/qr-pay/page.tsx — 96 commits, +1157/-1113 lines since 6 months ago
  • high complexity — src/hooks/usePullToRefresh.ts — CC 35, MI 59.22, SLOC 137
  • high complexity — src/utils/crisp.ts — CC 32, MI 62.96, SLOC 78
  • high complexity — src/app/layout.tsx — CC 12, MI 40.22, SLOC 110
  • medium react-long-component — src/app/(mobile-ui)/qr-pay/page.tsx:89 — QRPayPage is 1545 lines — split it
  • medium high-mdd — src/app/(mobile-ui)/qr-pay/page.tsx:89 — QRPayPage: MDD 459.4 (uses across many lines from declarations)
  • medium high-mdd — src/components/Global/QRScanner/useQRScanner.ts:98 — useQRScanner: MDD 109.3 (uses across many lines from declarations)
  • medium high-mdd — src/components/Global/SupportDrawer/index.tsx:17 — SupportDrawer: MDD 68.5 (uses across many lines from declarations)
  • medium structural-dup — app/(mobile-ui)/dev/shake-test/page.tsx:27 — 46 duplicate lines / 216 tokens with app/(mobile-ui)/qr-pay/page.tsx:901
  • medium high-dlt — src/components/Global/QRScanner/useQRScanner.ts:98 — useQRScanner: DLT 41 (calls 41 distinct functions — high context load)
  • medium high-mdd — src/hooks/usePullToRefresh.ts:49 — : MDD 30.8 (uses across many lines from declarations)
  • medium high-dlt — src/components/Global/SupportDrawer/index.tsx:17 — SupportDrawer: DLT 30 (calls 30 distinct functions — high context load)
  • medium high-mdd — src/hooks/usePullToRefresh.ts:33 — usePullToRefresh: MDD 30.3 (uses across many lines from declarations)
  • medium high-mdd — src/utils/crisp.ts:41 — setCrispUserData: MDD 20.5 (uses across many lines from declarations)
  • medium complexity — src/utils/confetti.ts — CC 18, MI 59.72, SLOC 121
  • medium method-complexity — src/components/Global/QRScanner/useQRScanner.ts:224 — CC 15 SLOC 75
  • medium method-complexity — src/utils/crisp.ts:41 — setCrispUserData CC 15 SLOC 22

…and 33 more.

✅ Resolved (52)

  • src/app/(mobile-ui)/qr-pay/page.tsx — CC 305, MI 52.63, SLOC 1072
  • src/components/Global/QRScanner/useQRScanner.ts — CC 63, MI 55.65, SLOC 304
  • src/components/Global/SupportDrawer/index.tsx — CC 51, MI 62.04, SLOC 136
  • src/app/(mobile-ui)/qr-pay/page.tsx — 95 commits, +1156/-1110 lines since 6 months ago
  • src/app/layout.tsx — CC 10, MI 40.42, SLOC 110
  • src/app/(mobile-ui)/qr-pay/page.tsx:89 — QRPayPage is 1547 lines — split it
  • src/app/(mobile-ui)/qr-pay/page.tsx:89 — QRPayPage: MDD 460.2 (uses across many lines from declarations)
  • src/components/Global/QRScanner/useQRScanner.ts:93 — useQRScanner: MDD 108.1 (uses across many lines from declarations)
  • src/components/Global/SupportDrawer/index.tsx:16 — SupportDrawer: MDD 67.2 (uses across many lines from declarations)
  • app/(mobile-ui)/dev/shake-test/page.tsx:27 — 46 duplicate lines / 216 tokens with app/(mobile-ui)/qr-pay/page.tsx:903
  • src/components/Global/QRScanner/useQRScanner.ts:93 — useQRScanner: DLT 41 (calls 41 distinct functions — high context load)
  • src/utils/crisp.ts — CC 27, MI 61.36, SLOC 63
  • src/utils/crisp.ts:17 — setCrispUserData: MDD 20.5 (uses across many lines from declarations)
  • src/components/Global/QRScanner/useQRScanner.ts:219 — CC 15 SLOC 72
  • src/config/peanut.config.tsx — CC 15, MI 69.85, SLOC 53
  • src/utils/crisp.ts:17 — setCrispUserData CC 15 SLOC 22
  • src/utils/confetti.ts — CC 7, MI 60.38, SLOC 68
  • src/app/(mobile-ui)/qr-pay/page.tsx:1004 — small useEffect that only sets state from deps
  • src/components/Global/SupportDrawer/index.tsx:40 — small useEffect that only sets state from deps
  • src/components/Global/SupportDrawer/index.tsx:122 — useEffect with empty deps + setState — derived state anti-pattern

…and 32 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/hooks/usePullToRefresh.ts 6.5 9.4 +3.0
src/utils/confetti.ts 5.1 6.1 +1.0

@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2419 ran, 0 failed, 0 skipped, 42.0s

📊 Coverage (unit)

metric %
statements 62.0%
branches 45.0%
functions 51.6%
lines 62.5%
⏱ 10 slowest test cases
time test
3.9s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.4s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.4s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.4s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
0.4s src/utils/__tests__/auth-token.test.ts › ignores the guarded marker and falls back to the plain token
0.3s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/utils/__tests__/auth-token.test.ts › is none — never guarded — when only the guarded marker is present
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/utils/__tests__/auth-token.test.ts › authReady does not park — hydrates the plain token without an unlock
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

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