diff --git a/apps/example/src/Examples/API/FrameCallbackChurn.tsx b/apps/example/src/Examples/API/FrameCallbackChurn.tsx
new file mode 100644
index 0000000000..10d1bf9255
--- /dev/null
+++ b/apps/example/src/Examples/API/FrameCallbackChurn.tsx
@@ -0,0 +1,225 @@
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { StyleSheet, Switch, Text, View } from "react-native";
+import { Canvas, Circle, Fill } from "@shopify/react-native-skia";
+import type { SkSize } from "@shopify/react-native-skia";
+import {
+ runOnJS,
+ useFrameCallback,
+ useSharedValue,
+} from "react-native-reanimated";
+
+/**
+ * Frame callback churn.
+ *
+ * `` drives `onSize` from a Reanimated frame callback. Reanimated's
+ * `useFrameCallback` has deps `[callback, autostart]`, so if the callback is not
+ * memoized its identity changes on every render and the effect tears down and
+ * re-registers the callback every single time:
+ *
+ * cleanup: unregisterFrameCallback(oldId) -> scheduleOnUI
+ * setup: registerFrameCallback(callback) -> scheduleOnUI (+ new shareable)
+ * setActive(isActive) -> scheduleOnUI
+ *
+ * That is three UI-thread hops and a freshly serialized worklet closure per
+ * `` per render. Reanimated's UI-side registry also stops and restarts
+ * its rAF loop each time, because `manageStateFrameCallback` bumps `nextCallId`
+ * whenever `activeFrameCallbacks` empties, which makes the in-flight `loop` bail
+ * on its next tick.
+ *
+ * Reanimated hands out frame callback ids from a single monotonically
+ * increasing counter, so the highest id present in the UI-thread registry is a
+ * direct measure of how many registrations have happened. This screen samples
+ * that from the UI thread and reports the rate: **new registrations / second**.
+ *
+ * - 0/s while the tree re-renders -> callbacks are stable (fixed)
+ * - ~1/s per re-rendering -> churn (the bug)
+ *
+ * The "reference churner" toggle mounts a component that deliberately uses the
+ * unmemoized pattern, so you can see what the bug looks like on this same meter
+ * even after `` itself is fixed.
+ */
+
+// ---------------------------------------------------------------------------
+// A component that reproduces the unmemoized pattern on purpose, as a control.
+// ---------------------------------------------------------------------------
+
+const ReferenceChurner = ({ renders }: { renders: number }) => {
+ // Inline arrow: the Babel plugin rebuilds this closure on every render, so
+ // `useFrameCallback`'s effect re-runs and re-registers every render.
+ const fc = useFrameCallback(() => {
+ "worklet";
+ }, true);
+ return (
+
+ reference churner callbackId: {fc.callbackId} (render {renders})
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+
+export const FrameCallbackChurn = () => {
+ const size = useSharedValue({ width: 0, height: 0 });
+ const [renders, setRenders] = useState(0);
+ const [driving, setDriving] = useState(true);
+ const [mountCanvas, setMountCanvas] = useState(true);
+ const [useOnSize, setUseOnSize] = useState(true);
+ const [showChurner, setShowChurner] = useState(false);
+
+ const [registered, setRegistered] = useState(0);
+ const [maxId, setMaxId] = useState(0);
+ const [rate, setRate] = useState(0);
+
+ // One render per frame. Any frequently re-rendering parent does the same
+ // thing: a chat, a list, a progress bar, a gesture-driven layout.
+ useEffect(() => {
+ if (!driving) {
+ return;
+ }
+ let raf = 0;
+ const tick = () => {
+ setRenders((n) => n + 1);
+ raf = requestAnimationFrame(tick);
+ };
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, [driving]);
+
+ const report = useCallback((count: number, highest: number) => {
+ setRegistered(count);
+ setMaxId(highest);
+ }, []);
+
+ const frames = useSharedValue(0);
+ const lastSampleId = useSharedValue(-1);
+ const lastSampleAt = useSharedValue(0);
+
+ // This probe is itself memoized, so it registers exactly once and does not
+ // pollute the measurement.
+ const probe = useCallback(
+ (info: { timestamp: number }) => {
+ "worklet";
+ frames.value += 1;
+ if (frames.value % 30 !== 0) {
+ return;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const registry = (global as any)._frameCallbackRegistry;
+ if (!registry) {
+ return;
+ }
+ let highest = -1;
+ let count = 0;
+ registry.frameCallbackRegistry.forEach((_: unknown, id: number) => {
+ count += 1;
+ if (id > highest) {
+ highest = id;
+ }
+ });
+ if (lastSampleId.value >= 0) {
+ const elapsed = (info.timestamp - lastSampleAt.value) / 1000;
+ if (elapsed > 0) {
+ runOnJS(setRate)(
+ Math.round((highest - lastSampleId.value) / elapsed)
+ );
+ }
+ }
+ lastSampleId.value = highest;
+ lastSampleAt.value = info.timestamp;
+ runOnJS(report)(count, highest);
+ },
+ [frames, lastSampleId, lastSampleAt, report]
+ );
+ useFrameCallback(probe, true);
+
+ const churning = rate > 0;
+ const verdict = useMemo(() => {
+ if (!driving) {
+ return "not re-rendering — turn the driver on";
+ }
+ return churning
+ ? `⚠️ ${rate} new frame-callback registrations / second`
+ : "✅ no churn: frame callbacks are stable across renders";
+ }, [churning, driving, rate]);
+
+ return (
+
+
+ {verdict}
+
+ renders {renders} · registered {registered} · highest id {maxId}
+
+
+
+
+ Re-render every frame
+
+
+
+ Mount <Canvas />
+
+
+
+ Pass onSize= to the Canvas
+
+
+
+ Mount reference churner (control)
+
+
+
+ {showChurner ? : null}
+
+ {mountCanvas ? (
+
+ ) : (
+
+ no canvas
+
+ )}
+
+
+ With the Canvas mounted and the tree re-rendering, the registration rate
+ should stay at 0/s. Flip on the reference churner to see what an
+ unmemoized frame callback looks like on this meter — the rate jumps to
+ roughly one registration per render, and its callbackId climbs by one
+ every frame.
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, padding: 16 },
+ banner: {
+ padding: 12,
+ borderRadius: 8,
+ backgroundColor: "#ecfdf5",
+ marginBottom: 12,
+ },
+ bannerBad: { backgroundColor: "#fee2e2" },
+ bannerText: { fontWeight: "700", marginBottom: 4 },
+ mono: { fontFamily: "Courier", fontSize: 12, color: "#374151" },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingVertical: 6,
+ },
+ label: { flex: 1, paddingRight: 12 },
+ canvas: { height: 120, marginTop: 12, borderRadius: 8, overflow: "hidden" },
+ placeholder: {
+ backgroundColor: "#e5e7eb",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ placeholderText: { color: "#6b7280" },
+ hint: { color: "#6b7280", fontSize: 12, marginTop: 12 },
+});
diff --git a/apps/example/src/Examples/API/KeyboardTapRepro.tsx b/apps/example/src/Examples/API/KeyboardTapRepro.tsx
new file mode 100644
index 0000000000..d63a9ac732
--- /dev/null
+++ b/apps/example/src/Examples/API/KeyboardTapRepro.tsx
@@ -0,0 +1,471 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { createNativeStackNavigator } from "@react-navigation/native-stack";
+import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import { useNavigation } from "@react-navigation/native";
+import { Canvas, Circle, Fill } from "@shopify/react-native-skia";
+import type { SkSize } from "@shopify/react-native-skia";
+import {
+ runOnJS,
+ useDerivedValue,
+ useFrameCallback,
+ useSharedValue,
+ withRepeat,
+ withTiming,
+} from "react-native-reanimated";
+
+/**
+ * Repro harness for https://github.com/Shopify/react-native-skia/issues/4006
+ *
+ * Reported symptom (iOS, Fabric, real device, after prolonged usage): the first
+ * tap on a Touchable next to a focused multiline TextInput is swallowed — the
+ * keyboard closes but Pressability never fires. The second tap works. The Skia
+ * canvas is on a *different* screen that was visited earlier in the session and
+ * is therefore still mounted (pushed under the current screen in a native
+ * stack), even though it is not visible.
+ *
+ * The harness is a nested 2-screen stack:
+ *
+ * 1. "Canvas" — holds the . Configure it, then push the chat
+ * screen. The canvas screen stays mounted underneath.
+ * 2. "Chat" — an input bar (multiline TextInput + send button) outside a
+ * ScrollView, i.e. the layout where a tap on the button should
+ * *always* reach onPress regardless of keyboard state.
+ *
+ * A tap is counted as LOST when the raw `onTouchStart` on the button's wrapper
+ * fires but the Touchable's `onPress` never does. `onTouchStart` is dispatched
+ * by ReactNativeBridgeEventPlugin to the target and its ancestors and is *not*
+ * subject to responder negotiation, so it still fires when the responder is
+ * stolen — which is exactly the failure mode described in the issue.
+ *
+ * The toggles let you A/B the suspects without reinstalling anything:
+ * - "onSize" — Canvas onSize={} moved to a Reanimated useFrameCallback in
+ * #3500 (v2.3.10). That runs measure() on the *main thread*
+ * every frame for as long as the Canvas is mounted, including
+ * while its screen is hidden.
+ * - "animated" — keeps the (hidden) canvas re-rendering/redrawing, which
+ * keeps [_layer nextDrawable] running on the main thread.
+ * - "canvas" — no Canvas at all: the control.
+ *
+ * The UI-thread frame meter at the top of the chat screen reports the worst
+ * frame delta seen; a main-thread stall shows up there as a spike.
+ */
+
+interface Config {
+ mountCanvas: boolean;
+ useOnSize: boolean;
+ animated: boolean;
+ persistTaps: boolean;
+}
+
+const ConfigContext = createContext<{
+ config: Config;
+ setConfig: (c: Config) => void;
+}>({
+ config: {
+ mountCanvas: true,
+ useOnSize: true,
+ animated: true,
+ persistTaps: false,
+ },
+ setConfig: () => {},
+});
+
+type ReproRoutes = {
+ KeyboardTapReproCanvas: undefined;
+ KeyboardTapReproChat: undefined;
+};
+
+const Stack = createNativeStackNavigator();
+
+// ---------------------------------------------------------------------------
+// Screen 1 — the canvas screen. Stays mounted under the chat screen.
+// ---------------------------------------------------------------------------
+
+const AnimatedCanvas = ({
+ useOnSize,
+ animated,
+}: {
+ useOnSize: boolean;
+ animated: boolean;
+}) => {
+ const size = useSharedValue({ width: 0, height: 0 });
+ const progress = useSharedValue(0);
+
+ useEffect(() => {
+ if (animated) {
+ progress.value = withRepeat(withTiming(1, { duration: 1500 }), -1, true);
+ } else {
+ progress.value = 0.5;
+ }
+ }, [animated, progress]);
+
+ const cx = useDerivedValue(() => 40 + progress.value * 200);
+ const r = useDerivedValue(() => 20 + progress.value * 20);
+
+ // Deliberately passing `onSize` conditionally: this is the code path added in
+ // #3500 that installs a per-frame main-thread measure() loop.
+ return (
+
+ );
+};
+
+const Row = ({
+ label,
+ value,
+ onValueChange,
+}: {
+ label: string;
+ value: boolean;
+ onValueChange: (v: boolean) => void;
+}) => (
+
+ {label}
+
+
+);
+
+const CanvasScreen = () => {
+ const { config, setConfig } = useContext(ConfigContext);
+ const navigation = useNavigation>();
+ return (
+
+ Step 1 — configure, then open the chat
+ {config.mountCanvas ? (
+
+ ) : (
+
+ no canvas (control)
+
+ )}
+ setConfig({ ...config, mountCanvas: v })}
+ />
+ setConfig({ ...config, useOnSize: v })}
+ />
+ setConfig({ ...config, animated: v })}
+ />
+ setConfig({ ...config, persistTaps: v })}
+ />
+ navigation.navigate("KeyboardTapReproChat")}
+ >
+ Open chat screen →
+
+
+ This screen stays mounted underneath the chat screen, exactly like the
+ setup described in issue #4006.
+
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Screen 2 — the chat screen. No canvas here.
+// ---------------------------------------------------------------------------
+
+interface Stats {
+ rawTouches: number;
+ pressIns: number;
+ presses: number;
+ cancels: number;
+ lastLostAt: string | null;
+}
+
+const initialStats: Stats = {
+ rawTouches: 0,
+ pressIns: 0,
+ presses: 0,
+ cancels: 0,
+ lastLostAt: null,
+};
+
+const ChatScreen = () => {
+ const { config } = useContext(ConfigContext);
+ const [stats, setStats] = useState(initialStats);
+ const [text, setText] = useState("");
+ const [messages, setMessages] = useState([]);
+ const [worstFrame, setWorstFrame] = useState(0);
+ const [worstJs, setWorstJs] = useState(0);
+
+ // A touch that started on the button but never produced an onPress.
+ const pendingTouch = useRef(false);
+
+ const onTouchStart = useCallback(() => {
+ pendingTouch.current = true;
+ setStats((s) => ({ ...s, rawTouches: s.rawTouches + 1 }));
+ // Give the responder system a couple of frames to deliver onPress.
+ setTimeout(() => {
+ if (pendingTouch.current) {
+ pendingTouch.current = false;
+ setStats((s) => ({
+ ...s,
+ lastLostAt: new Date().toISOString().slice(11, 23),
+ }));
+
+ console.warn("[#4006] LOST TAP — onTouchStart without onPress");
+ }
+ }, 400);
+ }, []);
+
+ const onTouchCancel = useCallback(() => {
+ setStats((s) => ({ ...s, cancels: s.cancels + 1 }));
+ }, []);
+
+ const onPressIn = useCallback(() => {
+ setStats((s) => ({ ...s, pressIns: s.pressIns + 1 }));
+ }, []);
+
+ const onPress = useCallback(() => {
+ pendingTouch.current = false;
+ setStats((s) => ({ ...s, presses: s.presses + 1 }));
+ setMessages((m) => [...m, text || `message ${m.length + 1}`]);
+ setText("");
+ }, [text]);
+
+ // --- UI (main) thread frame meter ---------------------------------------
+ const worst = useSharedValue(0);
+ const onFrame = useCallback(
+ (info: { timeSincePreviousFrame: number | null }) => {
+ "worklet";
+ const delta = info.timeSincePreviousFrame ?? 0;
+ if (delta > worst.value) {
+ worst.value = delta;
+ runOnJS(setWorstFrame)(Math.round(delta));
+ }
+ },
+ [worst]
+ );
+ useFrameCallback(onFrame, true);
+
+ // --- JS thread stall meter ----------------------------------------------
+ useEffect(() => {
+ let last = Date.now();
+ const id = setInterval(() => {
+ const now = Date.now();
+ const drift = now - last - 16;
+ last = now;
+ setWorstJs((w) => (drift > w ? Math.round(drift) : w));
+ }, 16);
+ return () => clearInterval(id);
+ }, []);
+
+ const lost = Math.max(0, stats.rawTouches - stats.presses);
+ const reset = useCallback(() => {
+ setStats(initialStats);
+ setWorstFrame(0);
+ setWorstJs(0);
+ worst.value = 0;
+ }, [worst]);
+
+ const summary = useMemo(
+ () =>
+ `touches ${stats.rawTouches} · pressIn ${stats.pressIns} · press ${stats.presses} · cancel ${stats.cancels}`,
+ [stats]
+ );
+
+ return (
+
+ 0 && styles.bannerBad]}>
+
+ {lost > 0 ? `⚠️ LOST TAPS: ${lost}` : "no lost taps yet"}
+
+ {summary}
+
+ worst UI frame {worstFrame}ms · worst JS tick {worstJs}ms
+
+ {stats.lastLostAt ? (
+ last lost at {stats.lastLostAt}
+ ) : null}
+
+ reset counters
+
+
+
+
+
+ Focus the input so the keyboard is up, then tap SEND. Repeat for a few
+ minutes — the issue only shows up after prolonged usage. Every tap
+ that starts on SEND but never fires onPress is counted above.
+
+ {messages.map((m, i) => (
+
+ {m}
+
+ ))}
+
+
+
+
+ {/* onTouchStart/onTouchCancel are dispatched outside of responder
+ negotiation, so they still fire when the tap is swallowed. */}
+
+
+ SEND
+
+
+
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+
+export const KeyboardTapRepro = () => {
+ const [config, setConfig] = useState({
+ mountCanvas: true,
+ useOnSize: true,
+ animated: true,
+ persistTaps: false,
+ });
+ const value = useMemo(() => ({ config, setConfig }), [config]);
+ return (
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ flex: { flex: 1 },
+ canvasScreen: { padding: 16 },
+ title: { fontSize: 16, fontWeight: "600", marginBottom: 12 },
+ canvas: { height: 120, borderRadius: 8, overflow: "hidden" },
+ canvasPlaceholder: {
+ backgroundColor: "#e5e7eb",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ placeholderText: { color: "#6b7280" },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingVertical: 8,
+ },
+ rowLabel: { flex: 1, paddingRight: 12 },
+ primaryButton: {
+ backgroundColor: "#2563eb",
+ borderRadius: 8,
+ paddingVertical: 14,
+ alignItems: "center",
+ marginTop: 12,
+ },
+ primaryButtonText: { color: "white", fontWeight: "600" },
+ hint: { color: "#6b7280", fontSize: 12, marginTop: 12 },
+ banner: { padding: 12, backgroundColor: "#ecfdf5" },
+ bannerBad: { backgroundColor: "#fee2e2" },
+ bannerText: { fontWeight: "700" },
+ bannerSub: { fontSize: 12, color: "#374151", marginTop: 2 },
+ resetText: { fontSize: 12, color: "#2563eb", marginTop: 6 },
+ messages: { padding: 12 },
+ bubble: {
+ alignSelf: "flex-end",
+ backgroundColor: "#2563eb",
+ borderRadius: 16,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ marginTop: 8,
+ },
+ bubbleText: { color: "white" },
+ inputBar: {
+ flexDirection: "row",
+ alignItems: "flex-end",
+ padding: 8,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: "#d1d5db",
+ },
+ input: {
+ flex: 1,
+ maxHeight: 120,
+ minHeight: 40,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: "#d1d5db",
+ borderRadius: 20,
+ paddingHorizontal: 14,
+ paddingTop: 10,
+ paddingBottom: 10,
+ marginRight: 8,
+ },
+ sendButton: {
+ backgroundColor: "#16a34a",
+ borderRadius: 20,
+ paddingHorizontal: 18,
+ height: 40,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ sendText: { color: "white", fontWeight: "700" },
+});
diff --git a/apps/example/src/Examples/API/List.tsx b/apps/example/src/Examples/API/List.tsx
index 305fa8f203..b4c658c65a 100644
--- a/apps/example/src/Examples/API/List.tsx
+++ b/apps/example/src/Examples/API/List.tsx
@@ -146,6 +146,14 @@ export const examples = [
screen: "PictureViewCrashTest",
title: "💥 PictureView Race Condition",
},
+ {
+ screen: "KeyboardTapRepro",
+ title: "⌨️ Lost Tap (#4006)",
+ },
+ {
+ screen: "FrameCallbackChurn",
+ title: "🔁 Frame Callback Churn",
+ },
{
screen: "FirstFrame",
title: "🎬 First Frame",
diff --git a/apps/example/src/Examples/API/Routes.ts b/apps/example/src/Examples/API/Routes.ts
index e69add788b..ac3cc37d30 100644
--- a/apps/example/src/Examples/API/Routes.ts
+++ b/apps/example/src/Examples/API/Routes.ts
@@ -35,6 +35,8 @@ export type Routes = {
StressTest3: undefined;
StressTest4: undefined;
PictureViewCrashTest: undefined;
+ KeyboardTapRepro: undefined;
+ FrameCallbackChurn: undefined;
FirstFrame: undefined;
FirstFrameEmpty: undefined;
PictureBug: undefined;
diff --git a/apps/example/src/Examples/API/index.tsx b/apps/example/src/Examples/API/index.tsx
index 6f10aa05f8..17874a08d1 100644
--- a/apps/example/src/Examples/API/index.tsx
+++ b/apps/example/src/Examples/API/index.tsx
@@ -38,6 +38,8 @@ import { StressTest2 } from "./StressTest2";
import { StressTest3 } from "./StressTest3";
import { StressTest4 } from "./StressTest4";
import { PictureViewCrashTest } from "./PictureViewCrashTest";
+import { KeyboardTapRepro } from "./KeyboardTapRepro";
+import { FrameCallbackChurn } from "./FrameCallbackChurn";
import { FirstFrame, FirstFrameEmpty } from "./FirstFrame";
import { ZIndexExample } from "./ZIndex";
import { PictureBug } from "./PictureBug";
@@ -305,6 +307,21 @@ export const API = () => {
title: "💥 PictureView Race Condition",
}}
/>
+
+
= {
StressTest3: "stress-test3",
StressTest4: "stress-test4",
PictureViewCrashTest: "picture-view-crash-test",
+ KeyboardTapRepro: "keyboard-tap-repro",
+ FrameCallbackChurn: "frame-callback-churn",
FirstFrame: "first-frame",
FirstFrameEmpty: "first-frame-empty",
PictureBug: "picture-bug",
diff --git a/packages/skia/src/renderer/Canvas.tsx b/packages/skia/src/renderer/Canvas.tsx
index 184f763ab5..f1f61dd307 100644
--- a/packages/skia/src/renderer/Canvas.tsx
+++ b/packages/skia/src/renderer/Canvas.tsx
@@ -109,7 +109,10 @@ export const Canvas = ({
// Root
const root = useMemo(() => new SkiaSGRoot(Skia, nativeId), [nativeId]);
- useReanimatedFrame(() => {
+ // The callback identity must be stable: Reanimated's useFrameCallback has
+ // deps [callback, autostart], so an inline worklet would unregister and
+ // re-register the frame callback on the UI thread on every single render.
+ const onFrame = useCallback(() => {
"worklet";
if (onSize && measure) {
const result =
@@ -131,7 +134,9 @@ export const Canvas = ({
}
}
}
- }, !!onSize);
+ }, [onSize, viewRef]);
+
+ useReanimatedFrame(onFrame, !!onSize);
// Render effects
useLayoutEffect(() => {