Shopify / Shopify/react-native-skia

[Android] Multiple <Image> elements at Canvas root bind to wrong SkImage (Skia 2.6.2 / Expo SDK 56)

Open
#3,873 0 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

Summary

On Android (Expo Go SDK 56 + @shopify/react-native-skia@2.6.2), multiple <Image> children of a single <Canvas> do not bind correctly to their independent SkImage sources. Only the first <Image> paints its source; subsequent <Image> elements either fail to paint, paint the first image's pixels, or shuffle bindings on re-render.

Each useImage(...) call logs a valid, distinct SkImage (different widths) — so the loading path is fine. Only the rendering / binding step is broken.

Environment

Platform Android
Device Pixel-class emulator sdk_gphone16k_arm64 (16KB page size), also reproduces on real Galaxy S24 Ultra
Expo SDK 56
React Native 0.85.3
JS engine Hermes
@shopify/react-native-skia 2.6.2
react-native-reanimated 4.3.1
react-native-worklets 0.8.3
Web (CanvasKit) not affected
iOS not yet tested

Expected behaviour

Three <Image> elements with three different useImage(require(...)) sources each paint their own distinct image.

Actual behaviour

  • The first <Image> paints its source correctly.
  • Subsequent <Image> elements paint the first image's pixels (or fail to paint).
  • Console logs confirm each useImage hook resolved a distinct SkImage (e.g. 512×512, 256×256, 256×256) — so the data is correct.
  • Triggering a re-render (any state change, including a button press) causes the displayed images at positions 2 and 3 to shuffle to different SkImages already mounted elsewhere in the tree — the binding is not stable.

Minimal reproduction

Fresh npx create-expo-app --template blank-typescript on SDK 56, install Skia, drop three small PNGs into ./assets/:

// App.tsx
import React, { useEffect } from "react";
import { StyleSheet, View } from "react-native";
import {
  Canvas,
  Image as SkiaImage,
  useImage,
} from "@shopify/react-native-skia";

// Three DIFFERENT image assets. Pick three visually distinct PNGs.
const SRC_A = require("./assets/a.png"); // e.g. 512x512 realistic photo
const SRC_B = require("./assets/b.png"); // e.g. 256x256 cartoon
const SRC_C = require("./assets/c.png"); // e.g. 256x256 cartoon (different)

function Probe({ src, x, label }: { src: number; x: number; label: string }) {
  const img = useImage(src);
  useEffect(() => {
    if (img) console.log(`[${label}]`, img.width(), img.height());
  }, [img, label]);
  if (img === null) return null;
  return (
    <SkiaImage image={img} x={x} y={10} width={120} height={120} fit="cover" />
  );
}

export default function App() {
  return (
    <View style={styles.root}>
      <Canvas style={styles.canvas}>
        <Probe src={SRC_A} x={10} label="A" />
        <Probe src={SRC_B} x={140} label="B" />
        <Probe src={SRC_C} x={270} label="C" />
      </Canvas>
    </View>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, backgroundColor: "white" },
  canvas: { flex: 1 },
});

Expected: three distinct 120×120 portraits side-by-side at the top of the canvas.

Observed on Android: the first portrait paints correctly. The other two slots show the same image as the first (or a different one from later in the tree), not their own. Console logs show [A] 512 512, [B] 256 256, [C] 256 256 — all three SkImages resolve, but only A's pixels actually paint.

Additional observations from a larger app

Reproduced in a family-tree canvas with ~50 portrait avatars. The same bugs surface in three related forms:

  1. <Image> children of <Group transform={...}> never paint. Other primitives (Circle, RoundedRect, Text, Path) in the same Group paint correctly. Only image draws are dropped.
  2. Swapping the image prop of an <Image> between two valid SkImages does not re-paint — the originally bound image stays even after the prop reference changes.
  3. Re-renders shuffle bindings. Pressing a filter button (which causes a setState upstream) shuffles which SkImage is displayed at each hardcoded probe position.

What we tried that did not fix it

  • useImage(Uint8Array) via Skia.Data.fromBytes (workaround for the SDK 55 / Skia 2.4 Data.fromURI bug #452)
  • useSharedValue<SkImage> cross-thread bridge from a reanimated worklet
  • <ImageShader> inside a <Rect> / <Circle> (alternative draw path)
  • Forcing Canvas/Group re-record via key={imageLoadCounter}
  • Removing all React.memo and useMemo boundaries
  • Replacing <Group transform> with manually computed screen-space coordinates (paints at canvas root, but multi-image binding still wrong as above)
  • A pre-loaded "placeholder" SkImage shared across all <Image> elements (the placeholder paints; the subsequent prop swap to each node's real source never updates the painted pixels)

Related issues found while debugging

  • #3712 (picture intermittently not rendering)
  • #3092 (useImage Android)
  • #3643 (Canvas failing to paint)
  • #2656 (text/image not rendering in lists)

…none of which seem to match this specific "multiple <Image> siblings bind to the wrong SkImage" symptom exactly.

Workaround currently used

Falling back to RN <Image> overlays absolutely positioned over the Skia <Canvas>, syncing positions to the Canvas's transform state. Functional but reintroduces the lag that pulling portraits into Skia was meant to solve.

Happy to test patches or share more context.

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 App.tsx minimal reproduction and verify the Android behavior with three distinct Skia Image children under one Canvas. Then trace the Android rendering and image-binding entry points for Canvas, Image, and useImage. Done means each sibling keeps its own pixels, prop swaps repaint, grouped images render, and re-renders do not shuffle bindings.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, react-native, typescript
Domain
mobile-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.