Skip to content

fix: iPhone Duo / iOS 27 resizable-window readiness - #3818

Merged
oliverlaz merged 21 commits into
developfrom
chore/duo-readiness-audit
Sep 25, 2026
Merged

oliverlaz merged 21 commits into
developfrom
chore/duo-readiness-audit

Conversation

@oliverlaz

@oliverlaz oliverlaz commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

🎯 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:

  1. Safe-area insets are asymmetric, and top is 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 read insets.left / insets.right, so channel-row timestamps, the header compose button, unread badges and the scroll-to-bottom button rendered underneath the system indicators.
  2. inactive was treated as background. In Split View a visible app sits inactive. useAppStateListener conflated the two, so useIsOnline called client.closeConnection() and showed the offline banner while the user was reading the chat (on by default via closeConnectionOnBackground). Typing indicators stopped the same way, and KeyboardCompatibleView had 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']}>. develop has 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:

  • React Native Modals, which render in their own native window — BottomSheetModal (new useHorizontalInsets) and the channel details Modal.
  • Image gallery header and footer, hosted by OverlayProvider at the app root. They use SafeAreaView edges rather than raw inset values, so a nested instance contributes nothing when an ancestor has already narrowed the subtree.
  • SafeAreaViewWrapper now passes all four edges.

useHorizontalInsets keeps left and right separate (they differ on iOS 27) and omits a side whose inset is 0, because a paddingLeft: 0 longhand would override a padding shorthand 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. ImageGrid remounts on a column-count change, since FlatList rejects numColumns changes on a mounted list.

App state

useAppStateListener and KeyboardCompatibleView now treat only a real background transition 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

  • StreamShimmerView reads traitCollection.displayScale instead of a once-cached UIScreen.main.scale, and per-view window.windowScene.activationState instead of the app-wide applicationState.
  • MessageItemView reads the window, not the screen. screen ≠ window in both directions: an 834pt iPad screen behind a 417pt window, and the Duo's 466pt outer screen behind its 951pt inner window.
  • useScreenDimensions is marked @deprecated in favour of useViewport — 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-rolled insets.left/right padding in ScreenHeader and on the channel-list search bar is gone. BottomTabs keeps 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

Device / mode window (pt) screen (pt) insets t/b/l/r
Duo, outer display, folded 466 × 678 @3x 466 × 678 @3x 0 / 34 / 0 / 84
Duo, inner display, unfolded, landscape 951 × 669 @3x 466 × 678 @3x 0 / 34 / 0 / 84
iPad Pro 11", iPhone-only app in a compatibility window 417 × 1210 @2x 834 × 1210 @2x 32 / 20 / 0 / 0

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

Gate Result
yarn lint exit 0, zero warnings
cd package && yarn test:typecheck exit 0
yarn build succeeds (the strict type gate)
yarn test:unit 185 suites passed (2 skipped), 1506 tests passed (14 skipped), 12 snapshots

On 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.

Device Orientations Result
iPhone Duo (iOS 27.1), outer display portrait reserved column clear on every screen except the six headers below (captured before their fix)
iPhone Duo, inner display (unfolded) landscape reserved column clear on every screen; live unfold re-lays out without a relaunch
iPhone 18 Pro Max (iOS 27.0) portrait + landscape 62pt side strips clear; portrait pixel-identical to the previous verified head, apart from content
Pixel 10 emulator (Android 17, gesture nav) portrait + both landscape rotations 54dp cutout strip clear on whichever side it is; no double padding on the other side
Pixel 10 Pro Fold emulator (Android 17) unfolded + folded, 3 orientations each cover-screen cutout strip clear; media grid and attachment picker re-lay out on fold and are pixel-identical after unfolding again

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 area and 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

  • The media and picker grids are 3 columns. On the Duo's inner display and on the Pixel Fold unfolded, the middle column crosses the fold. Apple's Duo guidance asks for an even column count, but develop has 3 columns too, so this is not a regression.
  • Rotating the media grid keeps the pixel offset, not the row. Landscape's larger tiles land you several rows earlier. develop behaves the same way.
  • ExpoMessaging does not launch on iOS 27. Expo SDK 57's prebuild template predates the scene lifecycle. This needs SDK 58 or a config plugin, and is out of scope here.
  • Two-pane layout on the Duo's inner display. Apple's HIG presents this as the main adaptation for that display. For us it means ChannelList + Channel side by side, which needs a product decision.
  • Native chrome in SampleApp. Verified empirically: iOS relocates real native bars into the reserved column. SampleApp forgoes that by using headerShown: false and a custom JS tab bar.
  • Unrelated, spotted in passing: the Drafts list renders a literal {{count}} Files (useMessagePreviewText.tsx:105).

☑️ Checklist

  • I have signed the Stream CLA (required)
  • PR targets the develop branch
  • Documentation is updated (JSDoc on the new hooks, @deprecated on useScreenDimensions)
  • New code is tested in main example apps, including all possible scenarios
    • SampleApp iOS — iPhone Duo (iOS 27.1, both displays) and iPhone 18 Pro Max (iOS 27.0), portrait and landscape
    • SampleApp Android — Pixel 10 and Pixel 10 Pro Fold emulators (Android 17), portrait and both landscape directions, folded and unfolded
    • Expo iOS and Android — untouched; ExpoMessaging does not launch on iOS 27 at all (see above)

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
@Stream-SDK-Bot

Stream-SDK-Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

SDK Size

title develop branch diff status
js_bundle_size 2031 KB 2032 KB +1706 B 🔴

oliverlaz and others added 14 commits September 22, 2026 17:39
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.
Remove ai-docs/iphone-duo-readiness.md and the AGENTS.md reference that
pointed to it, so the PR carries only the code changes.
@oliverlaz
oliverlaz merged commit c51967a into develop Sep 25, 2026
8 of 9 checks passed
@oliverlaz
oliverlaz deleted the chore/duo-readiness-audit branch September 25, 2026 12:50
@github-actions github-actions Bot mentioned this pull request Sep 25, 2026
6 tasks
@stream-ci-bot

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 9.9.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants