Skip to content

perf(mobile): Android back leaves a thread without waiting for JS - #14054

Open
AKolenda wants to merge 2 commits into
pingdotgg:mainfrom
AKolenda:perf/mobile-android-instant-back
Open

AKolenda wants to merge 2 commits into
pingdotgg:mainfrom
AKolenda:perf/mobile-android-instant-back

Conversation

@AKolenda

@AKolenda AKolenda commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

What Changed

On Android, back from a thread now pops the screen natively instead of waiting for the JS thread.

  • withAndroidNativeScreenBack (new config plugin) adds an OnBackPressedCallback to MainActivity.
    • It is registered in onPostCreate, after React Native's own, so it runs first.
    • It finds the top screen of the innermost ScreenStack. If that screen opted in and isn't the stack's root, it calls dismissFromContainer(). That is the native dismissal react-native-screens uses for the iOS swipe back, and JS catches up through onDismissed.
    • Anything else goes to React Native's callback, as before.
    • It does nothing on layouts 600 dp and wider, where more than one stack is on screen.
  • native-stack patch: a new unstable_nativeBackDismissalEnabled option sets the screen's nativeBackButtonDismissalEnabled. native-stack hardcodes it to false on Android.
  • Thread screen: opts in.
  • In-window ⋮ menu (the one shown while the keyboard is up) takes back in JS. Its overlay carries a nativeID (JS_BACK_HANDLER_NATIVE_ID), and the native callback hands back to JS while a view with that ID is on screen. The check reads the view tree when back is pressed, so it holds from the overlay's first frame, and back still closes the menu first.
  • The keyboard and the native ⋮ popup close before anything else, as before.

Why

Android back reaches the navigator through the JS thread:

  1. React Native's callback emits hardwareBackPress.
  2. JS runs the BackHandler listeners.
  3. React Navigation pops.
  4. Only then does the stack animate.

When JS is busy, back does nothing until that work finishes, for example while a thread is loading or syncing. That's what users see: back from a thread that's still loading waits until it has loaded. An earlier probe on my Pixel 9, during a heavy sync, measured the JS event loop blocked for 28 s out of 60.

The native dismissal pops on the UI thread, so it doesn't wait for JS.

Measurements

