Repository navigation
fix: iPhone Duo / iOS 27 resizable-window readiness - #3818
Merged
Merged
Conversation
From iOS 27 the system stacks its controls down a single vertical edge, so the horizontal safe area insets are asymmetric and the top inset can be zero. Measured on iPhone Duo: top 0, bottom 34, left 0, right 84. Nothing in the SDK read insets.left or insets.right, so content rendered underneath the system indicators - channel row timestamps, the header compose button, the unread badge and the scroll-to-bottom button all landed inside the 84pt reserved column. Adds useHorizontalInsets and applies it to the surfaces that own the window, which a consumer cannot inset from outside, plus the full-width list roots. Two pitfalls this had to work around: - A paddingLeft longhand replaces the padding shorthand for that side, so merging a zero inset over an existing padding silently drops it. useHorizontalInsets omits a side entirely when its inset is 0. - position: 'absolute' children are offset from the border box, so a container's padding never reaches them. The inset is added to the offsets themselves in MessageList, MessageFlashList and MessageComposer. Snapshots record an extra wrapper View (empty at zero insets) and paddingHorizontal becoming paddingLeft/paddingRight - same value, no behaviour change on devices without horizontal insets. Refs RN-429
In Split View a visible app sits in the inactive state. useAppStateListener treated inactive as equivalent to background, so useIsOnline called client.closeConnection() and showed the offline banner while the user was reading the chat. This is on by default (closeConnectionOnBackground). The same conflation sent typing.stop from Channel, and KeyboardCompatibleView had its own copy of it, dropping the keyboard listeners so the composer stopped tracking the keyboard. Only a real background transition now counts, guarded so each callback fires once per transition. This also stops the connection dropping during transient inactive moments such as Control Center and the app switcher. The previous test codified the bug and has been rewritten. Refs RN-429
Dimensions.get('screen') is not the app's window, in either direction: an
iPhone-only app in an iPad compatibility window measured an 834pt screen behind
a 417pt window, while on the unfolded iPhone Duo screen still reports the outer
display at 466pt behind a 951pt window. Sizing from it renders at under half
width there.
The picker and gallery grids also sized tiles with vw(100) while rendering
inside a bottom sheet, so thumbnails overflowed it once the sheet was inset.
- New useContainerWidth hook; grids measure their own box and pass the tile
size down.
- The channel details media grid derives its column count from that width past
a 600pt breakpoint instead of a hardcoded 3, with a new numberOfColumns prop
as an escape hatch. Counts stay even past the breakpoint, per Apple's
guidance that an odd count puts a column across the fold. Phone widths are
pinned to 3 by test and are unchanged.
- MediaListLoadingSkeleton derives the same count, so the layout no longer
jumps from 3 tiles to 6 on load.
- MessageItemView takes its swipe hit slop from useWindowDimensions.
- useScreenDimensions is deprecated in favour of useViewport; its vw/vh are
percentages of the screen rather than the window. Behaviour is unchanged.
Refs RN-429
…erView UIScreen.main.scale was cached once at setupLayers(), so the shimmer rastered at the wrong scale if the view moved to a display with a different scale. It now reads its own traitCollection.displayScale and resyncs on didMoveToWindow and traitCollectionDidChange. UIApplication.shared.applicationState is app-wide and says nothing about whether this particular view's scene is on screen once an app can share the display. Replaced with the view's own window.windowScene.activationState plus UIScene notification observers. Refs RN-429
The SDK insets the surfaces it owns; a consumer's own chrome and in-flow
content is its own job. On iPhone Duo's outer display the reserved column is
84pt wide on the trailing edge.
- ScreenHeader and BottomTabs add the insets to their existing padding rather
than substituting for it, so a zero inset cannot drop it.
- The secondary list screens wrap their content in a SafeAreaView with left and
right edges.
- ChannelScreen and ThreadScreen pass bottomInset through to Channel.
- The ChannelListScreen wrapper is removed now that ChannelList insets itself,
so the two do not double-pad.
- Toast read Dimensions.get('window') at module scope, baking the launch width
into StyleSheet.create; it now reads per render.
Refs RN-429
Records the measured device geometry, all 17 findings with one withdrawn and one left unfixed, the two React Native style pitfalls this work walked into, and cross-checks against Apple's Human Interface Guidelines. Notes the follow-ups deliberately not actioned here: the ExpoMessaging launch blocker, two-pane layout on the inner display, the grid remount on fold, and adopting native chrome in the sample app. Refs RN-429
Contributor
SDK Size
|
Review follow-up. Four more instances of the class this branch already fixed twice: absolutely positioned children whose parent's horizontal padding never reaches them, because they are offset from the border box. - NotificationList spans the width of whatever hosts it, with left/right of its own. One fix covers its ten render sites. - NetworkDownIndicator sat at left: 0, right: 0 inside the padded message list container, in both MessageList and MessageFlashList. - ImageGalleryHeader read only the top inset, although the footer gained left/right edges here. Its trailing slot is consumer-customisable, and under RTL the close button sits at that edge. - The autocomplete suggestion list used width: '100%', which resolves against the border box and so ignored the container's padding. It now spans with explicit left/right. Also closes the ordering hazard behind them: the theme override was spread *after* the inset-bearing offsets, so a consumer setting `right` silently dropped the inset and put the element back under the system controls. The new `addInset` helper is applied after every spread instead, keeping a consumer's own value and adding the inset to it. A themed percentage passes through untouched, since an inset cannot be expressed in one. One snapshot records width: '100%' becoming left: 0, right: 0 - geometrically identical at zero insets. Refs RN-429
Dropping the module-scope Dimensions read left the value itself window-derived. The toast sits in a SafeAreaView that applies the horizontal insets, so on the Duo outer display it asked for 419pt inside a 350pt content box and overflowed its left edge. A percentage width resolves against the parent's content box, so the safe area padding is already accounted for and no dimension is read at all. Makes the toast marginally narrower on every device, since the container's own 16pt offsets now count against it. Refs RN-429
…nt picker
Passing `bottomInset={insets.bottom}` to Channel counted the home indicator
twice. The screen already keeps the composer clear of it, so the extra inset
lifted the picker sheet 34pt and shifted the composer 34pt less, leaving the
composer hidden behind the sheet whenever the picker was open, with an empty
band under the photo grid.
Found by A/B testing develop against this branch on an iPhone 18 Pro Max, in
portrait and landscape. With this reverted, the portrait picker is
pixel-identical to develop apart from the header's intended padding change.
It was added for the Duo, but nothing about it is Duo-specific: the Duo's
bottom inset is the same 34pt as every Face ID iPhone.
Refs RN-429
FlatList rejects a numColumns change on a mounted list, so the media grid remounts under a new key whenever its column count changes - which reset it to the top. That was expected on a fold, but it also hit ordinary phones: an iPhone 18 Pro Max in landscape is 832pt wide after insets, past the 600pt breakpoint, so every rotation went from 3 to 6 columns and threw the user back to the top. develop keeps its place, so this was a regression. The new useGridScrollAnchor hook tracks the first visible item while scrolling and, after the remount, scrolls to the row that now holds it once the content reaches that far. It ignores scroll events that arrive with a changed viewport: rotating to a taller viewport over shorter content makes iOS clamp the offset before the grid re-lays out, which would otherwise move the anchor. Verified on device across a portrait -> landscape -> portrait round trip. The handlers are applied after additionalFlatListProps and call the consumer's own onScroll and onContentSizeChange, so those keep firing. Also corrects the audit doc, which said phone widths were unchanged: that holds in portrait only. Refs RN-429
Testing on an Android emulator found the previous restore lost its place, and re-checking iOS found a smaller version of the same problem. - Android landed about three rows short on the way back to portrait. The reported content size covers only the rows rendered so far, so the first scrollToOffset was clamped (asked for 827pt, landed at 575pt). The restore is now re-applied as the content grows until a scroll event confirms it landed, and stops if the user starts scrolling. - iOS landed one row short. It reports a momentum end for the programmatic restore, and tracking it re-rounded the anchor down to a whole row at the wider column count. Momentum now only counts when it follows a drag. The anchor also now follows only user-driven scrolling (drags and the flings that follow them), which replaces the viewport-size check: Android reports a resize-driven clamp after the viewport has already changed, so that check let it through. The hook now composes the consumer's scroll handlers itself, which removes the hand-written wrappers from MediaList. Verified on device across a portrait -> landscape -> portrait round trip on an iPhone 18 Pro Max (iOS 27.0) and a Pixel 10 emulator (Android 17), both returning to the same rows. Refs RN-429
Six screens wrapped only their list in the left/right SafeAreaView and rendered the header as a sibling outside it. Once ScreenHeader stopped adding the insets itself, those headers spanned the full window: on iPhone Duo the Mentions, Drafts and Reminders compose button sat under the system controls, and the Photos & Videos, Pinned Messages and Files titles were centred on the full width. Move the wrapper to each screen's root so the header is inside it.
isekovanic
approved these changes
Sep 25, 2026
Remove ai-docs/iphone-duo-readiness.md and the AGENTS.md reference that pointed to it, so the PR carries only the code changes.
Contributor
|
🎉 This PR is included in version 9.9.2 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🎯 Goal
From iOS 27 every iPhone app is resizable — Split View, and the fold/unfold of iPhone Duo (ships 2026-10-23). This makes the SDK and SampleApp correct on those devices. No device detection is involved anywhere: Duo is treated as a resizable window, not a device to branch on.
Linear: RN-429
The two defects that matter:
topis zero. Measured on device:top: 0, bottom: 34, left: 0, right: 84, on both Duo displays. iOS 27 stacks the status bar, Dynamic Island, toolbars and tab bars down a single vertical edge — an 84pt reserved column. Nothing in the SDK or SampleApp readinsets.left/insets.right, so channel-row timestamps, the header compose button, unread badges and the scroll-to-bottom button rendered underneath the system indicators.inactivewas treated asbackground. In Split View a visible app sitsinactive.useAppStateListenerconflated the two, souseIsOnlinecalledclient.closeConnection()and showed the offline banner while the user was reading the chat (on by default viacloseConnectionOnBackground). Typing indicators stopped the same way, andKeyboardCompatibleViewhad its own copy of the bug that dropped the keyboard listeners.🛠 Implementation details
Horizontal insets: the integrator wraps, the SDK insets only what a wrapper cannot reach
An integrator wraps their screens in
<SafeAreaView edges={['left', 'right']}>.develophas no horizontal inset handling at all, so any integrator supporting landscape on a notched phone already does this — and adding SDK padding on top would silently double-inset exactly them on a minor upgrade. Leaving in-tree components alone is a no-op for everyone else. A wrapper also narrows the whole subtree, so absolutely positioned children (which ignore their container's padding) come along for free.The SDK insets itself only where a consumer's wrapper cannot reach:
Modals, which render in their own native window —BottomSheetModal(newuseHorizontalInsets) and the channel detailsModal.OverlayProviderat the app root. They useSafeAreaViewedgesrather than raw inset values, so a nested instance contributes nothing when an ancestor has already narrowed the subtree.SafeAreaViewWrappernow passes all four edges.useHorizontalInsetskeepsleftandrightseparate (they differ on iOS 27) and omits a side whose inset is 0, because apaddingLeft: 0longhand would override apaddingshorthand set by the component or a consumer theme.Grid and tile sizing
A wrapper can supply layout, but not a number. New
useWindowContentWidth(window width minus the horizontal insets) sizes the channel-details media grid and its skeleton, the attachment picker grid and tiles, and the image gallery grid. It degrades safely: an unwrapped app gets slightly small tiles, where plain window width would overflow for anyone who wraps. Grids stay at 3 columns.ImageGridremounts on a column-count change, sinceFlatListrejectsnumColumnschanges on a mounted list.App state
useAppStateListenerandKeyboardCompatibleViewnow treat only a realbackgroundtransition as backgrounding, each callback firing once per transition. This also stops the connection dropping during Control Center and the app switcher. The previous test codified the bug and was rewritten.Smaller fixes
StreamShimmerViewreadstraitCollection.displayScaleinstead of a once-cachedUIScreen.main.scale, and per-viewwindow.windowScene.activationStateinstead of the app-wideapplicationState.MessageItemViewreads the window, not the screen.screen≠windowin both directions: an 834pt iPad screen behind a 417pt window, and the Duo's 466pt outer screen behind its 951pt inner window.useScreenDimensionsis marked@deprecatedin favour ofuseViewport— same API, measured against the window. Behaviour unchanged; removal in the next major.SampleApp
Every screen wraps itself in
<SafeAreaView edges={['left', 'right']}>, with the header inside the wrapper. The hand-rolledinsets.left/rightpadding inScreenHeaderand on the channel-list search bar is gone.BottomTabskeeps its own horizontal padding, because it is the navigator's tab bar and renders outside every screen. The toast is sized as a percentage of its container instead of the window.Measured geometry, since the published figures are wrong
The inner display is 669 × 951pt, not the widely cited 626 × 890: 669 × 3 = 2007 and 951 × 3 = 2853, matching the framebuffer exactly.
🎨 UI Changes
On the Duo, nothing the app draws enters the 84pt reserved column any more, on either display. Previously the row timestamps, the header compose button, the unread badges and the scroll-to-bottom button (406–446pt) all sat inside it. Folded, content now ends at the 382pt boundary (466 − 84). Unfolded, it ends at 867pt (951 − 84) or before.
On phones without horizontal insets in portrait there is no visible change. In landscape, content now clears the Dynamic Island or camera cutout on whichever side it is.
🧪 Testing
yarn lintcd package && yarn test:typecheckyarn buildyarn test:unitOn device. The same 13-screen tour on every device: channel list, channel, attachment picker, channel details, Photos & Videos, gallery, gallery grid, Threads, Mentions, Drafts, Reminders, Pinned Messages and Files. Each screen was captured, and the inset regions were measured from pixels.
In every run the only thing inside an inset region is the gallery grid's dimmed backdrop, which is intended.
This device pass caught one regression from the inset simplification: six SampleApp screens (Mentions, Drafts, Reminders, Photos & Videos, Pinned Messages, Files) rendered their header outside the new wrapper. The Duo's compose button went under the system controls, and titles centred on the full window. It is fixed in
fix(SampleApp): wrap screen headers in the horizontal safe areaand re-verified on the Duo inner display, the Pro Max and both Android emulators. The Duo outer display has not been re-captured since the fix; it has the same root cause.Not verified: the Duo inner display in portrait (the simulator pins the unfolded scene to landscape); Android 3-button navigation, where the nav bar sits on a side in landscape; the toast, thread detail, message actions, polls and the keyboard; Expo.
Known trade-offs and follow-ups
developbehaves the same way.ChannelList+Channelside by side, which needs a product decision.headerShown: falseand a custom JS tab bar.{{count}} Files(useMessagePreviewText.tsx:105).☑️ Checklist
developbranch@deprecatedonuseScreenDimensions)ExpoMessagingdoes not launch on iOS 27 at all (see above)