From da86e60ffcb6a5c3b6a9045be560509d34249626 Mon Sep 17 00:00:00 2001 From: woosanggyu Date: Tue, 25 Aug 2026 20:14:46 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(=F0=9F=90=9B):=20don't=20crash=20when?= =?UTF-8?q?=20snapshotting=20a=20canvas=20that=20is=20not=20laid=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeImageSnapshot passed the canvas size (-1 before layoutSubviews) into makeOffscreenSurface, aborting in Metal texture validation, and makeImageSnapshotAsync dereferenced a null view. Return nullptr in both cases so they fall through to the existing snapshot rejection. Adds an example screen (API → Snapshot Before Layout) reproducing #4029. --- apps/example/src/Examples/API/List.tsx | 4 + apps/example/src/Examples/API/Routes.ts | 1 + .../src/Examples/API/SnapshotBeforeLayout.tsx | 141 ++++++++++++++++++ apps/example/src/Examples/API/index.tsx | 8 + apps/example/src/Examples/API/linking.ts | 1 + packages/skia/cpp/rnskia/RNSkJsiViewApi.h | 9 +- packages/skia/cpp/rnskia/RNSkView.h | 14 +- 7 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 apps/example/src/Examples/API/SnapshotBeforeLayout.tsx diff --git a/apps/example/src/Examples/API/List.tsx b/apps/example/src/Examples/API/List.tsx index 305fa8f203..bd442714ad 100644 --- a/apps/example/src/Examples/API/List.tsx +++ b/apps/example/src/Examples/API/List.tsx @@ -54,6 +54,10 @@ export const examples = [ screen: "Snapshot", title: "📺 View Snapshot", }, + { + screen: "SnapshotBeforeLayout", + title: "🫥 Snapshot Before Layout", + }, { screen: "PathEffect", title: "⭐️ Path Effect", diff --git a/apps/example/src/Examples/API/Routes.ts b/apps/example/src/Examples/API/Routes.ts index e69add788b..309e8511e1 100644 --- a/apps/example/src/Examples/API/Routes.ts +++ b/apps/example/src/Examples/API/Routes.ts @@ -24,6 +24,7 @@ export type Routes = { OnLayout: undefined; OnSize: undefined; Snapshot: undefined; + SnapshotBeforeLayout: undefined; IconsExample: undefined; Paragraphs: undefined; Paragraphs2: undefined; diff --git a/apps/example/src/Examples/API/SnapshotBeforeLayout.tsx b/apps/example/src/Examples/API/SnapshotBeforeLayout.tsx new file mode 100644 index 0000000000..301f6de66d --- /dev/null +++ b/apps/example/src/Examples/API/SnapshotBeforeLayout.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useState } from "react"; +import { Button, ScrollView, StyleSheet, Text, View } from "react-native"; +import { Canvas, Circle, useCanvasRef } from "@shopify/react-native-skia"; + +/** + * Regression check for https://github.com/Shopify/react-native-skia/issues/4029 + * + * Snapshots of a Canvas whose native view is not ready must reject with + * "Failed to make snapshot from view." instead of aborting the process. + * + * A) view does not exist: `display: "none"` parent (never mounted natively), + * or the snapshot is requested right after commit / after unmount. + * Without the fix: null dereference in makeImageSnapshotAsync. + * B) view mounted but not laid out yet: the view is registered in + * willMoveToSuperview, but its Metal context is only created in + * layoutSubviews, so the canvas size is -1 in between. + * Without the fix: MTLTextureDescriptor has width (18446744073709551615). + * The window is small, so this case is exercised in a remount loop. + * C) zero-size canvas. + */ + +// Set to true to start the case-B loop automatically after mount. +const AUTO_START = false; + +type Log = (m: string) => void; + +const snapshot = ( + tag: string, + ref: ReturnType, + log: Log +) => { + ref.current + ?.makeImageSnapshotAsync() + .then((img) => log(`[${tag}] resolved ${img.width()}x${img.height()}`)) + .catch((e) => log(`[${tag}] rejected: ${String(e)}`)); +}; + +const FreshCanvas = ({ log }: { log: Log }) => { + const ref = useCanvasRef(); + useEffect(() => { + // One tick after commit: the native view has been added to its superview + // (registered) but may not have gone through layoutSubviews yet. + const t = setTimeout(() => snapshot("fresh", ref, log), 0); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + + + + ); +}; + +export const SnapshotBeforeLayout = () => { + const refHidden = useCanvasRef(); + const refZero = useCanvasRef(); + const refVisible = useCanvasRef(); + const [logs, setLogs] = useState([]); + const [counts, setCounts] = useState({ resolved: 0, rejected: 0 }); + const [stress, setStress] = useState(AUTO_START); + const [mountKey, setMountKey] = useState(0); + const log: Log = (m) => { + console.log(m); + setLogs((prev) => [...prev.slice(-30), m]); + const key = m.includes("rejected") ? "rejected" : "resolved"; + setCounts((prev) => ({ ...prev, [key]: prev[key] + 1 })); + }; + + useEffect(() => { + if (!stress) { + return; + } + const iv = setInterval(() => setMountKey((k) => k + 1), 40); + return () => clearInterval(iv); + }, [stress]); + + return ( + + Snapshot of a canvas that is not ready + Every button must log a rejection, never crash. + + resolved: {counts.resolved} / rejected: {counts.rejected} + + + {/* A) never mounted natively */} + + + + + + + {/* C) zero size */} + + + + + {/* B) freshly mounted, snapshot before first layout */} + {stress && } + + {/* control */} + + + + +