Pixel 9, real account, release builds installed in place. The builds are my test builds (main plus my other open mobile PRs), before and with this PR.

  • JS thread busy (see the clip below): one test JS bundle holds the JS thread for 3 s, 300 ms after a thread opens, to stand in for a slow load. Then back is swiped. Both native builds run that same bundle, so the only difference is this PR's native callback.
    • Before: Home came back 2.7 s and 3.8 s after the back arrow showed (2 runs), once the block ended.
    • With this PR: 0.28 s both times, while JS was still blocked.
  • JS thread free (today's normal path, thread still loading or already loaded): time from the back arrow showing to Home, including the gesture and the pop animation.
    • Before: 259–480 ms, 10 runs.
    • With this PR: 272–317 ms, 4 runs.
    • So the normal path doesn't get slower.
  • Checked on the phone with this PR:
    • With the keyboard up and the in-window ⋮ menu open, back closes the keyboard, then the menu, and only then leaves the thread.
    • Back closes the native ⋮ popup and stays in the thread.
    • Back on Home still leaves the app.

withAndroidNativeScreenBack.test.mjs covers the generated MainActivity, including the menu check (against the JS constant) and chaining with withAndroidPredictiveBackCompat.

UI Changes

The JS thread is held busy for 3 s right after the thread opens, then back is swiped. On the left is the build before this PR: back waits for JS. On the right, with it, Home comes back right away. Real time (MP4).

Back while the JS thread is busy, before vs with this PR

Android performance series

These are separate PRs, each reviewable on its own:

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

Summary by CodeRabbit

  • New Features
    • Android back navigation can now dismiss eligible screens natively, including the root thread screen.
    • Back presses continue to be handled by in-window menus when the keyboard is visible.
    • Navigation headers now support additional item types, subtitles, styling, and custom item identifiers.

Android back reached the navigator through the JS thread, so while a thread
was loading or syncing, back did nothing until that work finished.

- withAndroidNativeScreenBack adds an OnBackPressedCallback to MainActivity
  that runs ahead of React Native's. When the top screen of the innermost
  stack opted in, it dismisses it natively (the same dismissal an iOS swipe
  back uses) and JS catches up through onDismissed. Everything else still
  goes to JS.
- native-stack: `unstable_nativeBackDismissalEnabled` sets the screen's
  nativeBackButtonDismissalEnabled instead of hardcoding false on Android.
- The Thread screen opts in. The in-window ⋮ menu holds back for JS while
  it is open (jsBackHold), so back still closes it first.
Comment thread patches/@react-navigation%2Fnative-stack@7.17.6.patch
@macroscopeapp

macroscopeapp Bot commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR changes the default Android back behavior for thread screens through a new native callback and patched navigation stack, with coordination across native and JS code. The cross-layer production behavior and unresolved menu-dismissal timing race require human review.

Not approved because:

  • 1 blocking correctness issue found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

The menu's JS hold reached the screen's native dismissal setting two
passive effects after the menu opened, so back pressed in between popped
the thread instead of closing the menu.

The in-window menu's overlay now carries a nativeID, and the native back
callback hands back to JS while a view with that ID is on screen. The
callback reads the view tree when back is pressed, so the check holds from
the overlay's first frame. The JS hold store and the Thread screen's
setOptions round trip are gone.
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: pingdotgg/t3code/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 42fd07b3-3476-45c7-89c9-b7c48e97d562

📥 Commits

Reviewing files that changed from the base of the PR and between 41f5786 and e093681.

📒 Files selected for processing (5)
  • apps/mobile/plugins/withAndroidNativeScreenBack.cjs
  • apps/mobile/plugins/withAndroidNativeScreenBack.test.mjs
  • apps/mobile/src/Stack.tsx
  • apps/mobile/src/components/AndroidAnchoredMenu.tsx
  • apps/mobile/src/lib/androidNativeBack.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/mobile/src/Stack.tsx

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

The mobile app adds a Kotlin Android back callback and enables native dismissal for the Thread route. It marks keyboard-visible anchored menus for JavaScript back handling. The native-stack patch also updates dismissal support and header item processing.

Changes

Android back handling

Layer / File(s) Summary
Native-stack dismissal contract
patches/@react-navigation%2Fnative-stack@7.17.6.patch
The patch adds an Android dismissal option, passes its enabled state to the native stack, emits transition completion, and adds an optional custom-header-item identifier.
Generated Android back callback
apps/mobile/plugins/withAndroidNativeScreenBack.cjs, apps/mobile/app.config.ts, apps/mobile/plugins/withAndroidNativeScreenBack.test.mjs
The Expo plugin adds a Kotlin callback that dismisses an eligible opted-in screen or delegates to the default back action. It validates template anchors and supports repeated application. Tests cover generated behavior and plugin errors.
JavaScript back markers and route wiring
apps/mobile/src/lib/androidNativeBack.ts, apps/mobile/src/Stack.tsx, apps/mobile/src/components/AndroidAnchoredMenu.tsx
The Thread route enables native dismissal. The anchored menu exposes the JavaScript back-handler ID when its anchor was opened while the keyboard was visible.

Native-stack header processing

Layer / File(s) Summary
Header item processing
patches/@react-navigation%2Fnative-stack@7.17.6.patch
The patch processes mail-search toolbar items and supplies center and toolbar items, subtitles, navigation item styles, and custom item identifiers.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant AndroidSystem
  participant MainActivity
  participant AndroidAnchoredMenu
  participant NativeStack
  participant ReactNativeBackDispatcher
  AndroidAnchoredMenu->>MainActivity: Expose JS back-handler ID in the view hierarchy
  AndroidSystem->>MainActivity: Invoke back callback
  alt JS back-handler ID is present
    MainActivity->>ReactNativeBackDispatcher: Delegate default back action
  else Native dismissal is enabled for an eligible screen
    MainActivity->>NativeStack: Dismiss top screen
  end
Loading

Merge Risk: 🔵 Low · up to e0936

The PR is mergeable with a bounded follow-up: custom iOS center header items will not retain identifier-based transition matching when callers provide an identifier.

Security Architecture Review

Security architecture risk: 🔵 Low · up to e0936

Native back is limited to opted-in screens, and no security-control bypass was established. Reconciliation after an interrupted dismissal and the presence of any parent-level navigation guard remain unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The identified exposure is a device-local back action on an opted-in, non-root screen in a narrow layout, not a newly identified network, credential, tenant, or data-store entrypoint.

Trust Boundaries and Controls

  • observed — Explicit opt-in, root-screen and layout checks, and the menu marker constrain when the native callback takes ownership; otherwise it delegates to the existing back dispatcher.

Resilience and Maintainability Implications

  • inferred — The inspected native stack’s dismissed-wrapper set counters repeated dismissal of the same wrapper, but that does not establish end-to-end JavaScript reconciliation for canceled or interrupted transitions.

Hardening Proposals

  • proposed — Exercise interrupted and repeated native back transitions against navigation-state reconciliation, and confirm whether any parent-level JavaScript exit guard applies to Thread before relying on the native path.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 9 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: Android back exits a thread without waiting for JavaScript.
Description check ✅ Passed The description explains what changed, why it changed, implementation details, measurements, behavioral checks, UI evidence, and related PRs. It follows the required sections and provides a video for …
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant