Skip to content

fix(🤖): move a SurfaceView's media overlay in place only where Android can - #578

Open
YevheniiKotyrlo wants to merge 4 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/backing-view-follow-ups
Open

YevheniiKotyrlo wants to merge 4 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/backing-view-follow-ups

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Follow-ups to #574, the same ones wcandillon/react-native-skia#4266 makes to the Skia side; the first four fail on main and pass here:

  1. zOrderMediaOverlay doesn't move a mounted canvas before Android 14, and nothing warns. takesZOrder lets both z-order props through from Android 11, but up to 13 SurfaceView.setZOrderMediaOverlay() only writes the sublayer the next surface is created with, and updateSurface() re-applies it only when it creates one (android-13); 14 compares the requested sublayer with the applied one (android-14). Its Javadoc asks for it before the window is attached.
  2. On Android 13, a media overlay that leaves the top comes back among the other SurfaceViews. setZOrderOnTop(false) applies the default sublayer to the live surface at once (android-13), before setZOrderMediaOverlay(true) asks for its own. 11 to 12L apply it from a frame callback that reads the sublayer when it runs (android-12), and 14 re-applies the changed request.
  3. android={{ opaque: false, zOrderOnTop: true }} compiles and gives a translucent TextureView: with no backingView the union can't pick a member, so its excess-property check accepts a key any member declares.
  4. A backingView no view takes falls back to a TextureView silently: a JavaScript caller's typo gets the default view with nothing in development to say so.
  5. The docs don't say that a SurfaceView canvas can leave black as it unmounts up to Android 12 (API 31): it removes its layer before the window frame without it lands. Since feat(🤖): name the Android backing view explicitly #574 a SurfaceView is one the app names, so the app needs to know.

Solution

  • BackingViewKind has one in-place rule per z-order prop: zOrderOnTop from Android 11, except on a media overlay on 13, and zOrderMediaOverlay from 14. A change the rule refuses is ignored with a warning, like a backingView change; the JS warnings, the example's key and the docs follow, and the example gains a "surface media overlay on top" option.
  • WebGPUSurfaceView.setZOrder() keeps the z-order it last asked of SurfaceView and asks only for a change, and WebGPUView hands it the props once per transaction, in updateView() with the opacity, so a change of both props moves the surface once.
  • Every member of AndroidCanvasProps declares every option, never where it doesn't apply; Canvas resolves the union in one exhaustive switch, without the as AndroidOptions cast, and AndroidBackingView and the web spec's props derive from the codegen spec.
  • In development, a JavaScript caller that hands the z-order options to a view that ignores them gets a warning, as on the Skia side, and so does one that names a backingView no view takes; the canvas keeps the default TextureView with the opacity asked for.
  • The SurfaceView paragraph of the canvas page carries the unmount caveat.

Tests

src/__tests__/unit/android.test.tsx, in #529's unit tier: the union's type rules, the native props each android value resolves to, and the development warnings at mount and on a change, for each z-order prop on API 29, 30, 33 and 34 and for zOrderOnTop on a media overlay on API 30, 32, 33 and 34. Run against #529's head they fail for each defect: TS2322 on the union, and 6 of 29 cases (no warning for a media overlay change on API 30 and 33, for zOrderOnTop on a media overlay on API 33, for the z-order options on a TextureView or a HardwareBufferView, or for a backingView no view takes).

BackingViewKind's rules have no JVM test here: the library's Java gets a host tier with #561.

Verification

yarn lint · yarn tsc · package build · the Expo template's web build · yarn test:unit 38 passed · yarn test:plugin 10 passed · yarn semantic-release --dry-run · yarn build:docs · yarn check-dawn · cpplint · CI's Android e2e step: on an Android 15 emulator, 226 of 228, the two YUV cases that fail on main on that emulator too; on the revision before the last commit, which changes Canvas.tsx alone, on a Galaxy S23 (Android 14), 228 of 228, and on a Mi MIX 3 (Android 10), 200 of 200 without the AHardwareBuffer import suites, whose first import loses the device on that phone's Adreno 630 driver on main too. CI's iOS e2e step, a Debug build against Metro, on an iPhone 12 Pro Max (iOS 27.0.1): 228 of 228.

On Android 11, 12L, 13 and 14 emulators, a media overlay canvas over a second SurfaceView canvas, its z-order props changed in place (evidence comment): on main, the backdrop covers it once the media overlay is turned back on (11 to 13) or once it leaves the top (13). Here it keeps the z-order it mounted with, with a warning, wherever Android can't move it, and moves in place everywhere else, 14 included.

Related

Stacked on #529, whose unit tier holds the tests; the Skia side is wcandillon/react-native-skia#4266.

Base

Cut from main at c080934e: #529's re-cut, then three commits (the fix, the docs caveat, and the opacity and warning for a backingView no view takes); it merges cleanly with main at dbb17185.

… React Native Testing Library

The Jest mock's Canvas built its ref handle on every render
(useImperativeHandle without dependencies), so a ref got a new handle after
each re-render, and a state setter used as the ref re-rendered its owner
until React stopped with "Maximum update depth exceeded". It builds the
handle once, as Canvas does.

Every spec under src/__tests__ goes through the e2e harness, which waits for
the example app, so nothing rendered a component under Jest.
jest.unit.config.js runs src/__tests__/unit with React Native's Jest preset
and React Native Testing Library 14 on test-renderer, React 19's
replacement for react-test-renderer; CI runs it beside the plugin's tests.
The Canvas spec drives a stand-in RNWebGPU module and View's measureLayout,
and the mock's spec holds its handle and its loud failures.

On a device, Canvas.spec.ts checks a canvas drawn at half its layout size
under a scale transform: its context is sized from the layout, and its ref
handle is the same after re-renders. setup.ts's isNativeApp() replaces the
specs' inline platform checks, and WebPolyfill.spec.ts covers the web
polyfill's native surface.
…d can

- zOrderMediaOverlay applies to a live surface only from Android 14: up to
  Android 13, SurfaceView.setZOrderMediaOverlay() sets the sublayer the next
  surface is created with, and updateSurface() never re-applies it. The
  in-place gate is per prop (BackingViewKind), the change is ignored with a
  warning before 14, and the example remounts on it there.
- On Android 13 a media overlay reads zOrderOnTop at mount as well: its
  SurfaceView moves a surface leaving the top to the default sublayer at
  once, before setZOrderMediaOverlay() asks for its own, so the canvas would
  come back among the other SurfaceViews rather than above them. Android 11
  and 12 apply the sublayer with the next frame, and 14 re-applies it.
- A z-order change is applied once per prop transaction, with the opacity:
  WebGPUSurfaceView keeps the z-order it last asked of SurfaceView and asks
  only for a change.
- AndroidCanvasProps declares every option on every view, `never` where it
  does not apply, so a SurfaceView option without backingView no longer
  compiles, and Canvas resolves the union with one exhaustive switch instead
  of a cast. AndroidBackingView and the web spec's props derive from the
  codegen spec.
- The development warnings follow each release's rule, read the props as
  the native side receives them, and say when a JavaScript caller hands the
  z-order options to a view that ignores them.
…o Android 12

Up to Android 12 (API 31) a SurfaceView removes its layer before the window
frame without it lands, so an unmounting SurfaceView canvas can leave black
where it was for a frame; from 12L the removal lands with the window's next
frame. With the backing view named by the app, the docs say so where they
describe the SurfaceView.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

The z-order of a mounted media overlay — before / after

On x86_64 emulator images, release builds of the example with a probe screen, the same JavaScript over main's native code and over this PR's. The screen mounts a media overlay SurfaceView canvas cleared red over a second SurfaceView canvas cleared green (mounted second, so it wins a tie in the default sublayer), and two buttons change the media overlay's zOrderOnTop and zOrderMediaOverlay in place, under the same key. After each step the media overlay layer's z is read from dumpsys SurfaceFlinger: 1 on top of the window, −1 the media overlay sublayer, −2 the default one.

Image Build Mount zOrderOnTop on zOrderOnTop off zOrderMediaOverlay off zOrderMediaOverlay on
API 30 (Android 11) main −1 1 −1 −2 −2
API 30 this PR −1 1 −1 −1, warns −1, warns
API 32 (Android 12L) main −1 1 −1 −2 −2
API 32 this PR −1 1 −1 −1, warns −1, warns
API 33 (Android 13) main −1 1 −2 −2 −2
API 33 this PR −1 −1, warns −1, warns −1, warns −1, warns
API 34 (Android 14) main −1 1 −1 −2 −1
API 34 this PR −1 1 −1 −2 −1

A bold cell is a media overlay left in the default sublayer, where the other canvas covers it. On Android 13 that happens as soon as it leaves the top:

before — main, Android 13, zOrderOnTop turned on and off again

after — with this PR

With this PR a change Android can't apply to the live surface is ignored with a warning, and a remount takes it, as the example's key does; everywhere else it moves in place, as on main. The probe screen, each canvas clearing to its colour every 200 ms:

<View style={{ flex: 1 }}>
  <Canvas
    ref={red}
    style={{ position: "absolute", left: "20%", right: "20%", top: "30%", bottom: "30%" }}
    android={{ backingView: "SurfaceView", zOrderMediaOverlay: mediaOverlay, zOrderOnTop: onTop }}
  />
  <Canvas ref={green} style={StyleSheet.absoluteFill} android={{ backingView: "SurfaceView" }} />
  {/* two buttons toggling onTop and mediaOverlay */}
</View>

CI's Android e2e step on an API 35 emulator: 226 of 228 here, the two YUV cases main fails on that emulator too; on a Galaxy S23 (Android 14): 228 of 228; on a Mi MIX 3 (Android 10): 200 of 200 without the AHardwareBuffer import suites, which lose the device on that phone on main too.

…and say so

A backingView value no view takes resolves to the default TextureView, and
now keeps the opacity the android prop asks for, as main does; in
development the canvas names the unknown value in a warning. The native
props of a view without a z-order of its own come from one builder. The
tests also warn about the z-order options on a HardwareBufferView, and
mount one canvas per z-order change case.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant