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(() => {