Skip to content

[Bug]: iOS: attaching a camera photo (HEIC) from the photo library freezes the composer; draft cannot be cancelled or removed, survives app relaunch #11427

Description

@Nelglor

Note

🤖 Claude Fable 5.1 responding on behalf of Nick

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/mobile

Steps to reproduce

  1. Open a thread in the iOS app.
  2. Tap attach → Photo Library.
  3. Pick a normal camera photo (HEIC, 12 MP or larger). Not a screenshot.
  4. Wait.

Expected behavior

The photo is downscaled and attached in under a second, with a thumbnail and a working remove button. Cancelling mid-import should be possible.

Actual behavior

The composer freezes for a long time while the photo is imported. There is no cancel. When it finishes, the thumbnail appears but the composer is only half alive: typing still works (the native editor accepts keystrokes), but the editor no longer grows past one line, so anything longer than a line scrolls out of view, and neither send nor the attachment's remove button does anything. Force-closing and reopening the app restores the same stuck draft. The only recovery found so far is rebooting the phone. Screenshots of the same photo (PNG/JPEG, small) attach fine.

Root cause (from reading main as of 2026-09-12 and the 1.1.0 tag 1d1bf5040)

Three things stack up in apps/mobile/src/lib/composerImages.ts pickComposerMedia:

  1. Full-resolution re-encode through the bridge. The picker is called with base64: true, quality: 1. On iOS with PHPicker, expo-image-picker's MediaHandler calls ImageUtils.readJpegBase64From(..., tryReadingFile: true), which decodes the whole HEIC into a UIImage, re-encodes it as JPEG at compressionQuality: 1.0, and base64-encodes it. A 12 MP photo lands around 6–10 MB of JPEG, ~8–13 MB of base64, handed to JS as one string. Nothing downscales first (the web composer caps at 2048 px in imageCompression.ts; mobile has no equivalent).

  2. The preview is the data URL itself. Because the picker relabels HEIC as image/jpeg, mimeType !== asset.mimeType and the attachment gets previewUri: dataUrl. In 1.1.0 ComposerAttachmentStrip renders <Image source={{ uri: previewUri }}> with that multi-megabyte string. feat: add inline file previews and attachment chips across surfaces #11265 (merged 2026-09-12, not in the store build) added a comment describing exactly this failure: "Fabric re-parses an image source URL on every layout pass of the node, and a multi-megabyte data URL makes each Fabric commit slow enough that concurrent UI-thread commits ... win the race every time until the renderer aborts." That is the half-alive composer: the native text view (T3ComposerEditorView.swift) keeps accepting input on its own, but its height comes back through onComposerContentSizeChange and is applied by JS, and send/remove are JS Pressables, so with the JS/Fabric loop saturated the editor stays one line tall and no press lands.

  3. The draft is persisted with the data URL and re-hydrated on launch (use-composer-drafts.ts write/hydrate), so relaunching the app reloads the same giant preview and re-freezes. There is also no cancel path while launchImageLibraryAsync / the size gate are running.

Photos just over the 10 MB gate get rejected with the "exceeds the 10 MB attachment limit" alert instead of attached, so only photos that land under the cap hit the stuck state. That is why "some HEICs" seem to work and others brick the composer.

Related, not duplicates

Suggested fix

  • Drop base64: true from the picker and read the file URI instead. Downscale to a max edge (2048 px, matching the web client) and re-encode at ~0.85 before size-gating, via expo-image-manipulator or the picker's own quality option. This removes the bridge stall and makes the payload small.
  • Never use a data URL as previewUri. feat: add inline file previews and attachment chips across surfaces #11265's materializeDataUrlPreview covers the render side on main; the picker path should also write a thumbnail file up front so persistence never holds a multi-megabyte string.
  • Surface an in-progress state with a cancel while the pick/convert runs, and let remove work on a not-yet-ready attachment.

Workaround

Screenshot the photo and attach the screenshot. If the composer is already stuck, the persisted draft is per thread, so a fresh thread should work while the stuck one is rebooted away.

Impact

Blocks work completely (the affected draft needs a phone reboot to recover)

Version or commit

iOS app 1.1.0 from the App Store (installed 2026-09-10)

Activity

  1. juliusmarminge commented on Sep 12, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed as a real iOS composer bug on App Store 1.1.0 (1d1bf5040) and still present on current main. Not a duplicate of #10361 (web), #9727 (persistence-only, held), #11265 (render mitigation on main only), or #7225 (provider HEIC rejection).

    The three stacked causes in the report check out against the code. One nuance on the “half-alive” height path is below; it does not change the diagnosis.

    What the code does

    1. Full-resolution re-encode through the bridge. pickComposerMedia in apps/mobile/src/lib/composerImages.ts still calls launchImageLibraryAsync({ base64: true, quality: 1 }) on both 1.1.0 and main. There is no max-edge downscale (web caps at 2048px in apps/web/src/lib/imageCompression.ts; mobile has no expo-image-manipulator equivalent). A 12 MP HEIC is decoded, re-encoded as JPEG at compression 1.0, then handed to JS as one multi-megabyte base64 string. The 10 MB gate (PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) runs only after that. Oversized conversions are rejected with “exceeds the 10 MB attachment limit” and never attach — that is why some HEICs appear fine and others brick the composer.

    2. HEIC previews become the data URL. The picker returns JPEG magic (/9j/) while metadata is still image/heic. HEIC is not a supported send type, so the attachment is relabeled image/jpeg and gets previewUri: dataUrl because mimeType !== asset.mimeType. Tests in composerFiles.test.ts assert exactly that. PNG/GIF/WebP originals keep asset.uri as the preview — that is why a screenshot of the same photo attaches cleanly.

    On 1.1.0, ComposerAttachmentStrip renders <Image source={{ uri: previewUri }}> with that string. #11265 (merged 2026-09-12, not in the store build) added materializeDataUrlPreview and the comment describing the Fabric stall: a multi-megabyte data URL re-parsed on every layout pass starves JS/Fabric commits.

    That matches the half-alive composer: T3ComposerEditorView is a native UITextView and still accepts keystrokes. Send and the attachment remove button are JS Pressables. Composer expansion is also JS (isFocused → height: 36 collapsed vs minHeight: 72 / maxHeight: 160 expanded). Native onComposerContentSizeChange is emitted but not subscribed in the JS wrapper, so the stuck one-line height is the JS/Fabric layout path failing, not a missing native resize callback.

    #11265 on main avoids feeding the giant URL to <Image>. It does not remove the picker bridge stall, the inlined dataUrl on the attachment, persist/hydrate of that payload, or the missing cancel.

    3. The draft is persisted and re-hydrated. pickComposerMedia writes both dataUrl and previewUri. use-composer-drafts.ts JSON.stringifys the full draft document into composer-drafts/drafts.json after a 200 ms debounce and hydrates it on launch. The schema still requires fileUri or inline dataUrl; the comment on the image type is explicit that current writers still use inline bytes. Drafts are keyed per thread, so a fresh thread works while the stuck one reloads the same preview.

    There is no cancel while launchImageLibraryAsync / the size gate run (onPickDraftMedia in use-thread-composer-state.ts awaits the picker, then appends).

    Related work

    Suggested fix

    1. Drop base64: true from the picker and read the file URI. Downscale to a 2048px max edge (match web) and re-encode at ~0.85 before the 10 MB gate, via expo-image-manipulator or the picker’s quality option.
    2. Never use a data URL as previewUri. Write a thumbnail file up front so persistence never holds a multi-megabyte string. feat: add inline file previews and attachment chips across surfaces #11265 is a render-side safety net, not a substitute.
    3. Show an in-progress attachment with cancel while pick/convert runs, and let remove work on a not-yet-ready item.

    Do not wait on #9727’s native-runtime hold for (1) or (3). Ship #11265 in the next store build as a partial mitigation of the post-import freeze.

    Workaround

    Screenshot the photo and attach the screenshot. The stuck draft is per thread, so a new thread should work. Force-quit is not enough: relaunch re-hydrates the same preview.

    Validation

    On a 12 MP+ camera HEIC (not a screenshot): attach from Photo Library; confirm import finishes quickly with a thumbnail; typing grows the editor; send and remove work; force-quit + relaunch does not re-freeze. Compare with a screenshot of the same photo and with a conversion that would have exceeded 10 MB at quality: 1.

    Severity: high (the affected draft is unusable; force-quit does not recover it).

    Accepting as bug.

  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions