Skip to content

fix(mobile): long turns no longer freeze the app - #15780

Open
nekohasekai wants to merge 1 commit into
pingdotgg:mainfrom
nekohasekai:agent/fix-15641-ios-long-turn-freeze
Open

nekohasekai wants to merge 1 commit into
pingdotgg:mainfrom
nekohasekai:agent/fix-15641-ios-long-turn-freeze

Conversation

@nekohasekai

@nekohasekai nekohasekai commented Oct 4, 2026 •

Copy link
Copy Markdown

Problem

Opening a long thread while its turn is running freezes the mobile app. The thread never renders. While the turn runs, the "Working" row's shimmer makes Reanimated commit the shadow tree on every frame, so a React commit whose layout takes longer than a frame keeps failing React Native's revision check and starts over. Debug builds abort after 1,024 attempts; Release builds keep retrying.

Reproduce with the harness from the issue: start a turn with 30 units of history followed by 60 s of streaming, then open the thread about 8 s in.

Fixes #15641.

Change

#5451 turned off Reanimated's commit pausing (DISABLE_COMMIT_PAUSING_MECHANISM) for keyboard-controller. Reanimated documents that flag as safe only together with React Native's preventShadowTreeCommitExhaustion: with it, React Native takes a lock after three failed attempts and the commit lands. React Native 0.88 ships that flag off, both in the prebuilt iOS core and in the Android artifacts. Reanimated's suggested way to turn it on is to patch React Native and build it from source.

Instead, a local Expo module, t3-react-native-flags, turns the flag on at launch on iOS and Android. The React Native factory installs the stable release level's flags first. The module then swaps in the same set with this one flag on, before React Native starts, using React Native's dangerouslyForceOverride. Debug builds assert that nothing read preventShadowTreeCommitExhaustion before the swap. If an Expo template change ever starts React Native earlier, the debug app fails at launch instead of silently racing. On Android debug builds, Expo's dev launcher creates the React host before the module runs, and that reads two other flags; they keep their values because the module changes only this one.

Why keep the Reanimated flag. The thread feed still renders keyboard-controller's KeyboardChatScrollView through LegendList's KeyboardAwareLegendList, and the composer sits in a KeyboardStickyView. I built the variant with commit pausing back on to check.

  • On idle threads, the keyboard looked the same in all builds (6 tries each).
  • While a turn streamed, the composer jumped the whole keyboard height in one frame in 2 of 5 tries.
  • On main (2 tries) and with this fix (5 tries), it never moved more than about 50 pt per frame, keeping pace with the keyboard.

Opening the keyboard while a turn streams, 4x slow motion. main and this fix keep the composer on the keyboard; with commit pausing on, it has already jumped to the top while the keyboard has not appeared yet.

docs/internals/mobile-development.md records why the two flags go together and when the module can be removed.

Scope and approval

The triage of #15641 confirmed the starvation and listed enabling preventShadowTreeCommitExhaustion as the first fix option. It expected that to need React Native built from source; the forced override keeps iOS on the prebuilt core. It also asked for a keyboard recording before turning commit pausing back on; that comparison is above.

This PR fixes only the commit starvation. The markdown measurement cost tracked in #14010 still makes a long thread's first render slow. The change is mobile only. It adds native code, so it changes the native fingerprint and reaches users with the next store build, not an OTA update. The fingerprint check can't label a fork PR, so it has no 📱 Native Change label.

Verification

All runs used an iPhone 17 Pro Simulator on iOS 26.5, with the Debug dev client serving production JavaScript. The isolated backend held only synthetic data, plus the fake streaming provider from the issue. main is 4ee6bfd50e. The branch is rebased onto efecd3cf8b, which adds four commits that don't touch these dependencies or files.

Long-turn scenario, with the app sampled every 5 s:

main (2 runs) This fix
Thread renders Never Within 2.5 s of opening
JS thread inside ShadowTree::commit 99.9–100% 0–5%
JS thread idle 0% 59–99%
Main thread waiting on a lock 0–0.2% 0–0.1%
Outcome Aborts on attempts < 1024 (at 80.6 s and about 77 s) Turn finishes, no crash

