Shopify / Shopify/react-native-skia

Canvas permanently stops being presented after a few mounts (iOS + Android)

Open
#4,039 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
8.6k
Forks
647
Avg merge
1d 17h
Merged PRs (30d)
35

Description

[!IMPORTANT]
Updated — there is now a standalone reproduction, and parts of the original description below are wrong.

Repro: https://github.com/Kerumen/skia-blank-repropnpm install && npx expo run:ios. It cycles automatically and the canvas blanks within ~5-8 cycles.

Re-bisected by subtraction from the failing app (remove one thing, verify on device, repeat) instead of by trying to rebuild it. Five conditions, each removed from a reproducing build and then restored:

  1. Two Skia canvases alive at once. With one it never fires. Unmounting one is not required — two coexisting is enough.
  2. An ancestor of the canvas carries a Reanimated animated style — it may be finished and resting at the identity transform.
  3. A sibling mounts with an entering layout animation a beat later. The same sibling as a plain <View> never triggers it.
  4. A second sibling running its own useAnimatedStyle mounts behind the first.
  5. A GestureDetector wraps the second canvas. Replacing it with a plain View — two lines — stops the bug; restoring it brings it back.

Two corrections to the table below:

  • The row "the same text with its entering layout animation removed → blank again" was confounded — another element with its own FadeIn was still mounting. The entering animation IS required (condition 3).
  • Conditions 1, 4 and 5 are missing from the original entirely. The GestureDetector in particular is required, and nothing below mentions it.

The repro's README also lists what turned out not to matter: scene complexity, a third canvas, an image texture, a burst of 14 independently-animated views, gyro tilt re-recording every frame, a scaling ancestor, network I/O landing across the animation, and the mount timing of the canvas subtree.

The workaround is unchanged: drop the animated style once nothing is moving (FIX in lib/fix.ts). At rest the transform is the identity, so it is visually identical.

Original report (kept for the thread; see the corrections above)

Description

A <Canvas> permanently stops being presented when an unrelated React commit mounts a sibling view, if any ancestor of the canvas carries a Reanimated animated style at that moment.

The canvas keeps recording correctly the whole time — makeImageSnapshot() on the blank canvas returns a fully painted image at the correct dimensions — but nothing reaches the screen, and CanvasRef.redraw() does not recover it. The area shows whatever is behind the canvas until the component is remounted.

The animated ancestor does not have to be doing anything. In our case its animations had finished ~4 seconds earlier and its values were provably at rest; it is the presence of the animated style, not its value, that matters.

This is not backend-specific: it reproduces on iOS (Metal) and on a physical Android device (OpenGL), which use entirely separate canvas providers.

Versions

package version
@shopify/react-native-skia 2.11.1 (also reproduces on 2.6.2)
react-native 0.86.3 (new architecture / Fabric)
react-native-reanimated 4.5.1
react-native-worklets 0.10.1
expo 57.0.18

Tested on the iOS Simulator (iPhone 17 Pro Max) and a physical Android phone.

Reproduction

Structure, reduced from a real screen:

<Animated.View style={animatedStyle}>   {/* any Reanimated animated style */}
  <Canvas style={{ width, height }}>
    …scene…
  </Canvas>
</Animated.View>

{settled && <Animated.Text>…</Animated.Text>}   {/* mounts ~4s later */}
  1. Render the canvas under a view carrying a Reanimated animated style.
  2. Let every animation finish. The canvas paints correctly.
  3. Seconds later, mount any sibling view (a <Text> is enough) in the same tree via a state change.
  4. Roughly one time in five, the canvas goes blank on that frame and never recovers.

A 60fps capture shows it die in a single frame — region mean luma 50 → 0, no fade, no intermediate value.

What we ruled out, by bisection on device

Each cell is 20+ trials; the baseline failure rate is 1 in 4–7 on the iOS Simulator and 1 in ~9 on the Android phone.

variation result
suppress every view that mounts after the canvas 20+ clean
re-enable only a single <Animated.Text> blank on the 8th
the same text with its entering layout animation removed blank again
that text always-mounted, revealed by opacity instead 20+ clean
swap every animated ancestor for a plain style once at rest 20+ clean
leave any one animated ancestor live blank within 6–8

So it is the mount, not the layout animation, and no particular ancestor is at fault — one is enough.

Also ruled out by measurement, in case they save you time: the mutated-SkPath pattern (rewind() + addPoly() on shared objects), decoded-image cache eviction, component remounts (the canvas mounts once and never unmounts), and the ordering of the sibling mount relative to other state changes.

The measurable symptom

An animated ancestor's transform leaks into the size the canvas measures for itself, on both platforms:

platform true canvas width onSize reports
iOS 330 368
Android 312 349

That is the same ~11.8% — exactly the scale the ancestor was applying. Both platforms' providers call setSize on every layout pass, and setSize recreates the window context unconditionally (no idempotence check for unchanged bounds), which reassigns CAMetalLayer.drawableSize on iOS and resizes the surface holder on Android.

We could not confirm the final step from JS. Two things in the iOS path look relevant and are unchanged in 2.11.1:

  • RNSkMetalCanvasProvider::setSize recreates MetalWindowContext even when the bounds are identical.
  • MetalWindowContext::present() calls presentDrawable and commits without checking command-buffer creation, status, error, or whether the drawable was actually presented. A failed renderToCanvas() is discarded by RNSkPictureRenderer, and the redraw callback clears _redrawRequested regardless — so a failed present is silent and nothing retries.

Attempted minimal reproductions (all failed — please tell me what to add)

I could not reduce this to a standalone app, and I want to be upfront about that rather than attach a snippet that does not show the bug. Three attempts, each run for ~20 cycles on the iOS Simulator against the same Expo SDK 57 / Skia 2.11.1 setup, with a timer mounting a sibling <Text> a second after the canvas settled:

  1. A static <Rect> under one Animated.View. No reproduction.
  2. Same, but the canvas rebuilds an SkPath every frame from a withRepeat shared value, with a Group transform re-recording the scene. No reproduction.
  3. Same, plus a second <Canvas> layered over the first and unmounted mid-cycle, and the whole subtree remounted between cycles via a key — mirroring our screen, which has a scratch-off foil canvas over the card and navigates card to card. No reproduction.

So something about the real screen's scale is needed that I have not isolated. The real scene is considerably heavier: a full 3D card relief built from six per-frame homographies and seven mutated SkPaths, a decoded ~4MB image, and Paragraph text, all under a route transition.

If you can tell me what would make the race fire in isolation, I will build it. In the meantime the reproduction in our app is completely reliable and switches on and off with a single flag, so I can run any experiment you want, add native logging to a local pod, or test a patch on either platform.

Workaround

Carry no Reanimated animated style on any ancestor of a <Canvas> while nothing is moving. We swap the animated styles for plain resting equivalents once the animations settle, and re-attach them when a gesture starts. With that in place: 20+ clean on both platforms, on both 2.6.2 and 2.11.1.

Happy to help

We have a reliable harness for this and can run experiments, add native logging to a local pod, or try a patch against either platform's provider.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the standalone reproduction in https://github.com/Kerumen/skia-blank-repro using pnpm install && npx expo run:ios. Inspect RNSkMetalCanvasProvider::setSize, MetalWindowContext::present(), RNSkPictureRenderer, and the corresponding Android provider path. Done means the canvas continues presenting after the listed mount and animation conditions on both iOS and Android.

Written by the indexing model from the issue text.

Assessment

Tech stack
react-native
Domain
computer-graphics, mobile-dev
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.