Skip to content

fix(🤖): keep a canvas on screen while it swaps its backing view in place - #563

Closed
YevheniiKotyrlo wants to merge 10 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/in-place-swap
Closed

YevheniiKotyrlo wants to merge 10 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/in-place-swap

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Changing a mounted canvas's surfaceType, zOrderOnTop or, with the default surfaceType, opaque replaces its Android backing view, and the canvas region shows what lies under the canvas for a few frames:

  • a new TextureView makes its SurfaceTexture in its first draw and takes a frame presented then only in its next draw, so it draws empty once or twice;
  • a new SurfaceView shows nothing until its first frame, and fix(🤖): open a SurfaceView canvas's hole with its first frame #561 opens its hole only then, while the outgoing TextureView is gone with the window frame that removed it;
  • on Android 10 to 12 an outgoing SurfaceView's layer leaves before the window frame that stops punching its hole, so that frame shows black.

A full-screen canvas over a white stage, cleared every frame by a requestAnimationFrame loop, its surfaceType switched between SurfaceView and TextureView eight times per round: on a Galaxy S23 (Android 14), 16 of 16 swaps show the stage for a frame or more on main and 14 of 16 on this PR's base, and on a Mi MIX 3 (Android 10) 14 of 16 show black or a frame of neither the canvas nor the stage on both; with this change none does on either (measurement).

These are wcandillon/react-native-skia#4189 and #4192 for this repo.

Solution

The same design as those two PRs:

  • A new TextureView gets its texture before its first draw when it is shown and laid out: WebGPUTextureView.supplySurfaceTexture makes the SurfaceTexture, and onSurfaceCreate adopts the surface on the main thread right away, which blits the latest frame (fix(🤖): keep the last frame of a canvas whose Android surface is recreated #544 keeps it as the outgoing view goes) and presents it, so the first draw shows it. TextureView reports no onSurfaceTextureAvailable for a texture it was given.
  • A new SurfaceView keeps the outgoing TextureView above it (OutgoingViewHold): the outgoing view stops reporting to the canvas, keeps the frames presented into it until the new surface is adopted, and leaves once the SurfaceView has presented its first frame (fix(🤖): open a SurfaceView canvas's hole with its first frame #561's report) and a window frame drawn after it has committed. The new surface starts from the frame the held view shows as it is created. On Android 13 the hold also waits for the hole, which opens a message after the report there. A new view that shows nothing within fix(🤖): open a SurfaceView canvas's hole with its first frame #561's one-second wait is not waited for, and another swap or the canvas leaving its window removes the held view at once.
  • On Android 10 to 12 an outgoing SurfaceView is retired rather than removed: it reads its frame back for the canvas and releases its surface before the new view attaches, stays attached, and WebGPUView stops drawing it, so its layer is hidden with the window frame that draws the new view. It is removed at the next swap, when the canvas leaves its window, or when the window hides.

BackingViewKind holds the three decisions. With #543 in the stack, keepFrame leaves a frame deferred until the window draws, on Android 11, where it waits in the offscreen texture: it is newer than the frame the surface shows. canvas.mdx says the canvas stays on screen through a replacement.

Tests

BackingViewKindTest covers when a new view gets its surface before its first draw, keeps the outgoing one, or retires it, over every kind and release; OutgoingViewHoldTest drives the hold through the first frame, the hole, the window frame and the one-second wait with a recording host; FirstFrameDrawGateTest covers hasSurface. The gap itself shows only in what the window composites, so the counts come from screenrecord.

Verification

yarn lint · yarn tsc · yarn turbo run build --filter='./packages/*' · the Expo template's web build · yarn test:plugin · yarn check-dawn · yarn build:docs · cpplint with the package's flags · clang-format on the changed lines · ./gradlew :react-native-webgpu:testDebugUnitTest (47 tests) · the example's iOS build for the simulator SDK. CI's Android e2e step (debug build, Metro with CI=true) on an Android 15 emulator: 221 of 223, main 8f664fca's 219 and the two cases #542 and #543 add to TextureViewPresent; the two failures, the YUV cases of ExternalTextureColorSpace and NativeBufferWrapper, fail on main there too. Not run here: the iOS e2e job, which needs a simulator, the SwiftPM example's build, and the semantic-release dry run, which needs the repository's token.

Known limits

On Android 10 a retired SurfaceView keeps its last buffer in the compositor, one canvas-sized buffer, until the canvas swaps again, leaves its window or its window hides: removing it any earlier is what shows black. Android 8 and 9 still remove the outgoing SurfaceView at once, since they hide nothing for a view the window stops drawing. A swap made before the canvas is laid out keeps the lazy path: there is no size to give a texture.

This builds on #544 and #562, which carries #561, #542 and #543, and shows their commits until they merge.

A canvas drawn from Reanimated's UI thread presents inside the main
thread's Choreographer frame, ahead of the window's draw. A TextureView
takes its next buffer only when the window draws, and its buffer queue
replaces a buffer it has not taken yet, so the next present replaced
every other frame and the window showed half of them.

A frame presented on the main thread to a TextureView surface now waits
for a present the view posts to the main looper, behind the draw the
window has pending, as react-native-skia's FrameScheduler does. The next
frame boundary presents a frame still waiting first, a detach drops it
with its surface, and frames presented on other threads, or to a
SurfaceView or HardwareBufferView, are presented as before.

Android 11 keeps presenting at once: its Vulkan loader gives a
TextureView's buffer queue a 4.3 s dequeue timeout, and a present held
behind the draw stretched those waits into 14 s freezes and an ANR on
the Android 11 emulator.

The weak handle the hardware buffer view's waker kept on its view is
shared by both calls from native, the canvas docs say when a UI-thread
frame reaches a TextureView, and the e2e suite presents frames from a
worklet on a TextureView canvas on the tests screen.
…oid 11

Android 11's Vulkan loader gives a TextureView's queue a 4.29 s dequeue
timeout where it means none, and a canvas presenting on the UI thread
then waits there for a buffer only that thread's next draw gives back.
On Android 11 a TextureView canvas acquires only once its window has
drawn the frame presented last, rendering into the offscreen texture
meanwhile, so a frame presented on the UI thread waits behind the
window's pending draw there too. The TextureViewPresent spec runs on
the UI thread as well.
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — without this PR, the canvas region shows the white stage under the canvas for two frames when it swaps to a TextureView (top row) and three when it swaps back to a SurfaceView (bottom row).

FIXED — with this PR the canvas shows a frame in every one.

before

after — with this PR

Galaxy S23 (Android 14, 120 Hz): a full-screen canvas over a white stage that a requestAnimationFrame loop clears to the next of eight colors every frame, with a switch that changes surfaceType between SurfaceView and TextureView. Each row is eight consecutive frames of a screenrecord from the tap on, scaled down, with the switch in its panel.

Device measurement — before / after

Before — on the S23, 16 of 16 swaps show the stage on main and 14 of 16 on this PR's base; on the Mi MIX 3, 14 of 16 show black or a frame of neither the canvas nor the stage on both.

This PR — the canvas shows a frame through every swap on both.

swaps showing anything but the canvas, two rounds of eight main 8f664fca base: #544, #561, #562 this PR 78beb81e
Galaxy S23 (Android 14, API 34) 16 of 16, 26 frames of the stage 14 of 16, 21 frames of the stage 0 of 16
Mi MIX 3 (Android 10, API 29) 14 of 16, 11 black frames and 11 of neither 14 of 16, 10 black and 24 of neither 0 of 16

Release builds of the example opening the probe screen, this PR's JavaScript over the native code each column names; screenrecord, every recorded frame classified at the canvas's centre. #544's kept frame alone does not close the gap: the frame is there, but neither new view can show it in time.

An opaque canvas is a SurfaceView behind its window, and the window opens
its hole once the SurfaceView reports its draw finished. WebGPUSurfaceView
reported it as soon as surfaceChanged returned, so the hole opened on a
surface with no frame and showed black until the first one landed.

The view now holds the report until a frame is presented into the surface,
as GLSurfaceView holds it until its next frame. attachSurface takes a
callback for the surface's first present, and each surface carries a token,
so a report for a replaced surface is ignored. Before Android 14 the wait is
bounded at a second; from Android 14 the platform bounds it. A report that
goes out without a frame, at that second or for a later surface of the view,
which SurfaceView counts as reported already, no longer opens the hole: the
view punches it, and adds it to the window's transparent region, only while
its surface has a frame. A context configured before its surface exists, or
left by a lost one, no longer presents an empty frame when the next surface
attaches, and detach is one operation for a lost surface and a destroyed
view.

The canvas docs say when the hole opens, the example gains a SurfaceView
First Frame diagnostic that can also draw its first frame late,
FirstFrameDrawGate is unit tested, and CI runs the Android library's unit
tests.
Before Android 12L a SurfaceView removes its layer as it leaves its window,
ahead of the window frame that stops punching its hole, so an opaque canvas
leaving the screen can show a black frame where it was. An opaque canvas
with no surfaceType now gets a TextureView on those releases, unless it sets
zOrderOnTop, which only a SurfaceView offers; surfaceType: "SurfaceView"
still selects one.

BackingViewKind decides the backing view, as in react-native-skia, and is
unit tested. The canvas docs and the surfaceType TSDoc state the default,
and the example's opaque canvas diagnostic gains a mounted switch.
…eated

A SurfaceView's surface goes with its buffers when the app goes to the
background or the view leaves its window, and the canvas presented nothing
into the next one, so a canvas that draws once, like ImageBlur, came back
black.

surfaceDestroyed is the last moment that frame can be read: the view reads
it back there with PixelCopy and writes it into the canvas's offscreen
texture, which the next surface's attach presents. A TextureView replaced by
another backing view hands its frame over the same way, through getBitmap.
Nothing is copied while the canvas draws.

The canvas docs say a canvas that stops drawing keeps its last frame.
# Conflicts:
#	packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
@wcandillon

Copy link
Copy Markdown
Owner

I would like to prevent the backing view from being changed once mounted but let's keep discussing it if needed.

@wcandillon wcandillon closed this Oct 17, 2026
wcandillon added a commit that referenced this pull request Oct 18, 2026
Same model as react-native-skia: the `opaque` prop was a shortcut to get
a SurfaceView on Android, consumed nowhere else on native (the web view
only painted a white background from it). It moves under `android` as a
compositing hint, applied in place on whichever view backs the canvas,
and the view itself is named by `android.backingView` (replacing
`surfaceType`), with `TextureView` as the default whatever the opacity.

- `android` is a discriminated union: a TextureView takes `opaque`
  (translucent by default), a SurfaceView takes `opaque` (opaque by
  default), `zOrderOnTop` and the new `zOrderMediaOverlay`, which
  composites it above the other SurfaceViews below the window; both
  z-order options apply in place on Android 11 and later. A
  HardwareBufferView takes no option: it always composites with alpha,
  and falls back to a TextureView below Android 10, with a warning in
  development.
- Nothing derives the backing view from another prop any more, so the
  JS and Java rules collapse to reading `backingView`; the pure rules
  live in BackingViewKind.
- On the web, `alphaMode` alone decides how the canvas composites, as in
  a browser: the white background `opaque` used to paint is gone.
- The docs describe the model, including what a remount through a key
  costs (a frame or two off screen) and that keeping the canvas on screen
  through a swap is possible but needs per-release logic (#563).

Verified on the Pixel 8 (Android 17) with the Android View Props
diagnostic: opaque applies in place on both views, the on-top and media
overlay z-orders move a mounted SurfaceView without a remount, and the
HardwareBufferView composites with alpha; no warning, error or crash in
logcat.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants