Repository navigation
[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
Activity
Triage
Confirmed as a real iOS composer bug on App Store 1.1.0 (
1d1bf5040) and still present on currentmain. Not a duplicate of #10361 (web), #9727 (persistence-only, held), #11265 (render mitigation onmainonly), 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.
pickComposerMediainapps/mobile/src/lib/composerImages.tsstill callslaunchImageLibraryAsync({ base64: true, quality: 1 })on both 1.1.0 andmain. There is no max-edge downscale (web caps at 2048px inapps/web/src/lib/imageCompression.ts; mobile has noexpo-image-manipulatorequivalent). 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 stillimage/heic. HEIC is not a supported send type, so the attachment is relabeledimage/jpegand getspreviewUri: dataUrlbecausemimeType !== asset.mimeType. Tests incomposerFiles.test.tsassert exactly that. PNG/GIF/WebP originals keepasset.urias the preview — that is why a screenshot of the same photo attaches cleanly.On 1.1.0,
ComposerAttachmentStriprenders<Image source={{ uri: previewUri }}>with that string. #11265 (merged 2026-09-12, not in the store build) addedmaterializeDataUrlPreviewand 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:
T3ComposerEditorViewis a nativeUITextViewand still accepts keystrokes. Send and the attachment remove button are JSPressables. Composer expansion is also JS (isFocused→height: 36collapsed vsminHeight: 72/maxHeight: 160expanded). NativeonComposerContentSizeChangeis 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
mainavoids feeding the giant URL to<Image>. It does not remove the picker bridge stall, the inlineddataUrlon the attachment, persist/hydrate of that payload, or the missing cancel.3. The draft is persisted and re-hydrated.
pickComposerMediawrites bothdataUrlandpreviewUri.use-composer-drafts.tsJSON.stringifys the full draft document intocomposer-drafts/drafts.jsonafter a 200 ms debounce and hydrates it on launch. The schema still requiresfileUrior inlinedataUrl; 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 (onPickDraftMediainuse-thread-composer-state.tsawaits the picker, then appends).Related work
- Typing becomes slow with a very tall image attached to the composer #10361 — web composer: full-size preview + image bytes inlined in the persisted draft. Same family, different client.
- perf(mobile): keep image bytes outside draft JSON #9727 (Theo, open,
DO NOT MERGE; M5 in Track performance audit fixes #9661) — keep image bytes out of mobile draft JSON. Fixes item 3 and the relaunch re-freeze once a new native runtime ships. Does not fix item 1 or cancel. - feat: add inline file previews and attachment chips across surfaces #11265 (merged 2026-09-12) — materialize large data-URL previews to a cache file in the strip. Item 2 on
mainonly; not in 1.1.0. - [Bug]: HEIC attachments are accepted then rejected by the provider - "Unsupported Claude image attachment type 'image/heic'" #7225 (closed) — HEIC rejected by the provider. Format acceptance only.
Suggested fix
- Drop
base64: truefrom 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, viaexpo-image-manipulatoror the picker’squalityoption. - 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. - 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.- addedbugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.acceptedfeature request acceptedfeature request acceptedvia-triageFiled through npx t3 triageFiled through npx t3 triage
on Sep 12, 2026
Note
🤖 Claude Fable 5.1 responding on behalf of Nick
Before submitting
Area
apps/mobile
Steps to reproduce
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
mainas of 2026-09-12 and the 1.1.0 tag1d1bf5040)Three things stack up in
apps/mobile/src/lib/composerImages.tspickComposerMedia:Full-resolution re-encode through the bridge. The picker is called with
base64: true, quality: 1. On iOS with PHPicker, expo-image-picker'sMediaHandlercallsImageUtils.readJpegBase64From(..., tryReadingFile: true), which decodes the whole HEIC into aUIImage, re-encodes it as JPEG atcompressionQuality: 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 inimageCompression.ts; mobile has no equivalent).The preview is the data URL itself. Because the picker relabels HEIC as
image/jpeg,mimeType !== asset.mimeTypeand the attachment getspreviewUri: dataUrl. In 1.1.0ComposerAttachmentStriprenders<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 throughonComposerContentSizeChangeand is applied by JS, and send/remove are JSPressables, so with the JS/Fabric loop saturated the editor stays one line tall and no press lands.The draft is persisted with the data URL and re-hydrated on launch (
use-composer-drafts.tswrite/hydrate), so relaunching the app reloads the same giant preview and re-freezes. There is also no cancel path whilelaunchImageLibraryAsync/ 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
base64: truefrom 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, viaexpo-image-manipulatoror the picker's ownqualityoption. This removes the bridge stall and makes the payload small.previewUri. feat: add inline file previews and attachment chips across surfaces #11265'smaterializeDataUrlPreviewcovers the render side on main; the picker path should also write a thumbnail file up front so persistence never holds a multi-megabyte string.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)