feat(android): add unstable_Immediate UpdateMode for synchronous state updates - #56311
Closed
intergalacticspacehighway wants to merge 1 commit into
Closed
intergalacticspacehighway wants to merge 1 commit into
intergalacticspacehighway wants to merge 1 commit into
Conversation
|
Warning JavaScript API change detected This PR commits an update to
This change was flagged as: |
3 tasks
intergalacticspacehighway
added a commit
to expo/expo
that referenced
this pull request
May 14, 2026
# Why
Below code snippet can cause layout shift on android on first mount.
```jsx
import { Box } from '@expo/ui/jetpack-compose';
import { size } from '@expo/ui/jetpack-compose/modifiers';
import { Host } from '@expo/ui/swift-ui';
import { background } from '@expo/ui/swift-ui/modifiers';
import { View } from 'react-native';
export default function ImageSymbolEffectExample() {
return (
<View style={{ flex: 1 }}>
<Host matchContents>
<Box modifiers={[size(100, 100), background('blue')]}></Box>
</Host>
<View style={{ width: 100, height: 100, backgroundColor: 'red' }}></View>
</View>
);
}
```
### Before and after the fix (left is before)
https://github.com/user-attachments/assets/b1b93781-0517-414d-90d0-7df14332652e
iOS had the same issue, it was fixed here -
#40017
Issue is currently `Host` with `matchContents` uses updateState API to
update shadow node size, this can cause layout shift as the current
dispatch is async on android. On iOS, we're using experimental sync flag
provided by RN. On Android, the flag is not exposed. We made a PR to
enable it for android -
react/react-native#56311. Until it gets
merged or any alternate API is provided by RN, we need a workaround that
can enable sync state updates on Android.
<!--
Please describe the motivation for this PR, and link to relevant GitHub
issues, forums posts, or feature requests.
-->
# How
- Android has a solution to dispatch sync event from Kotlin (or cpp) to
JS (via event emitter) (this is used by VirtualView in RN to send sync
event from native to JS). So the approach in this PR relies on the
behavior that both state update queue and events are flushed in the same
tick when a sync event is dispatched. This allows us to dispatch a state
update (which is async) but then immediately trigger a sync event which
will flush our state update synchronously 😅.
- The API had been in the core for quite some time (2024 -
react/react-native#44489)
<!--
How did you build this feature or fix this bug and why?
-->
# Test Plan
- Tested the above repro with changes in this PR, layout shift gets
fixed.
- Tested all the examples in NCL and confirmed there are no regressions.
<!--
Please describe how you tested this change and how a reviewer could
reproduce your test, especially if this PR does not include automated
tests! If possible, please also provide terminal output and/or
screenshots demonstrating your test/reproduction.
-->
# Checklist
<!--
Please check the appropriate items below if they apply to your diff.
-->
- [ ] I added a `changelog.md` entry and rebuilt the package sources
according to [this short
guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin).
- [ ] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
Contributor
Author
|
cc - @sammy-SC can you take a look pls 🙏 |
Merged
3 tasks
|
@sammy-SC has imported this pull request. If you are a Meta employee, you can view this in D108023634. |
intergalacticspacehighway
added a commit
to expo/expo
that referenced
this pull request
Jun 10, 2026
…in RN Screens (#46604) # Why More robust approach than #45775. (Also tested with the original repro) `matchContents` `Host` view in compose shift by a frame when they mount inside react-native-screens native stack, because their size state update is applied asynchronously and lands after the first paint. <!-- Please describe the motivation for this PR, and link to relevant GitHub issues, forums posts, or feature requests. --> # How - `expo-modules-core`: Added a synchronous shadow-node state update path on Android, mirroring iOS, until we wait for this one to land react/react-native#56311 - `@expo/ui`: Removed the previous event-emitter workaround with `ShadowNodeSyncFlush.kt` since it is not working when we're in react native screens. This hack relied on event emitter on view being present, it is not present when RN screens does the layout [here](https://github.com/software-mansion/react-native-screens/blob/a6bfad893565efc1961dbc44307daad85a797615/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.kt#L42) <!-- How did you build this feature or fix this bug and why? --> # Test Plan Tested in the original repro shared by @sidorchukandrew [here](https://github.com/sidorchukandrew/expo-ui-repros). ### Before https://x.com/and_sidorchuk/status/2062212508862091757 ### After https://github.com/user-attachments/assets/6a8a2ee0-690a-4a1d-be81-8f3011c84bed <!-- Please describe how you tested this change and how a reviewer could reproduce your test, especially if this PR does not include automated tests! If possible, please also provide terminal output and/or screenshots demonstrating your test/reproduction. --> # Checklist <!-- Please check the appropriate items below if they apply to your diff. --> - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
Contributor
Author
|
cc - @sammy-SC @cipolleschi i created another PR here which includes tests. I just realised after the repo change i was not able to push changes to my fork while it was working for new forks (maybe i should've just waited more). |
intergalacticspacehighway
added a commit
to expo/expo
that referenced
this pull request
Oct 1, 2026
…performMeasureAndLayout called during measure layou (#49933) > [!WARNING] > **Agent-authored and NOT human-reviewed.** An automated `/verify --fix` run for #47625 wrote this change and checked it in a sandbox; the reasoning and evidence are in the outcome comment on that issue. Review it as you would any external contribution. Requested by @intergalacticspacehighway · [investigation run](https://github.com/expo/expo/actions/runs/34403733608) · refs #47625 Fixes #47625 `HostView` emits the `onLayoutContent` event synchronously from Compose's `onSizeChanged` callback, which runs inside the measure pass. A Fabric event listener that reacts synchronously — react-native-reanimated's `onEventDispatch` hook — flushes mount items on the same call stack and re-enters `onMeasure` of a view that is mid-measure. Compose throws `performMeasureAndLayout called during measure layout` and the ReactHost goes down (#47625). This change posts the dispatch to the main looper, so the event fires after the measure pass. `setStyleSize` timing does not change (`ShadowNodeProxy.scheduleFlush` already defers it), so `matchContents` sizing behaves as before. Proof: the unpatched code crashed with the exact reported stack in 4 of 4 emulator stress runs (each within ~2–5 minutes), and a build with this fix ran the same procedure for 784 cycles (~20 minutes) with no crash, with `onLayoutContent` still firing. <details><summary>Cause</summary> Full chain, all in this repository at dcd4c59: 1. `ExpoView.requestLayout` posts `measureAndLayout` ([ExpoView.kt#L49-L55](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-modules-core/android/src/main/java/expo/modules/kotlin/views/ExpoView.kt#L49-L55)). 2. The measure pass reaches the Compose layout in `MaybeMatchContentsLayout`, whose `Modifier.onSizeChanged` fires during measure ([HostView.kt#L158-L162](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-ui/android/src/main/java/expo/modules/ui/HostView.kt#L158-L162)) and calls `dispatchOnLayoutContent`, which emits the event synchronously ([HostView.kt#L233](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-ui/android/src/main/java/expo/modules/ui/HostView.kt#L233)). 3. The emit goes through `KEventEmitterWrapper.emit(view, ...)` to `FabricEventDispatcher.dispatchEvent` ([KModuleEventEmitterWrapper.kt#L103-L110](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-modules-core/android/src/main/java/expo/modules/kotlin/events/KModuleEventEmitterWrapper.kt#L103-L110)) — synchronous on the UI thread. 4. Reanimated's `NodesManager.onEventDispatch` runs `performOperationsRespectingDrawPass` on the same stack. That guard only skips the **draw** pass, not the measure pass, so it calls `performOperations` → `FabricUIManager.scheduleMountItem`, which executes queued mount items immediately on the UI thread. `SurfaceMountingManager.updateLayout` then measures a `HostView` that is still inside its own measure pass, and Compose throws. `shadowNodeProxy.setStyleSize` / `setViewSize` are not part of the hazard: `ShadowNodeProxy.scheduleFlush` already defers them to a pre-draw listener plus a posted fallback ([ShadowNodeProxy.kt#L45-L73](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-modules-core/android/src/main/java/expo/modules/kotlin/views/ShadowNodeProxy.kt#L45-L73)), and that deferral is present in every released 57.x (verified in the 57.0.2 npm tarball). The event emit at `HostView.kt#L233` is the one remaining synchronous Fabric interaction inside the measure pass. The synchronous Android dispatch shipped with [#45776](#45776) (expo-ui 56.0.8) and is carried by every released 57.x including 57.0.17. </details> <details><summary>Verification</summary> Stress recipe on a hosted Android emulator (new architecture): two screens cycled with `router.replace`; the hosts screen mounts 12 `<Host matchContents>` with `Text` content changing every 35 ms plus a `<Host matchContents={{vertical:true}}>` with `TextField`; 16 reanimated views animate width/height continuously with `ReduceMotion.Never` (the emulator's reduce-motion setting otherwise disables reanimated and suppresses the repro); a busy-JS interval bunches React commits. App: expo 57.0.13, react-native 0.86.2, reanimated 4.5.1, expo-router 57.0.13. | Arm | Binary | @expo/ui native code | Result | | --- | --- | --- | --- | | Before (4 runs) | Expo Go, SDK 57 | bundled release, synchronous dispatch | 4 of 4 runs crashed within ~2–5 min, exact reported stack | | After (1 long run) | dev client, EAS build 3ff744d7 | 57.0.17 + this fix, compiled from source | no crash through 784 cycles (~20 min); `onLayoutContent` readout still updates | One caveat, stated plainly: the before-arm binary is Expo Go and the after-arm binary is a dev client, because the four EAS builds of an unpatched dev client failed in Gradle before producing an artifact (undiagnosed; this run's credential cannot read EAS build logs). The crash mechanism lives in @expo/ui + React Native + reanimated code that both binaries contain, and the same JS bundle and procedure drove both arms. The crash-stack screenshots (attached to the linked findings comment) show the full reported chain frame by frame, from `dispatchOnLayoutContent-viCIZxY` through `onEventDispatch` and `updateLayout` to the exception. </details> <details><summary>Checks run</summary> In a sandbox checkout of this repository at dcd4c59 with the fix applied, in `packages/expo-ui`: `pnpm run typecheck` (clean), `pnpm run lint` (0 warnings, 0 errors), `pnpm test` (58 suites, 357 tests, 17 snapshots — all pass). Kotlin compile: `./gradlew :expo-ui:compileDebugKotlin` with `expo.autolinking.android.buildFromSource: ["expo-ui"]` succeeded on a GitHub Actions ubuntu VM, with `expo-ui` listed without the `[📦]` prebuilt marker (really compiled from source), and the patched file compiled in the incremental rebuild. The EAS build 3ff744d7 that ran on-device compiled the patched source the same way. </details> <details><summary>Not covered</summary> - The `onSizeChanged` modifier in [ModifierRegistry.kt#L695-L708](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-ui/android/src/main/java/expo/modules/ui/ModifierRegistry.kt#L695-L708) dispatches the same way during measure. This change does not touch it; a comment on #47625 reports a rarer residual crash after patching only `HostView`, which could come from another synchronous dispatch site, but no stack trace exists for it yet. - iOS is untouched; the SwiftUI host does not share this code path. - The deeper re-entrancy guard belongs to React Native ([react/react-native#56311](react/react-native#56311), referenced from `ShadowNodeProxy`); this change removes expo-ui's own trigger and does not wait for it. </details> <!-- expo-bot:fix-options v1 --> <details><summary>Options considered</summary> 1. **Post the `onLayoutContent` dispatch (`post { ... }`) so it runs after the measure pass.** Chosen: one line in `HostView.kt`; `setStyleSize` timing is untouched, so `matchContents` sizing is unchanged; the JS callback arrives one main-loop turn later, which its documented contract ("triggered when the content completes its layout") already allows. Matches the deferral pattern `ShadowNodeProxy.scheduleFlush` already uses for state updates. 2. **Defer every view event inside expo-modules-core's `ViewEvent`/`KEventEmitterWrapper`.** Rejected: changes event timing for every Expo module view on Android, far beyond the reported bug; that is a design decision for the maintainers. 3. **Route the event through `ShadowNodeProxy.scheduleFlush`'s pre-draw mechanism.** Rejected: `scheduleFlush` holds a single pending closure per view, so reusing it would make a size event and a style-size update overwrite each other; a separate pre-draw listener duplicates machinery that `post` already provides. 4. **Do nothing and wait for React Native's re-entrancy guard ([react/react-native#56311](react/react-native#56311 Rejected: the crash tears down production apps today, the RN PR is unmerged, and dispatching JS events mid-measure stays a hazard for any synchronous listener even with that guard. </details> <!-- /expo-bot:fix-options --> --------- Co-authored-by: expo-bot <expo-bot@users.noreply.github.com> Co-authored-by: nishan (o^▽^o) <nishanbende@gmail.com>
intergalacticspacehighway
added a commit
to expo/expo
that referenced
this pull request
Oct 1, 2026
…performMeasureAndLayout called during measure layou (#49933) > [!WARNING] > **Agent-authored and NOT human-reviewed.** An automated `/verify --fix` run for #47625 wrote this change and checked it in a sandbox; the reasoning and evidence are in the outcome comment on that issue. Review it as you would any external contribution. Requested by @intergalacticspacehighway · [investigation run](https://github.com/expo/expo/actions/runs/34403733608) · refs #47625 Fixes #47625 `HostView` emits the `onLayoutContent` event synchronously from Compose's `onSizeChanged` callback, which runs inside the measure pass. A Fabric event listener that reacts synchronously — react-native-reanimated's `onEventDispatch` hook — flushes mount items on the same call stack and re-enters `onMeasure` of a view that is mid-measure. Compose throws `performMeasureAndLayout called during measure layout` and the ReactHost goes down (#47625). This change posts the dispatch to the main looper, so the event fires after the measure pass. `setStyleSize` timing does not change (`ShadowNodeProxy.scheduleFlush` already defers it), so `matchContents` sizing behaves as before. Proof: the unpatched code crashed with the exact reported stack in 4 of 4 emulator stress runs (each within ~2–5 minutes), and a build with this fix ran the same procedure for 784 cycles (~20 minutes) with no crash, with `onLayoutContent` still firing. <details><summary>Cause</summary> Full chain, all in this repository at dcd4c59: 1. `ExpoView.requestLayout` posts `measureAndLayout` ([ExpoView.kt#L49-L55](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-modules-core/android/src/main/java/expo/modules/kotlin/views/ExpoView.kt#L49-L55)). 2. The measure pass reaches the Compose layout in `MaybeMatchContentsLayout`, whose `Modifier.onSizeChanged` fires during measure ([HostView.kt#L158-L162](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-ui/android/src/main/java/expo/modules/ui/HostView.kt#L158-L162)) and calls `dispatchOnLayoutContent`, which emits the event synchronously ([HostView.kt#L233](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-ui/android/src/main/java/expo/modules/ui/HostView.kt#L233)). 3. The emit goes through `KEventEmitterWrapper.emit(view, ...)` to `FabricEventDispatcher.dispatchEvent` ([KModuleEventEmitterWrapper.kt#L103-L110](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-modules-core/android/src/main/java/expo/modules/kotlin/events/KModuleEventEmitterWrapper.kt#L103-L110)) — synchronous on the UI thread. 4. Reanimated's `NodesManager.onEventDispatch` runs `performOperationsRespectingDrawPass` on the same stack. That guard only skips the **draw** pass, not the measure pass, so it calls `performOperations` → `FabricUIManager.scheduleMountItem`, which executes queued mount items immediately on the UI thread. `SurfaceMountingManager.updateLayout` then measures a `HostView` that is still inside its own measure pass, and Compose throws. `shadowNodeProxy.setStyleSize` / `setViewSize` are not part of the hazard: `ShadowNodeProxy.scheduleFlush` already defers them to a pre-draw listener plus a posted fallback ([ShadowNodeProxy.kt#L45-L73](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-modules-core/android/src/main/java/expo/modules/kotlin/views/ShadowNodeProxy.kt#L45-L73)), and that deferral is present in every released 57.x (verified in the 57.0.2 npm tarball). The event emit at `HostView.kt#L233` is the one remaining synchronous Fabric interaction inside the measure pass. The synchronous Android dispatch shipped with [#45776](#45776) (expo-ui 56.0.8) and is carried by every released 57.x including 57.0.17. </details> <details><summary>Verification</summary> Stress recipe on a hosted Android emulator (new architecture): two screens cycled with `router.replace`; the hosts screen mounts 12 `<Host matchContents>` with `Text` content changing every 35 ms plus a `<Host matchContents={{vertical:true}}>` with `TextField`; 16 reanimated views animate width/height continuously with `ReduceMotion.Never` (the emulator's reduce-motion setting otherwise disables reanimated and suppresses the repro); a busy-JS interval bunches React commits. App: expo 57.0.13, react-native 0.86.2, reanimated 4.5.1, expo-router 57.0.13. | Arm | Binary | @expo/ui native code | Result | | --- | --- | --- | --- | | Before (4 runs) | Expo Go, SDK 57 | bundled release, synchronous dispatch | 4 of 4 runs crashed within ~2–5 min, exact reported stack | | After (1 long run) | dev client, EAS build 3ff744d7 | 57.0.17 + this fix, compiled from source | no crash through 784 cycles (~20 min); `onLayoutContent` readout still updates | One caveat, stated plainly: the before-arm binary is Expo Go and the after-arm binary is a dev client, because the four EAS builds of an unpatched dev client failed in Gradle before producing an artifact (undiagnosed; this run's credential cannot read EAS build logs). The crash mechanism lives in @expo/ui + React Native + reanimated code that both binaries contain, and the same JS bundle and procedure drove both arms. The crash-stack screenshots (attached to the linked findings comment) show the full reported chain frame by frame, from `dispatchOnLayoutContent-viCIZxY` through `onEventDispatch` and `updateLayout` to the exception. </details> <details><summary>Checks run</summary> In a sandbox checkout of this repository at dcd4c59 with the fix applied, in `packages/expo-ui`: `pnpm run typecheck` (clean), `pnpm run lint` (0 warnings, 0 errors), `pnpm test` (58 suites, 357 tests, 17 snapshots — all pass). Kotlin compile: `./gradlew :expo-ui:compileDebugKotlin` with `expo.autolinking.android.buildFromSource: ["expo-ui"]` succeeded on a GitHub Actions ubuntu VM, with `expo-ui` listed without the `[📦]` prebuilt marker (really compiled from source), and the patched file compiled in the incremental rebuild. The EAS build 3ff744d7 that ran on-device compiled the patched source the same way. </details> <details><summary>Not covered</summary> - The `onSizeChanged` modifier in [ModifierRegistry.kt#L695-L708](https://github.com/expo/expo/blob/dcd4c59b5a78da5cbc43783dee5601e071c764af/packages/expo-ui/android/src/main/java/expo/modules/ui/ModifierRegistry.kt#L695-L708) dispatches the same way during measure. This change does not touch it; a comment on #47625 reports a rarer residual crash after patching only `HostView`, which could come from another synchronous dispatch site, but no stack trace exists for it yet. - iOS is untouched; the SwiftUI host does not share this code path. - The deeper re-entrancy guard belongs to React Native ([react/react-native#56311](react/react-native#56311), referenced from `ShadowNodeProxy`); this change removes expo-ui's own trigger and does not wait for it. </details> <!-- expo-bot:fix-options v1 --> <details><summary>Options considered</summary> 1. **Post the `onLayoutContent` dispatch (`post { ... }`) so it runs after the measure pass.** Chosen: one line in `HostView.kt`; `setStyleSize` timing is untouched, so `matchContents` sizing is unchanged; the JS callback arrives one main-loop turn later, which its documented contract ("triggered when the content completes its layout") already allows. Matches the deferral pattern `ShadowNodeProxy.scheduleFlush` already uses for state updates. 2. **Defer every view event inside expo-modules-core's `ViewEvent`/`KEventEmitterWrapper`.** Rejected: changes event timing for every Expo module view on Android, far beyond the reported bug; that is a design decision for the maintainers. 3. **Route the event through `ShadowNodeProxy.scheduleFlush`'s pre-draw mechanism.** Rejected: `scheduleFlush` holds a single pending closure per view, so reusing it would make a size event and a style-size update overwrite each other; a separate pre-draw listener duplicates machinery that `post` already provides. 4. **Do nothing and wait for React Native's re-entrancy guard ([react/react-native#56311](react/react-native#56311 Rejected: the crash tears down production apps today, the RN PR is unmerged, and dispatching JS events mid-measure stays a hazard for any synchronous listener even with that guard. </details> <!-- /expo-bot:fix-options --> --------- Co-authored-by: expo-bot <expo-bot@users.noreply.github.com> Co-authored-by: nishan (o^▽^o) <nishanbende@gmail.com> (cherry picked from commit b70dc5e)
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.
Summary:
Currently we support unstable_Immediate mode on
updateStatefor iOS. This allows us to dispatch synchronous shadow node size updates in Expo UI SwiftUI (communicating SwiftUI children size to Yoga. Host for more details).Since it is not enabled on android, it leads to layout shift when mixing Compose and React Native components as Compose tree currently dispatches size information asynchronously to the shadow node.
Here, the video demonstrates the asynchronous vs synchronous
updateStateusage (before and after applying the changes in this PR). Repro linkScreen.Recording.2026-04-02.at.8.52.27.PM.mov
More:
After enabling the
update_immediate, I noticed the flicker was still happening. The issue is that update layout instruction triggers a follow up instruction when we set the shadow node size from Expo UI which gets skipped to the next frame. This is handled on iOS using_followUpTransactionRequiredflag here. This seems to be added on iOS for similar reasons here when dispatching an instruction can lead to a follow-up instruction when synchronous update is triggered. So we also handle the sync follow up transaction in this PR using similar approach to iOS's.Changelog:
[ANDROID] [ADDED] - Add
unstable_ImmediateUpdateMode support inupdateState.Test Plan:
Tested the changes in the repro. Also tested RN Tester, it should be a non breaking change.