Skip to content

fix(mobile): Stabilize the iOS app on main#3910

Open
mwolson wants to merge 5 commits into
pingdotgg:mainfrom
mwolson:ios-fixes-main
Open

fix(mobile): Stabilize the iOS app on main#3910
mwolson wants to merge 5 commits into
pingdotgg:mainfrom
mwolson:ios-fixes-main

Conversation

@mwolson

@mwolson mwolson commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Backport the applicable v1 iOS runtime and chat reliability fixes onto main.
  • Recover cached shell and thread state cleanly after reconnects, and make stalled thread-detail loading visible and retryable.
  • Keep keyboard, transparent-header, floating-composer, Markdown, and screenshot layout predictable on a real iPhone.
  • Harden Release Hermes crash diagnosis so fatals leave recoverable disk records without attaching a device console.
  • Stop Thread catch-up re-renders from looping through PreventRemoveProvider via unstable native-stack options updates.

Problem and Fix

Problem and Why it Happened Fix
Cached shell and thread snapshots could remain marked as synchronizing after a reconnect, while thread-detail failures lost useful error information or appeared to load forever. Restore live status when cached data is available, preserve useful failure messages, retain prior thread data during refresh failures, and retry stalled detail requests before showing an actionable error.
LegendList and keyboard-controller scroll math did not consistently account for UIKit's automatic top and bottom insets around a transparent header and floating composer. Patch the inset and offset calculations, measure the composer overlay, account for the usable viewport, and correct short-content and hydrated-thread resting positions.
Screenshot rows could change geometry while their asset URI resolved, become non-interactive, or leave the newest content beneath the composer when a thread reopened. Keep a fixed attachment container through loading and display, use a full-size accessible press target, preserve the presenting thread behind the image viewer, and apply a one-time end correction after hydrated overflowing content is measured.
Composer image objects contained client-only draft fields that do not belong in startTurn payloads. Convert draft attachments to the upload wire shape for direct sends and outbox retries.
Native selectable Markdown could render at the wrong width or publish stale child content when Yoga skipped measurement on a clone. Add explicit fill-width support and rebuild native attributed content from current children before publishing changed state.
Release Hermes fatals were hard to diagnose after restart. A thin ErrorUtils logger often lost the race with RCTFatal abort and left empty Documents crash logs. Install crash capture early: JS write-first records, dual disk paths, native fsync (T3NativeControls.writeSyncText), unhandled-rejection capture, nav/outbox/stop breadcrumbs with max-wait flush, and a native RCTSetFatalHandler path (T3CrashLog + AppDelegate plugin) that writes last-crash.json / crash-native-*.json with the real JS message and stack.
Thread catch-up and other busy re-renders rebuilt inline native-stack options every layout, so navigation.setOptions re-entered PreventRemoveProvider until React hit maximum update depth. Compare a structural options signature before setOptions, keep stable header-item factories via refs, and memoize Thread screen options.

Defensive Fixes

Problem and Why it Happened Fix
Continuous breadcrumb activity could keep resetting the trailing debounce so last-breadcrumbs.json never flushed before a kill. Pair the trailing debounce with a max-wait timer so breadcrumbs still land about every 400ms under continuous activity.
Disclosure expansion and short-to-overflow transitions could fight automatic end maintenance. Suspend end maintenance while content underflows or a disclosure owns the visible-content anchor, then restore it through the intended transition path.

UI Changes

This affects thread loading feedback, screenshot messages and their viewer, transparent-header scrolling, and the floating composer. Before/after evidence and a short interaction recording will be added while the PR remains open.

Validation

  • vp check: pass
  • vp run typecheck: pass
  • Crash-logger unit tests (breadcrumbs, crash record shape, nav path helper, options signature): pass
  • Release Hermes build in the iOS Simulator: pass
  • Signed Release Hermes build on a physical iPhone: pass
  • Reconnect state and failure-message unit coverage: pass
  • Screenshot renders normally and remains tappable after asset resolution: pass
  • Screenshot viewer opens without replacing the presenting thread: pass
  • Reopened screenshot thread keeps the newest content above the floating composer: pass
  • Screenshot top remains reachable below the transparent header: pass
  • Follow-up message after a screenshot preserves complete scroll access: pass
  • Device crash capture: after a real fatal, Documents includes last-crash.json with source: "rct-fatal" and the JS exception message (verified for maximum update depth on Thread)
  • Thread catch-up after the stack-options fix: large active thread finished catching up without the previous PreventRemoveProvider abort
  • SwiftLint opening_brace fix for T3CrashLog (CI Mobile Native Static Analysis)

Remaining Work

  • Keep exercising heavy-thread catch-up and streaming on a physical iPhone for residual performance issues (memory / large projections are separate from the options-loop fatal).
  • Add before/after screenshots and a short interaction recording.

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

Note

Stabilize the iOS app with crash logging, scroll fixes, and reconnect recovery

  • Adds a full crash logging pipeline: JS errors and unhandled rejections are intercepted, persisted to crash-logs/ via native sync I/O or Expo FS, and pruned on startup. Navigation state changes are recorded as breadcrumbs.
  • Fixes ThreadFeed scroll behavior: initial and underflow states auto-scroll to the correct resting position; the floating composer inset and gap are now correctly accounted for in scroll calculations.
  • Fixes StackHeader / NativeStackScreenOptions to call navigation.setOptions only when a structural signature of options changes, using stable function references for header item factories to avoid unnecessary re-renders.
  • Adds retry logic in ThreadRouteScreen for stalled thread-detail loads: retries twice after 8s/12s then surfaces a user-facing error.
  • Fixes buildProjectThreadStartTurnInput and useThreadOutboxDrain to strip client-only fields (id, previewUri) from image attachments before sending.
  • Fixes shell and thread sync state to restore live status after reconnect when cached data exists, without requiring new stream events.
  • Behavioral Change: setReady in shell and thread state now sets status to live (not synchronizing) when a cached snapshot/data is present on reconnect.

Macroscope summarized f347d3e.


Note

High Risk
Touches startup crash hooks, AppDelegate prebuild mutation, large LegendList patches, and navigation setOptions behavior—areas that can affect Release stability and scroll correctness across thread screens.

Overview
Backports iOS runtime and chat reliability fixes: durable crash diagnostics, thread feed scroll/composer layout, reconnect and detail-loading behavior, and smaller native/JS hardening.

Crash logging loads first via installCrashLog, wraps ErrorUtils and unhandled rejections, writes minimal then full JSON to Documents/crash-logs/ using native writeSyncText (fsync), and records nav/outbox/stop breadcrumbs with debounced disk flush. An Expo plugin patches AppDelegate to hook RCTFatal / fatal exceptions into the same persistence path when JS never flushes.

Thread UI tracks content height and composer overlay, adds a composer gap, corrects short-content underflow vs transparent-header insets (LegendList patch + contentInsetStartAdjustment), shows a loading overlay while hydrating, fixes screenshot attachments (stable layout, Pressable, overFullScreen viewer), and enables fillWidth on native markdown. T3MarkdownText rebuilds attributed content on every layout() instead of caching measure-only state so text does not disappear when Yoga skips measureContent.

Navigation skips redundant setOptions when a structural signature of stack options is unchanged (avoids Release “maximum update depth” loops). Thread detail retries stalled loads (8s/12s) then surfaces an error; shell/thread sync setReady restores live when cached data exists after reconnect. startTurn / outbox sends strip client-only image fields via toUploadChatImageAttachments. Shared causeFailureMessage improves error text in queries and thread state.

Reviewed by Cursor Bugbot for commit f347d3e. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. 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: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9bdc5080-c448-41eb-8bf7-768a46d9c982

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Jul 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

+ onScrollBeginDrag: (event) => {

The onScrollBeginDrag wrapper is created inside a useMemo(..., []) that captures the initial onScrollBeginDrag prop. If a parent passes a new callback after mount, drag events continue invoking the stale closure — the prop update is silently ignored. Store the latest callback in a ref or include onScrollBeginDrag in the memo dependencies.

🤖 Copy this AI Prompt to have your agent fix this:
In file @patches/@legendapp__list@3.2.0.patch around line 951:

The `onScrollBeginDrag` wrapper is created inside a `useMemo(..., [])` that captures the initial `onScrollBeginDrag` prop. If a parent passes a new callback after mount, drag events continue invoking the stale closure — the prop update is silently ignored. Store the latest callback in a ref or include `onScrollBeginDrag` in the memo dependencies.

Comment thread patches/@legendapp__list@3.2.0.patch
Co-authored-by: codex <codex@users.noreply.github.com>
Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx Outdated

let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const scheduleRetry = () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium threads/ThreadRouteScreen.tsx:199

The stall error appears after ~40s, not the ~20s implied by THREAD_DETAIL_STALL_ERROR_DELAY_MS. After two failed retries (8s + 12s), scheduleRetry() schedules the error after an additional 20s, so a permanently empty detail doesn't show detailLoadError until roughly 40 seconds. When the retry limit is reached, set the stalled state immediately instead of scheduling another timeout.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadRouteScreen.tsx around line 199:

The stall error appears after ~40s, not the ~20s implied by `THREAD_DETAIL_STALL_ERROR_DELAY_MS`. After two failed retries (8s + 12s), `scheduleRetry()` schedules the error after an additional 20s, so a permanently empty detail doesn't show `detailLoadError` until roughly 40 seconds. When the retry limit is reached, set the stalled state immediately instead of scheduling another timeout.

Comment on lines +1439 to +1442
void props.listRef.current?.scrollToOffset({
animated: false,
offset: -anchorTopInset,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium threads/ThreadFeed.tsx:1439

When usesNativeAutomaticInsets is false, the underflow correction scrolls to -anchorTopInset, but that same inset is already rendered as the ListHeaderComponent spacer. This double-counts the header inset and positions a short conversation at a negative overscroll offset instead of resting at offset 0. The negative offset should only be used for native automatic-inset layouts where the header inset lives in UIKit's adjustedContentInset and is absent from contentHeight.

Suggested change
void props.listRef.current?.scrollToOffset({
animated: false,
offset: -anchorTopInset,
});
void props.listRef.current?.scrollToOffset({
animated: false,
offset: usesNativeAutomaticInsets ? -anchorTopInset : 0,
});
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadFeed.tsx around lines 1439-1442:

When `usesNativeAutomaticInsets` is false, the underflow correction scrolls to `-anchorTopInset`, but that same inset is already rendered as the `ListHeaderComponent` spacer. This double-counts the header inset and positions a short conversation at a negative overscroll offset instead of resting at offset `0`. The negative offset should only be used for native automatic-inset layouts where the header inset lives in UIKit's `adjustedContentInset` and is absent from `contentHeight`.

Comment thread apps/mobile/src/lib/breadcrumbPersist.ts
@mwolson mwolson marked this pull request as ready for review July 12, 2026 22:12

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b2a23fb. Configure here.

Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@macroscopeapp

macroscopeapp Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. This PR introduces a new crash logging infrastructure and makes significant runtime behavior changes to thread loading and navigation. There are unresolved review comments including a high-severity hook ordering issue that could cause crashes when thread data becomes null mid-session.

You can customize Macroscope's approvability policy. Learn more.

Capture RCTFatal message and stack to Documents via a native handler so
Release Hermes aborts leave last-crash.json without a device console.
Harden the JS ErrorUtils path with a minimal write-first record, durable
fsync writes, breadcrumb max-wait flush, and an Expo AppDelegate plugin.

Skip NativeStackScreenOptions setOptions when content is unchanged so
Thread catch-up re-renders do not loop through PreventRemoveProvider and
hit maximum update depth.
return "";
}
const record = options as Record<string, unknown>;
return stableJsonStringify({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium native/nativeStackOptionsSignature.ts:12

buildNativeStackOptionsSignature omits fields like headerShown, headerTransparent, presentation, and gestureEnabled. When a caller changes one of these omitted options while the included fields stay the same, the signature is identical, so the skip logic suppresses setOptions and the screen keeps stale options. Consider including all NativeStackNavigationOptions fields in the signature (or documenting why only this subset is tracked).

Also found in 1 other location(s)

apps/mobile/src/native/StackHeader.tsx:114

The signature cache is not scoped to navigation: when the navigation object/context changes while props.options has the same signature, this effect reruns but returns at the signature check before calling the new navigator's setOptions. The replacement navigator therefore never receives this component's screen options. Reset/cache the signature per navigation object or perform the comparison only after accounting for navigation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/native/nativeStackOptionsSignature.ts around line 12:

`buildNativeStackOptionsSignature` omits fields like `headerShown`, `headerTransparent`, `presentation`, and `gestureEnabled`. When a caller changes one of these omitted options while the included fields stay the same, the signature is identical, so the skip logic suppresses `setOptions` and the screen keeps stale options. Consider including all `NativeStackNavigationOptions` fields in the signature (or documenting why only this subset is tracked).

Also found in 1 other location(s):
- apps/mobile/src/native/StackHeader.tsx:114 -- The signature cache is not scoped to `navigation`: when the navigation object/context changes while `props.options` has the same signature, this effect reruns but returns at the signature check before calling the new navigator's `setOptions`. The replacement navigator therefore never receives this component's screen options. Reset/cache the signature per navigation object or perform the comparison only after accounting for `navigation`.

</View>
) : null}
{props.contentPresentation.kind === "loading" ? (
<View pointerEvents="none" className="bg-screen" style={StyleSheet.absoluteFill}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium threads/ThreadFeed.tsx:1890

The loading overlay sets pointerEvents="none", so taps pass through to the still-mounted feed underneath while only the "Loading messages" placeholder is visible. Users can unknowingly activate hidden links, image viewers, or disclosure controls at the tapped coordinates. Consider removing pointerEvents="none" so the overlay intercepts input and blocks interaction with the hidden feed.

Suggested change
<View pointerEvents="none" className="bg-screen" style={StyleSheet.absoluteFill}>
<View className="bg-screen" style={StyleSheet.absoluteFill}>
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadFeed.tsx around line 1890:

The loading overlay sets `pointerEvents="none"`, so taps pass through to the still-mounted feed underneath while only the "Loading messages" placeholder is visible. Users can unknowingly activate hidden links, image viewers, or disclosure controls at the tapped coordinates. Consider removing `pointerEvents="none"` so the overlay intercepts input and blocks interaction with the hidden feed.

</>
);

const threadScreenOptions = useMemo(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High threads/ThreadRouteScreen.tsx:898

The useMemo that builds threadScreenOptions is placed after the early returns for missing environmentId, threadId, or selectedThread. If any of those become null on a re-render (e.g. the thread is removed mid-session), the component returns before reaching useMemo, so React throws "Rendered fewer hooks than expected" and crashes the screen. Move the hook before the conditional returns so the hook count stays constant across renders.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadRouteScreen.tsx around line 898:

The `useMemo` that builds `threadScreenOptions` is placed after the early returns for missing `environmentId`, `threadId`, or `selectedThread`. If any of those become null on a re-render (e.g. the thread is removed mid-session), the component returns before reaching `useMemo`, so React throws "Rendered fewer hooks than expected" and crashes the screen. Move the hook before the conditional returns so the hook count stays constant across renders.

CI Mobile Native Static Analysis failed on a multi-line if where the
opening brace was on the following line.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ 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