An earlier run, made with a build that differed only in the debug check, also finished without a crash. In one other earlier run, the thread took about 45 s to appear while the main thread mounted its views. That is the rendering cost #14010 tracks, not commit retries.

main vs. this fix at 0:50 of real time

Long-turn video, main vs. this fix (48 s, 2x) · Keyboard video, main / this fix / commit pausing on (4x slow motion) · Sampling tables and measurements

Also checked:

  • iOS: the Debug build launches with the new check. lldb in the running app reads preventShadowTreeCommitExhaustion() as 1.
  • Android startup: on an API 36 emulator, the Debug APK launches with the check. The first version of the check fired, because Expo's dev launcher creates the React host first and reads enableBridgelessArchitecture and perfMonitorV2Enabled. That is why the check now looks only at the overridden flag.
  • Android scenario: main did not reproduce the freeze on the emulator. The thread rendered and the JS thread averaged about 30% of a core, so for Android this applies the same fix to the same mechanism rather than to a reproduced freeze. With the fix, the long turn also ran to the end.
  • Lint: swiftlint 0.65.1, ktlint 1.8.0 and detekt 1.23.8 (CI's versions) are clean on the module, and vp fmt is clean.

Not tested: Release builds, physical devices, iPad, and the keyboard on screens other than the thread (new task, terminal). I couldn't read the flag from the running Android app, because JDWP disconnected.

Evidence is uploaded as GitHub release attachments on the contributor fork, not committed to the repository.

Model: Claude Opus 5.5. Harness: Claude Code in T3 Code.

Reanimated's DISABLE_COMMIT_PAUSING_MECHANISM, set for keyboard-controller,
lets per-frame animation commits overtake a React commit that takes longer
than a frame, and React Native retries that commit forever. Reanimated
documents the flag as safe only with React Native's
preventShadowTreeCommitExhaustion, which the prebuilt core ships off. A local
Expo module now turns it on at launch on iOS and Android.

Fixes pingdotgg#15641
@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Oct 4, 2026
@macroscopeapp

macroscopeapp Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR adds a new cross-platform native module that force-enables a React Native commit-scheduling flag for every mobile app launch. Because it changes global runtime behavior and the effective default of the rendering pipeline, the change should receive human review.

You can add or adjust custom eligibility rules. Learn more.

@coderabbitai

coderabbitai Bot commented Oct 4, 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: f78abf2c-b06d-4948-8dc8-4778a47c788e
📥 Commits

Reviewing files that changed from the base of the PR and between efecd3c and 36d31d6.

📒 Files selected for processing (10)
  • apps/mobile/.swiftlint.yml
  • apps/mobile/modules/t3-react-native-flags/android/build.gradle
  • apps/mobile/modules/t3-react-native-flags/android/src/main/AndroidManifest.xml
  • apps/mobile/modules/t3-react-native-flags/android/src/main/java/expo/modules/t3reactnativeflags/T3ReactNativeFlagsPackage.kt
  • apps/mobile/modules/t3-react-native-flags/expo-module.config.json
  • apps/mobile/modules/t3-react-native-flags/ios/T3ReactNativeFeatureFlags.h
  • apps/mobile/modules/t3-react-native-flags/ios/T3ReactNativeFeatureFlags.mm
  • apps/mobile/modules/t3-react-native-flags/ios/T3ReactNativeFlags.podspec
  • apps/mobile/modules/t3-react-native-flags/ios/T3ReactNativeFlagsAppDelegateSubscriber.swift
  • docs/internals/mobile-development.md

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


📝 Walkthrough

Walkthrough

A new Expo native module overrides React Native’s preventShadowTreeCommitExhaustion flag during startup on iOS and Android. Debug builds check whether React Native accessed the flag before the override. The mobile development guide documents the flag’s relationship to Reanimated’s commit-pausing setting.

Changes

Native feature-flag override

Layer / File(s) Summary
Platform override implementations
apps/mobile/modules/t3-react-native-flags/ios/T3ReactNativeFeatureFlags.h, apps/mobile/modules/t3-react-native-flags/ios/T3ReactNativeFeatureFlags.mm, apps/mobile/modules/t3-react-native-flags/ios/T3ReactNativeFlagsAppDelegateSubscriber.swift, apps/mobile/modules/t3-react-native-flags/android/src/main/java/.../T3ReactNativeFlagsPackage.kt
The iOS app-delegate subscriber and Android lifecycle listener apply the override at launch. The providers set preventShadowTreeCommitExhaustion to true; debug builds check whether React Native accessed the flag before the override.
Module integration and development guidance
apps/mobile/modules/t3-react-native-flags/expo-module.config.json, apps/mobile/modules/t3-react-native-flags/android/build.gradle, apps/mobile/modules/t3-react-native-flags/android/src/main/AndroidManifest.xml, apps/mobile/modules/t3-react-native-flags/ios/T3ReactNativeFlags.podspec, apps/mobile/.swiftlint.yml, docs/internals/mobile-development.md
The Expo configuration declares Apple and Android support. Native build files configure the module, and SwiftLint includes its iOS path. The mobile development guide documents the flag relationship, release-level provider, and paired-removal requirement.

Priority: ⬆️ High

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

Change: Bug fix · Severity of issue fixed: High

Sequence Diagram(s)

sequenceDiagram
  participant IOSAppLaunch
  participant T3ReactNativeFlagsAppDelegateSubscriber
  participant T3ReactNativeFeatureFlags
  participant AndroidAppLaunch
  participant T3ReactNativeFlagsPackage
  participant FeatureFlagOverrides
  participant ReactNative
  IOSAppLaunch->>T3ReactNativeFlagsAppDelegateSubscriber: didFinishLaunchingWithOptions
  T3ReactNativeFlagsAppDelegateSubscriber->>T3ReactNativeFeatureFlags: applyOverrides
  T3ReactNativeFeatureFlags->>ReactNative: set preventShadowTreeCommitExhaustion to true
  AndroidAppLaunch->>T3ReactNativeFlagsPackage: create lifecycle listeners
  T3ReactNativeFlagsPackage->>FeatureFlagOverrides: register listener
  FeatureFlagOverrides->>ReactNative: set preventShadowTreeCommitExhaustion to true during onCreate
Loading

Merge Risk: ⚪ Minimal · up to 36d31

No identified issue remains that would prevent merging after normal checks.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 36d31

The change enables a fixed rendering safeguard rather than exposing a new user-controlled capability. Its main risk is dependence on native startup ordering and matching React Native defaults. Those assumptions are checked during development, but their behavior across all production startup and recovery paths remains unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The override's immediate scope is process-global React Native rendering behavior in the mobile application on both platforms, rather than a single thread screen. The inspected transition does not identify a cross-service or tenant authority change.

Trust Boundaries and Controls

  • observed — The visible control path is native lifecycle registration followed by a parameterless or fixed-provider override. Public declaration alone does not establish attacker reachability: the inspected callbacks ignore incoming parameters, and the Android listener and provider are private.

Resilience and Maintainability Implications

  • inferred — The debug prior-read checks provide development-time detection of the principal ordering violation, not a demonstrated production recovery mechanism. Repetition, interruption, concurrent reads, and late replacement cannot be assessed fully from the local wrappers; this is an evidence limitation rather than an established security failure.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 3 files. (7 skipped: 7 … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed #15641 requires a long thread to render and the app to remain responsive while a turn streams. The iOS and Android module overrides React Native’s preventShadowTreeCommitExhaustion flag while retain…
Out of Scope Changes check ✅ Passed The added native module, SwiftLint inclusion, and mobile-development documentation support the #15641 fix. The changes remain within mobile scope. The documentation distinguishes the separate markdown…
Title check ✅ Passed The title clearly identifies the mobile freeze during long turns, which is the primary problem addressed by the changes.
Description check ✅ Passed The description covers the problem and reproduction steps, explains the fix and its scope, provides issue context, and reports focused verification results, limitations, and evidence. It satisfies the…
Full details: Docstring Coverage

Explanation

Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 3 files. (7 skipped: 7 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

[Bug]: iOS app stops responding during long turns: Reanimated commits starve React commits

1 participant