Skip to content

Pull model: mount the commits of starting and stopping surfaces (#59030) - #59030

Open
bartlomiejbloniarz wants to merge 2 commits into
mainfrom
bartlomiejbloniarz/pull-model-surface-stop
Open

bartlomiejbloniarz wants to merge 2 commits into
mainfrom
bartlomiejbloniarz/pull-model-surface-stop

Conversation

@bartlomiejbloniarz

@bartlomiejbloniarz bartlomiejbloniarz commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary:
With enableMountingCoordinatorPullModelAndroid, a commit tells Java only its surface id. At the next mount dispatch, FabricUIManagerBinding::pullAndExecuteTransaction looks the surface's MountingCoordinator up in the binding's surface registry. A surface is not in the registry while it starts or stops:

  • The transaction that stops a surface is never pulled, because stopSurfaceWithSurfaceHandler removes the surface from the registry before SurfaceHandler::stop() commits the empty tree. Mounting override delegates such as LayoutAnimations and C++ Animated never see those Deletes. With enableLayoutAnimationsOnAndroid, a layout animation that runs when its surface stops stays in flight, and FabricUIManager calls driveCxxAnimations on every frame until another surface pulls a transaction.
  • A commit that a bridgeless surface makes before startSurfaceWithSurfaceHandler registers it is not mounted until the surface commits again.
  • The legacy stopSurface(int) holds the registry lock across stop(). On the UI thread, the stop commit pulls inline and blocks on the same lock, so the app freezes.

iOS does not look the surface up. RCTMountingManager captures the MountingCoordinator that reported the commit in the block it dispatches to the main queue, and pulls from it.

This PR does the same. schedulerShouldRenderTransactions keeps the reporting MountingCoordinator until the UI thread pulls, and pullAndExecuteTransaction pulls from it. Java still receives only the surface id. The surface registry, the start and stop functions, reportMount and the push model are unchanged.

As on iOS, a surface stopped on the UI thread has its stop transaction pulled during the stop commit. When it stops on another thread, its shadow tree is destroyed and its coordinator revoked before the UI thread pulls, so that transaction is not mounted. A pull of a stopped surface can log "Executing commit after surface N was stopped!" when a mounting override delegate still returns a transaction; iOS pulls these the same way.

Only one coordinator per surface id is kept. Stopping and starting the same ReactSurface on the same host reuses its id. If the stopped surface's commit has not been pulled yet, the restarted surface's coordinator replaces it and a warning is logged. The replaced coordinator is revoked and would pull nothing. Java does not mount such a restarted surface anyway: MountingManager.startSurface logs "Called startSurface more than once" and keeps the stopped surface's manager.

Changelog:

[ANDROID] [FIXED] - Pull model: mount the commits of starting and stopping surfaces, and fix a freeze when the legacy stopSurface(int) runs on the UI thread

Test Plan:
New FabricMountingManagerInstrumentationTest cases drive a real FabricUIManagerBinding. The test helper runs no JavaScript and commits views to the surface's shadow tree itself. The pull-model cases run with the Props 2.0 flags and enablePreallocatedPropsDiffOnInsertAndroid.

Test Without the fix With the fix
native_stopSurfaceWithSurfaceHandler_pullModel_pullsDeletes fails: no Deletes are pulled passes
native_stopSurface_pullModel_pullsDeletes hangs passes
native_stopSurfaceWithSurfaceHandler_pushModel_pullsDeletes passes passes

The push case is a control. The hang stops the run, so the bridgeless stop test was also run without the legacy one. With the fix, the whole class passes.

A one-off check, not committed, covered the start: a bridgeless surface started on a background thread commits a view, and the start is held before startSurfaceWithSurfaceHandler registers the surface. On #59029 alone the view is not mounted while the start is held. With this PR it is mounted by the next frame.

The test class has no Gradle target in the open-source repository, so I ran it on an Android emulator through a local androidTest source set:

./gradlew :packages:react-native:ReactAndroid:assembleReleaseAndroidTest -PreactNativeArchitectures=arm64-v8a
adb install -r -t packages/react-native/ReactAndroid/build/outputs/apk/androidTest/release/ReactAndroid-release-androidTest.apk
adb shell am instrument -w -e class com.facebook.react.fabric.FabricMountingManagerInstrumentationTest \
  com.facebook.react.tests.gradle/androidx.test.runner.AndroidJUnitRunner

I also checked the effect in RNTester with a temporary hook, not part of this PR. The Playground starts a 60 s LayoutAnimation, then ReactDelegate.unloadApp() stops the surface on the UI thread while the activity stays resumed. Props 2.0 and enableLayoutAnimationsOnAndroid were on:

Run After the stop
Pull, without this PR no onAllAnimationsComplete; driveCxxAnimations keeps running on every frame
Pull, with this PR onAllAnimationsComplete right after the stop, then no more driven frames
Push, with this PR onAllAnimationsComplete right after the stop, as before

Pulled By:
bartlomiejbloniarz

bartlomiejbloniarz

Differential Revision: D124966432

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 13, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner p: Facebook Partner: Facebook labels Oct 13, 2026
@meta-codesync

meta-codesync Bot commented Oct 13, 2026

Copy link
Copy Markdown

@bartlomiejbloniarz has imported this pull request. If you are a Meta employee, you can view this in D124966432.

@meta-codesync meta-codesync Bot changed the title [Android] Pull the transaction that stops a surface in the pull model Pull the transaction that stops a surface in the pull model (#59030) Oct 13, 2026
meta-codesync Bot pushed a commit that referenced this pull request Oct 13, 2026
Summary:
With `enableMountingCoordinatorPullModelAndroid`, the UI thread pulls a surface's transaction through `FabricUIManagerBinding`'s surface registry. `stopSurfaceWithSurfaceHandler` removes the surface from the registry before `SurfaceHandler::stop()` commits the empty tree, so the transaction that deletes the surface's views is never pulled. Mounting override delegates such as LayoutAnimations and C++ Animated never see those Deletes. With `enableLayoutAnimationsOnAndroid`, a layout animation that is running when its surface stops stays in flight. `FabricUIManager` then calls `driveCxxAnimations` on every frame until another surface pulls a transaction.

This PR:

- Pulls and executes a commit on the commit thread in `schedulerShouldRenderTransactions` when its surface is not registered, as push does.
- Makes `stopSurface(int)` unregister the surface before stopping it, like `stopSurfaceWithSurfaceHandler`.
- Registers a bridgeless surface before `SurfaceHandler::start()`, like `startSurface` and `startSurfaceWithConstraints`. Otherwise a commit made during the start would also take the commit-thread path and mount after the next pull. Only a stopping surface now commits while unregistered.
- Skips a registered surface that is not running in `pullAndExecuteTransaction` and `reportMount`. A restarted surface keeps its id and is now registered before it starts, so a pull left over from its previous run could reach it before it has a shadow tree.

The legacy `setConstraints` commits while it holds the registry's shared lock, so the new registry check nests a shared lock there. `pullAndExecuteTransaction` already does the same on main.

## Changelog:

[ANDROID] [FIXED] - Pull the transaction that stops a surface with `enableMountingCoordinatorPullModelAndroid`


Test Plan:
New `FabricMountingManagerInstrumentationTest` cases drive a real `FabricUIManagerBinding`. The test helper commits to the surface's shadow tree directly, without JavaScript. The pull-model cases run with the Props 2.0 flags and `enablePreallocatedPropsDiffOnInsertAndroid`.

| Test | Without the fix | With the fix |
| --- | --- | --- |
| `native_stopSurfaceWithSurfaceHandler_pullModel_pullsDeletes` | fails: no Deletes are pulled | passes |
| `native_stopSurfaceWithSurfaceHandler_pushModel_pullsDeletes` | passes | passes |
| `native_startSurfaceWithSurfaceHandler_pullModel_mountsCommitDuringStartInOrder` | passes | passes |
| `native_registeredSurfaceThatIsNotRunning_pullModel_skipsPullAndMountReport` | crashes in `SurfaceHandler::getMountingCoordinator()` | passes |

The start test passes on main, where nothing takes the commit-thread path. It guards the registration order: with the fallback in place but the surface registered after `start()`, it fails with the views mounted as `[42, 43]` instead of `[43, 42]`.

The test class has no Gradle target in the open-source repository, so I ran it on an Android emulator through a local androidTest source set:

```
./gradlew :packages:react-native:ReactAndroid:assembleReleaseAndroidTest -PreactNativeArchitectures=arm64-v8a
adb install -r -t packages/react-native/ReactAndroid/build/outputs/apk/androidTest/release/ReactAndroid-release-androidTest.apk
adb shell am instrument -w -e class com.facebook.react.fabric.FabricMountingManagerInstrumentationTest \
  com.facebook.react.tests.gradle/androidx.test.runner.AndroidJUnitRunner
```

With the fix, the whole class passes.

I also used RNTester with a temporary hook: the Playground starts a 60 s `LayoutAnimation`, then `ReactDelegate.unloadApp()` stops the surface while the activity stays resumed. Props 2.0 and `enableLayoutAnimationsOnAndroid` were on:

| | Before | After |
| --- | --- | --- |
| Pull | no `onAllAnimationsComplete`; `driveCxxAnimations` still runs every frame 100 s after the stop | `onAllAnimationsComplete` right after the stop, then no more frames |
| Push | `onAllAnimationsComplete` right after the stop | same |

Differential Revision: D124966432

Pulled By: bartlomiejbloniarz
@meta-codesync
meta-codesync Bot force-pushed the bartlomiejbloniarz/pull-model-surface-stop branch from 0ede810 to fb19d4e Compare October 13, 2026 09:21
@meta-codesync

meta-codesync Bot commented Oct 13, 2026

Copy link
Copy Markdown

@bartlomiejbloniarz has exported this pull request. If you are a Meta employee, you can view the originating Diff in D124966432.

meta-codesync Bot pushed a commit that referenced this pull request Oct 13, 2026
Summary:
With `enableMountingCoordinatorPullModelAndroid`, the UI thread pulls a surface's transaction through `FabricUIManagerBinding`'s surface registry. `stopSurfaceWithSurfaceHandler` removes the surface from the registry before `SurfaceHandler::stop()` commits the empty tree, so the transaction that deletes the surface's views is never pulled. Mounting override delegates such as LayoutAnimations and C++ Animated never see those Deletes. With `enableLayoutAnimationsOnAndroid`, a layout animation that is running when its surface stops stays in flight. `FabricUIManager` then calls `driveCxxAnimations` on every frame until another surface pulls a transaction.

This PR:

- Pulls and executes a commit on the commit thread in `schedulerShouldRenderTransactions` when its surface is not registered, as push does.
- Makes `stopSurface(int)` unregister the surface before stopping it, like `stopSurfaceWithSurfaceHandler`.
- Registers a bridgeless surface before `SurfaceHandler::start()`, like `startSurface` and `startSurfaceWithConstraints`. Otherwise a commit made during the start would also take the commit-thread path and mount after the next pull. Only a stopping surface now commits while unregistered.
- Skips a registered surface that is not running in `pullAndExecuteTransaction` and `reportMount`. A restarted surface keeps its id and is now registered before it starts, so a pull left over from its previous run could reach it before it has a shadow tree.

The legacy `setConstraints` commits while it holds the registry's shared lock, so the new registry check nests a shared lock there. `pullAndExecuteTransaction` already does the same on main.

## Changelog:

[ANDROID] [FIXED] - Pull the transaction that stops a surface with `enableMountingCoordinatorPullModelAndroid`


Test Plan:
New `FabricMountingManagerInstrumentationTest` cases drive a real `FabricUIManagerBinding`. The test helper commits to the surface's shadow tree directly, without JavaScript. The pull-model cases run with the Props 2.0 flags and `enablePreallocatedPropsDiffOnInsertAndroid`.

| Test | Without the fix | With the fix |
| --- | --- | --- |
| `native_stopSurfaceWithSurfaceHandler_pullModel_pullsDeletes` | fails: no Deletes are pulled | passes |
| `native_stopSurfaceWithSurfaceHandler_pushModel_pullsDeletes` | passes | passes |
| `native_startSurfaceWithSurfaceHandler_pullModel_mountsCommitDuringStartInOrder` | passes | passes |
| `native_registeredSurfaceThatIsNotRunning_pullModel_skipsPullAndMountReport` | crashes in `SurfaceHandler::getMountingCoordinator()` | passes |

The start test passes on main, where nothing takes the commit-thread path. It guards the registration order: with the fallback in place but the surface registered after `start()`, it fails with the views mounted as `[42, 43]` instead of `[43, 42]`.

The test class has no Gradle target in the open-source repository, so I ran it on an Android emulator through a local androidTest source set:

```
./gradlew :packages:react-native:ReactAndroid:assembleReleaseAndroidTest -PreactNativeArchitectures=arm64-v8a
adb install -r -t packages/react-native/ReactAndroid/build/outputs/apk/androidTest/release/ReactAndroid-release-androidTest.apk
adb shell am instrument -w -e class com.facebook.react.fabric.FabricMountingManagerInstrumentationTest \
  com.facebook.react.tests.gradle/androidx.test.runner.AndroidJUnitRunner
```

With the fix, the whole class passes.

I also used RNTester with a temporary hook: the Playground starts a 60 s `LayoutAnimation`, then `ReactDelegate.unloadApp()` stops the surface while the activity stays resumed. Props 2.0 and `enableLayoutAnimationsOnAndroid` were on:

| | Before | After |
| --- | --- | --- |
| Pull | no `onAllAnimationsComplete`; `driveCxxAnimations` still runs every frame 100 s after the stop | `onAllAnimationsComplete` right after the stop, then no more frames |
| Push | `onAllAnimationsComplete` right after the stop | same |

Differential Revision: D124966432

Pulled By: bartlomiejbloniarz
@meta-codesync
meta-codesync Bot force-pushed the bartlomiejbloniarz/pull-model-surface-stop branch from fb19d4e to 2ec93d1 Compare October 13, 2026 10:11
@bartlomiejbloniarz
bartlomiejbloniarz force-pushed the bartlomiejbloniarz/pull-model-surface-stop branch from 2ec93d1 to 57c7f89 Compare October 13, 2026 14:00
@bartlomiejbloniarz bartlomiejbloniarz changed the title Pull the transaction that stops a surface in the pull model (#59030) [Android] Pull model: mount the commits of starting and stopping surfaces Oct 13, 2026
@meta-codesync meta-codesync Bot changed the title [Android] Pull model: mount the commits of starting and stopping surfaces Pull model: mount the commits of starting and stopping surfaces (#59030) Oct 13, 2026
meta-codesync Bot pushed a commit that referenced this pull request Oct 13, 2026
Summary:
With `enableMountingCoordinatorPullModelAndroid`, a commit tells Java only its surface id. At the next mount dispatch, `FabricUIManagerBinding::pullAndExecuteTransaction` looks the surface's `MountingCoordinator` up in the binding's surface registry. A surface is not in the registry while it starts or stops:

- The transaction that stops a surface is never pulled, because `stopSurfaceWithSurfaceHandler` removes the surface from the registry before `SurfaceHandler::stop()` commits the empty tree. Mounting override delegates such as LayoutAnimations and C++ Animated never see those Deletes. With `enableLayoutAnimationsOnAndroid`, a layout animation that runs when its surface stops stays in flight, and `FabricUIManager` calls `driveCxxAnimations` on every frame until another surface pulls a transaction.
- A commit that a bridgeless surface makes before `startSurfaceWithSurfaceHandler` registers it is not mounted until the surface commits again.
- The legacy `stopSurface(int)` holds the registry lock across `stop()`. On the UI thread, the stop commit pulls inline and blocks on the same lock, so the app freezes.

iOS does not look the surface up. `RCTMountingManager` captures the `MountingCoordinator` that reported the commit in the block it dispatches to the main queue, and pulls from it.

This PR does the same. `schedulerShouldRenderTransactions` keeps the reporting `MountingCoordinator` until the UI thread pulls, and `pullAndExecuteTransaction` pulls from it. Java still receives only the surface id. The surface registry, the start and stop functions, `reportMount` and the push model are unchanged.

As on iOS, a surface stopped on the UI thread has its stop transaction pulled during the stop commit. When it stops on another thread, its shadow tree is destroyed and its coordinator revoked before the UI thread pulls, so that transaction is not mounted. A pull of a stopped surface can log "Executing commit after surface N was stopped!" when a mounting override delegate still returns a transaction; iOS pulls these the same way.

Only one coordinator per surface id is kept. Stopping and starting the same `ReactSurface` on the same host reuses its id. If the stopped surface's commit has not been pulled yet, the restarted surface's coordinator replaces it and a warning is logged. The replaced coordinator is revoked and would pull nothing. Java does not mount such a restarted surface anyway: `MountingManager.startSurface` logs "Called startSurface more than once" and keeps the stopped surface's manager.

## Changelog:

[ANDROID] [FIXED] - Pull model: mount the commits of starting and stopping surfaces, and fix a freeze when the legacy `stopSurface(int)` runs on the UI thread


Test Plan:
New `FabricMountingManagerInstrumentationTest` cases drive a real `FabricUIManagerBinding`. The test helper runs no JavaScript and commits views to the surface's shadow tree itself. The pull-model cases run with the Props 2.0 flags and `enablePreallocatedPropsDiffOnInsertAndroid`.

| Test | Without the fix | With the fix |
| --- | --- | --- |
| `native_stopSurfaceWithSurfaceHandler_pullModel_pullsDeletes` | fails: no Deletes are pulled | passes |
| `native_stopSurface_pullModel_pullsDeletes` | hangs | passes |
| `native_stopSurfaceWithSurfaceHandler_pushModel_pullsDeletes` | passes | passes |

The push case is a control. The hang stops the run, so the bridgeless stop test was also run without the legacy one. With the fix, the whole class passes.

A one-off check, not committed, covered the start: a bridgeless surface started on a background thread commits a view, and the start is held before `startSurfaceWithSurfaceHandler` registers the surface. On #59029 alone the view is not mounted while the start is held. With this PR it is mounted by the next frame.

The test class has no Gradle target in the open-source repository, so I ran it on an Android emulator through a local androidTest source set:

```
./gradlew :packages:react-native:ReactAndroid:assembleReleaseAndroidTest -PreactNativeArchitectures=arm64-v8a
adb install -r -t packages/react-native/ReactAndroid/build/outputs/apk/androidTest/release/ReactAndroid-release-androidTest.apk
adb shell am instrument -w -e class com.facebook.react.fabric.FabricMountingManagerInstrumentationTest \
  com.facebook.react.tests.gradle/androidx.test.runner.AndroidJUnitRunner
```

I also checked the effect in RNTester with a temporary hook, not part of this PR. The Playground starts a 60 s `LayoutAnimation`, then `ReactDelegate.unloadApp()` stops the surface on the UI thread while the activity stays resumed. Props 2.0 and `enableLayoutAnimationsOnAndroid` were on:

| Run | After the stop |
| --- | --- |
| Pull, without this PR | no `onAllAnimationsComplete`; `driveCxxAnimations` keeps running on every frame |
| Pull, with this PR | `onAllAnimationsComplete` right after the stop, then no more driven frames |
| Push, with this PR | `onAllAnimationsComplete` right after the stop, as before |

Pulled By:
bartlomiejbloniarz

bartlomiejbloniarz

Differential Revision: D124966432
@meta-codesync
meta-codesync Bot force-pushed the bartlomiejbloniarz/pull-model-surface-stop branch from 57c7f89 to b703a10 Compare October 13, 2026 14:32
…he mount phase (#59029)

Summary:
With `enableMountingCoordinatorPullModelAndroid`, each commit notification queues a `PullTransactionMountItem` in `MountItemDispatcher`. `MountingCoordinator::pullTransaction` mounts the latest revision, so a surface's first queued item mounts every pending commit and its later items pull nothing. The item's queue position only reflects when the notification arrived.

The queued item breaks root view attach. While the root view is detached, the item waits in the surface's attach queue, ahead of the preallocations for the revisions it pulls. #58152 handles this by re-queueing the pulled batch on the dispatcher. That puts the batch behind a later pull of the same surface, so the newer revision mounts first.

This PR replaces the queued item with the set of surfaces that have a transaction available. A dispatch runs view commands, then pre-mount items, then one `FabricUIManagerBinding.pullAndExecuteTransaction` per pending surface whose root view is attached, then the queued mount items. Pulls share the mount items' "mount" trace, their handling of ignorable mounting exceptions, and `reportMount`. A surface waiting for its root view stays pending. Its attach already runs through `MountingManager.MountItemExecutor`, which now also schedules a frame, and that frame pulls the surface after its preallocations. This removes the #58152 re-queue and `PullTransactionMountItem`. With `disableIdleMountItemFrameCallbackRearmAndroid`, an attached pending surface keeps the frame callback armed, like a queued item.

When a dispatch has both a pending surface and queued mount items, it now pulls the surface first. On main, an item queued before the surface's first pull notification ran before the pull. Push behaves like main here: its batch is queued at the end of the JS task, behind the items queued during it. An item that targets a view the commit creates, such as an accessibility event or a `SynchronousMountItem`, now finds it. An item that targets a view the commit deletes now misses it, although that view is gone after this frame either way. It logs a soft exception and is dropped, or for a view command, retries once first. With the default flags, view commands still run before the pulls, as on main.

## Changelog:

[ANDROID] [FIXED] - Pull model: fix batches applied out of order when a surface's root view attaches


Test Plan:
`FabricUIManagerPullModelTest` models each surface's `MountingCoordinator`: a pull mounts every revision committed since the previous pull, as one batch.

```
./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest \
  --tests 'com.facebook.react.fabric.*'
```

| Test | Before | After |
| --- | --- | --- |
| `pullDeferredByRootAttach_isAppliedBeforeLaterPulls` | fails: revisions mount as 3, 1, 2 | passes |
| `onTransactionAvailable_pullsEachPendingSurfaceOnce` | fails: pulls `[1, 2, 1, 3]` | passes |
| `mountItemQueuedBeforeCommit_runsAfterThePull` | fails: the item runs before the batch | passes |
| `pendingPullOfStoppedSurface_runsOnceAndIsDropped` | fails: the stop clears the queued pull, nothing is pulled | passes |
| `viewCommandsInMountItemOrder_runAfterThePull` | fails: the command dispatched before the commit runs before the batch | passes |

- The new tests for pre-mount items, `reportMount` and the frame callback with `disableIdleMountItemFrameCallbackRearmAndroid` pass before and after. One of them, a commit notified from the commit thread while a frame pulls another surface, fails if `hasPendingItems` ignores pending surfaces.
- `onTransactionAvailable_enqueuesOnePullPerNotification` is now `onTransactionAvailable_pullsEachPendingSurfaceOnce`. `synchronousBatch_waitingForRootAttach_isDeferred` is removed, because a synchronous batch can no longer run while its root view is detached.
- All `ReactAndroid` JVM tests pass.
- A few one-off checks, not committed, passed on main and with this PR: commits on two surfaces, an item queued after a commit, and a dispatch without a commit. A push-model check also passed, with the pull flag on: batches queued around a root view attach apply in commit order.
- RNTester debug on an Android emulator with `enableMountingCoordinatorPullModelAndroid`, `enableAccumulatedUpdatesInRawPropsAndroid` and `enablePreallocatedPropsDiffOnInsertAndroid`: FlatList, Image, Animated and Accessibility examples render and update, including after leaving and reopening the app. There were no crashes or mounting exceptions.

Differential Revision: D124966304

Pulled By: bartlomiejbloniarz
Summary:
With `enableMountingCoordinatorPullModelAndroid`, a commit tells Java only its surface id. At the next mount dispatch, `FabricUIManagerBinding::pullAndExecuteTransaction` looks the surface's `MountingCoordinator` up in the binding's surface registry. A surface is not in the registry while it starts or stops:

- The transaction that stops a surface is never pulled, because `stopSurfaceWithSurfaceHandler` removes the surface from the registry before `SurfaceHandler::stop()` commits the empty tree. Mounting override delegates such as LayoutAnimations and C++ Animated never see those Deletes. With `enableLayoutAnimationsOnAndroid`, a layout animation that runs when its surface stops stays in flight, and `FabricUIManager` calls `driveCxxAnimations` on every frame until another surface pulls a transaction.
- A commit that a bridgeless surface makes before `startSurfaceWithSurfaceHandler` registers it is not mounted until the surface commits again.
- The legacy `stopSurface(int)` holds the registry lock across `stop()`. On the UI thread, the stop commit pulls inline and blocks on the same lock, so the app freezes.

iOS does not look the surface up. `RCTMountingManager` captures the `MountingCoordinator` that reported the commit in the block it dispatches to the main queue, and pulls from it.

This PR does the same. `schedulerShouldRenderTransactions` keeps the reporting `MountingCoordinator` until the UI thread pulls, and `pullAndExecuteTransaction` pulls from it. Java still receives only the surface id. The surface registry, the start and stop functions, `reportMount` and the push model are unchanged.

As on iOS, a surface stopped on the UI thread has its stop transaction pulled during the stop commit. When it stops on another thread, its shadow tree is destroyed and its coordinator revoked before the UI thread pulls, so that transaction is not mounted. A pull of a stopped surface can log "Executing commit after surface N was stopped!" when a mounting override delegate still returns a transaction; iOS pulls these the same way.

Only one coordinator per surface id is kept. Stopping and starting the same `ReactSurface` on the same host reuses its id. If the stopped surface's commit has not been pulled yet, the restarted surface's coordinator replaces it and a warning is logged. The replaced coordinator is revoked and would pull nothing. Java does not mount such a restarted surface anyway: `MountingManager.startSurface` logs "Called startSurface more than once" and keeps the stopped surface's manager.

## Changelog:

[ANDROID] [FIXED] - Pull model: mount the commits of starting and stopping surfaces, and fix a freeze when the legacy `stopSurface(int)` runs on the UI thread


Test Plan:
New `FabricMountingManagerInstrumentationTest` cases drive a real `FabricUIManagerBinding`. The test helper runs no JavaScript and commits views to the surface's shadow tree itself. The pull-model cases run with the Props 2.0 flags and `enablePreallocatedPropsDiffOnInsertAndroid`.

| Test | Without the fix | With the fix |
| --- | --- | --- |
| `native_stopSurfaceWithSurfaceHandler_pullModel_pullsDeletes` | fails: no Deletes are pulled | passes |
| `native_stopSurface_pullModel_pullsDeletes` | hangs | passes |
| `native_stopSurfaceWithSurfaceHandler_pushModel_pullsDeletes` | passes | passes |

The push case is a control. The hang stops the run, so the bridgeless stop test was also run without the legacy one. With the fix, the whole class passes.

A one-off check, not committed, covered the start: a bridgeless surface started on a background thread commits a view, and the start is held before `startSurfaceWithSurfaceHandler` registers the surface. On #59029 alone the view is not mounted while the start is held. With this PR it is mounted by the next frame.

The test class has no Gradle target in the open-source repository, so I ran it on an Android emulator through a local androidTest source set:

```
./gradlew :packages:react-native:ReactAndroid:assembleReleaseAndroidTest -PreactNativeArchitectures=arm64-v8a
adb install -r -t packages/react-native/ReactAndroid/build/outputs/apk/androidTest/release/ReactAndroid-release-androidTest.apk
adb shell am instrument -w -e class com.facebook.react.fabric.FabricMountingManagerInstrumentationTest \
  com.facebook.react.tests.gradle/androidx.test.runner.AndroidJUnitRunner
```

I also checked the effect in RNTester with a temporary hook, not part of this PR. The Playground starts a 60 s `LayoutAnimation`, then `ReactDelegate.unloadApp()` stops the surface on the UI thread while the activity stays resumed. Props 2.0 and `enableLayoutAnimationsOnAndroid` were on:

| Run | After the stop |
| --- | --- |
| Pull, without this PR | no `onAllAnimationsComplete`; `driveCxxAnimations` keeps running on every frame |
| Pull, with this PR | `onAllAnimationsComplete` right after the stop, then no more driven frames |
| Push, with this PR | `onAllAnimationsComplete` right after the stop, as before |

Pulled By:
bartlomiejbloniarz

bartlomiejbloniarz

Differential Revision: D124966432
@meta-codesync
meta-codesync Bot force-pushed the bartlomiejbloniarz/pull-model-surface-stop branch from b703a10 to cc33220 Compare October 14, 2026 09:38

This branch has not been deployed

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. meta-exported p: Facebook Partner: Facebook p: Software Mansion Partner: Software Mansion Partner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant