From 5e3e8d10cb2d75866e719355ed2f46634cf648b7 Mon Sep 17 00:00:00 2001 From: Pradyumna Shome Date: Sat, 11 Jul 2026 00:09:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(=F0=9F=8E=A8):=20add=20Canvas=20frame=20pr?= =?UTF-8?q?esentation=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the native presentation boundary so applications can avoid racing UI handoffs against frames that have only been recorded or submitted. --- apps/docs/docs/canvas/canvas.md | 65 ++++++++++++-- .../rnskia-android/OpenGLWindowContext.cpp | 6 +- .../cpp/rnskia-android/OpenGLWindowContext.h | 2 +- .../RNSkOpenGLCanvasProvider.cpp | 2 +- packages/skia/apple/MetalWindowContext.h | 2 +- packages/skia/apple/MetalWindowContext.mm | 17 +++- .../skia/apple/RNSkMetalCanvasProvider.mm | 2 +- packages/skia/cpp/jsi/ViewProperty.h | 52 ++++++++--- .../skia/cpp/rnskia/RNDawnWindowContext.cpp | 5 +- .../skia/cpp/rnskia/RNDawnWindowContext.h | 2 +- packages/skia/cpp/rnskia/RNSkJsiViewApi.h | 3 +- packages/skia/cpp/rnskia/RNSkPictureView.h | 5 +- packages/skia/cpp/rnskia/RNSkView.h | 70 ++++++++++++++- packages/skia/cpp/rnskia/RNWindowContext.h | 3 +- packages/skia/package.json | 2 + packages/skia/src/renderer/Canvas.tsx | 23 +++++ .../__tests__/CanvasPresentation.spec.tsx | 86 +++++++++++++++++++ yarn.lock | 2 + 18 files changed, 317 insertions(+), 32 deletions(-) create mode 100644 packages/skia/src/renderer/__tests__/CanvasPresentation.spec.tsx diff --git a/apps/docs/docs/canvas/canvas.md b/apps/docs/docs/canvas/canvas.md index 4b065188d1..73f164e748 100644 --- a/apps/docs/docs/canvas/canvas.md +++ b/apps/docs/docs/canvas/canvas.md @@ -9,13 +9,14 @@ The Canvas component is the root of your Skia drawing. You can treat it as a regular React Native view and assign a view style. Behind the scenes, it is using its own React renderer. -| Name | Type | Description | -|:-----|:---------|:-----------------| -| style? | `ViewStyle` | View style | -| ref? | `Ref` | Reference to the `SkiaView` object | -| onSize? | `SharedValue` | Reanimated value to which the canvas size will be assigned (see [canvas size](#canvas-size)) | -| highBitDepth? | `boolean` | Render into a surface with more than 8 bits per channel (see [high bit depth](#high-bit-depth)) | -| androidWarmup? | `boolean` | Draw the first frame directly on the Android compositor. Use it for static icons or fully opaque drawings—animated or translucent canvases can misrender, so it remains opt-in. | +| Name | Type | Description | +| :---------------- | :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| style? | `ViewStyle` | View style | +| ref? | `Ref` | Reference to the `SkiaView` object | +| onSize? | `SharedValue` | Reanimated value to which the canvas size will be assigned (see [canvas size](#canvas-size)) | +| onFramePresented? | `() => void` | Called on the JavaScript thread after each successful on-screen render crosses the platform presentation boundary (see [frame presentation](#frame-presentation)) | +| highBitDepth? | `boolean` | Render into a surface with more than 8 bits per channel (see [high bit depth](#high-bit-depth)) | +| androidWarmup? | `boolean` | Draw the first frame directly on the Android compositor. Use it for static icons or fully opaque drawings—animated or translucent canvases can misrender, so it remains opt-in. | ## Canvas size @@ -85,6 +86,56 @@ const Demo = () => { }; ``` +## Frame presentation + +`onFramePresented` runs once for every successfully rendered on-screen frame. +Unlike React commit or Skia recording callbacks, it runs after the backend has +reached its native presentation boundary. This makes it useful for removing a +placeholder, taking measurements, or starting a capture without racing the +frame that contains the pixels you are waiting for. + +```tsx twoslash +import { useState } from "react"; +import { Canvas, Circle } from "@shopify/react-native-skia"; + +const LoadingPlaceholder = () => null; + +const Demo = () => { + const [presented, setPresented] = useState(false); + return ( + <> + setPresented(true)} + > + + + {!presented && } + + ); +}; +``` + +The exact native boundary depends on the renderer: + +- iOS and tvOS Metal call the callback only when Metal reports a nonzero + `presentedTime`, meaning that the drawable was displayed on screen. +- Android OpenGL calls it after a successful `eglSwapBuffers`. At that point + the buffer has been handed to the system compositor; this does not prove + physical display scanout. +- Graphite calls it after WebGPU `Surface::Present()` returns. This confirms + presentation submission, not physical display scanout. +- macOS and Mac Catalyst call it after the Metal presentation command buffer + completes because `MTLDrawable.addPresentedHandler` is unavailable there. +- Web does not currently emit this callback. + +The callback is delivered asynchronously on the JavaScript thread, so later JS +work can delay it. Frames that fail before presentation (for example, because +no drawable or surface is available) do not emit a callback. Updating or +clearing the prop cancels native notifications that have not started; a +callback already queued to the JavaScript thread can still run once. Callback +exceptions are normal JavaScript exceptions and are not swallowed by the +renderer. ## High bit depth diff --git a/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.cpp b/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.cpp index 3916c0d67a..302b3ff915 100644 --- a/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.cpp +++ b/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.cpp @@ -51,10 +51,12 @@ sk_sp OpenGLWindowContext::getSurface() { return _skSurface; } -void OpenGLWindowContext::present() { +void OpenGLWindowContext::present(std::function onPresented) { _glContext->makeCurrent(_glSurface.get()); _directContext->flushAndSubmit(); - _glSurface->present(); + if (_glSurface->present() && onPresented) { + onPresented(); + } } } // namespace RNSkia diff --git a/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.h b/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.h index 14b228a32e..6799e419a1 100644 --- a/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.h +++ b/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.h @@ -49,7 +49,7 @@ class OpenGLWindowContext : public WindowContext { sk_sp getSurface() override; - void present() override; + void present(std::function onPresented = nullptr) override; int getWidth() override { return ANativeWindow_getWidth(_window); }; diff --git a/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp b/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp index 34d0fc75ef..57e5a731fd 100644 --- a/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp +++ b/packages/skia/android/cpp/rnskia-android/RNSkOpenGLCanvasProvider.cpp @@ -66,7 +66,7 @@ bool RNSkOpenGLCanvasProvider::renderToCanvas( // Draw into canvas using callback cb(surface->getCanvas()); // Swap buffers and show on screen - _surfaceHolder->present(); + _surfaceHolder->present(makeFramePresentedNotifier()); return true; } else { // the render context did not provide a surface diff --git a/packages/skia/apple/MetalWindowContext.h b/packages/skia/apple/MetalWindowContext.h index 4fa3fd2d3f..572d0f4e9b 100644 --- a/packages/skia/apple/MetalWindowContext.h +++ b/packages/skia/apple/MetalWindowContext.h @@ -16,7 +16,7 @@ class MetalWindowContext : public RNSkia::WindowContext { sk_sp getSurface() override; - void present() override; + void present(std::function onPresented = nullptr) override; int getWidth() override { return _layer.frame.size.width * _layer.contentsScale; diff --git a/packages/skia/apple/MetalWindowContext.mm b/packages/skia/apple/MetalWindowContext.mm index b93d4c37a3..11b65ec6e5 100644 --- a/packages/skia/apple/MetalWindowContext.mm +++ b/packages/skia/apple/MetalWindowContext.mm @@ -83,12 +83,27 @@ return _skSurface; } -void MetalWindowContext::present() { +void MetalWindowContext::present(std::function onPresented) { if (auto dContext = GrAsDirectContext(_skSurface->recordingContext())) { dContext->flushAndSubmit(); } id commandBuffer([_commandQueue commandBuffer]); +#if TARGET_OS_OSX || TARGET_OS_MACCATALYST + if (onPresented) { + [commandBuffer addCompletedHandler:^(__unused id buffer) { + onPresented(); + }]; + } +#else + if (onPresented) { + [_currentDrawable addPresentedHandler:^(id drawable) { + if (drawable.presentedTime > 0) { + onPresented(); + } + }]; + } +#endif [commandBuffer presentDrawable:_currentDrawable]; [commandBuffer commit]; _skSurface = nullptr; diff --git a/packages/skia/apple/RNSkMetalCanvasProvider.mm b/packages/skia/apple/RNSkMetalCanvasProvider.mm index 90ad9e64fc..b5224c0680 100644 --- a/packages/skia/apple/RNSkMetalCanvasProvider.mm +++ b/packages/skia/apple/RNSkMetalCanvasProvider.mm @@ -84,7 +84,7 @@ } auto canvas = surface->getCanvas(); cb(canvas); - _ctx->present(); + _ctx->present(makeFramePresentedNotifier()); return true; } return false; diff --git a/packages/skia/cpp/jsi/ViewProperty.h b/packages/skia/cpp/jsi/ViewProperty.h index a361086cfd..bf1addbe2b 100644 --- a/packages/skia/cpp/jsi/ViewProperty.h +++ b/packages/skia/cpp/jsi/ViewProperty.h @@ -5,10 +5,11 @@ #include #include #include +#include #include -#include "api/JsiSkPicture.h" #include "RuntimeLifecycleMonitor.h" +#include "api/JsiSkPicture.h" namespace RNJsi { namespace jsi = facebook::jsi; @@ -38,6 +39,43 @@ class RuntimeAwareRuntimeGuard : public RuntimeLifecycleListener { class ViewProperty { public: ViewProperty(jsi::Runtime &runtime, const jsi::Value &value) { + setObjectValue(runtime, value); + } + + template + ViewProperty(jsi::Runtime &runtime, const jsi::Value &value, + PlatformContext platformContext, size_t) { + if (value.isObject()) { + auto object = value.asObject(runtime); + if (object.isFunction(runtime)) { + auto runtimeGuard = std::make_shared(runtime); + auto function = + std::make_shared(object.getFunction(runtime)); + _value = [runtimeGuard, function, + platformContext = std::move(platformContext)]() { + platformContext->runOnJavascriptThread([runtimeGuard, function]() { + auto runtime = runtimeGuard->getRuntime(); + if (runtime != nullptr) { + function->call(*runtime); + } + }); + }; + return; + } + } + setObjectValue(runtime, value); + } + + bool isNull() { return std::holds_alternative(_value); } + + sk_sp getPicture() { return std::get>(_value); } + + std::function getFunction() { + return std::get>(_value); + } + +private: + void setObjectValue(jsi::Runtime &runtime, const jsi::Value &value) { if (value.isObject()) { auto object = value.asObject(runtime); if (object.isHostObject(runtime)) { @@ -51,15 +89,7 @@ class ViewProperty { } } - template - ViewProperty(jsi::Runtime &runtime, const jsi::Value &value, - PlatformContext platformContext, size_t nativeId) {} - - bool isNull() { return std::holds_alternative(_value); } - - sk_sp getPicture() { return std::get>(_value); } - -private: - std::variant> _value = nullptr; + std::variant, std::function> _value = + nullptr; }; } // namespace RNJsi diff --git a/packages/skia/cpp/rnskia/RNDawnWindowContext.cpp b/packages/skia/cpp/rnskia/RNDawnWindowContext.cpp index dd9fb45cd5..b05d06b0dc 100644 --- a/packages/skia/cpp/rnskia/RNDawnWindowContext.cpp +++ b/packages/skia/cpp/rnskia/RNDawnWindowContext.cpp @@ -4,7 +4,7 @@ namespace RNSkia { -void DawnWindowContext::present() { +void DawnWindowContext::present(std::function onPresented) { auto recording = _recorder->snap(); if (!recording) { throw std::runtime_error("Failed to create graphite recording"); @@ -14,6 +14,9 @@ void DawnWindowContext::present() { dawn::native::metal::WaitForCommandsToBeScheduled(_device.Get()); #endif _surface.Present(); + if (onPresented) { + onPresented(); + } } } // namespace RNSkia diff --git a/packages/skia/cpp/rnskia/RNDawnWindowContext.h b/packages/skia/cpp/rnskia/RNDawnWindowContext.h index a73ca0fa81..fff5c24c70 100644 --- a/packages/skia/cpp/rnskia/RNDawnWindowContext.h +++ b/packages/skia/cpp/rnskia/RNDawnWindowContext.h @@ -62,7 +62,7 @@ class DawnWindowContext : public WindowContext { return surface; } - void present() override; + void present(std::function onPresented = nullptr) override; void resize(int width, int height) override { _width = width; diff --git a/packages/skia/cpp/rnskia/RNSkJsiViewApi.h b/packages/skia/cpp/rnskia/RNSkJsiViewApi.h index b48bd686b3..c315390c9a 100644 --- a/packages/skia/cpp/rnskia/RNSkJsiViewApi.h +++ b/packages/skia/cpp/rnskia/RNSkJsiViewApi.h @@ -129,7 +129,8 @@ class RNSkJsiViewApi : public RNJsi::JsiHostObject, auto name = arguments[1].asString(runtime).utf8(runtime); info->props.insert_or_assign( arguments[1].asString(runtime).utf8(runtime), - RNJsi::ViewProperty(runtime, arguments[2])); + RNJsi::ViewProperty(runtime, arguments[2], _platformContext, + nativeId)); // Now let's see if we have a view that we can update if (info->view != nullptr) { // Update view! diff --git a/packages/skia/cpp/rnskia/RNSkPictureView.h b/packages/skia/cpp/rnskia/RNSkPictureView.h index 8cc91b690b..0d290b670b 100644 --- a/packages/skia/cpp/rnskia/RNSkPictureView.h +++ b/packages/skia/cpp/rnskia/RNSkPictureView.h @@ -13,9 +13,9 @@ #include "RNSkView.h" #include "jsi/ViewProperty.h" +#include "RNSkPlatformContext.h" #include "api/JsiSkPicture.h" #include "utils/RNSkLog.h" -#include "RNSkPlatformContext.h" #include "utils/RNSkTimingInfo.h" #pragma clang diagnostic push @@ -105,6 +105,9 @@ class RNSkPictureView : public RNSkView { // Save picture std::static_pointer_cast(getRenderer()) ->setPicture(prop.second.getPicture()); + } else if (prop.first == "onFramePresented") { + setOnFramePresented(prop.second.isNull() ? nullptr + : prop.second.getFunction()); } } } diff --git a/packages/skia/cpp/rnskia/RNSkView.h b/packages/skia/cpp/rnskia/RNSkView.h index ba92d75f1d..bb4bf1db6a 100644 --- a/packages/skia/cpp/rnskia/RNSkView.h +++ b/packages/skia/cpp/rnskia/RNSkView.h @@ -1,7 +1,10 @@ #pragma once +#include +#include #include +#include #include #include #include @@ -25,6 +28,47 @@ namespace RNSkia { namespace jsi = facebook::jsi; +class RNSkFramePresentationObserver + : public std::enable_shared_from_this { +public: + void setCallback(std::function callback) { + std::lock_guard lock(_mutex); + _callback = std::move(callback); + _generation++; + } + + std::function makeNotifier() { + std::lock_guard lock(_mutex); + if (!_callback) { + return nullptr; + } + auto generation = _generation; + auto weakThis = weak_from_this(); + return [weakThis, generation]() { + auto observer = weakThis.lock(); + if (!observer) { + return; + } + std::function callback; + { + std::lock_guard lock(observer->_mutex); + if (observer->_generation != generation) { + return; + } + callback = observer->_callback; + } + if (callback) { + callback(); + } + }; + } + +private: + std::mutex _mutex; + std::function _callback = nullptr; + uint64_t _generation = 0; +}; + class RNSkCanvasProvider { public: explicit RNSkCanvasProvider(std::function requestRedraw) @@ -45,8 +89,21 @@ class RNSkCanvasProvider { */ virtual bool renderToCanvas(const std::function &) = 0; + void setFramePresentationObserver( + std::weak_ptr observer) { + _framePresentationObserver = std::move(observer); + } + protected: + std::function makeFramePresentedNotifier() { + auto observer = _framePresentationObserver.lock(); + return observer ? observer->makeNotifier() : nullptr; + } + std::function _requestRedraw; + +private: + std::weak_ptr _framePresentationObserver; }; class RNSkRenderer { @@ -141,12 +198,16 @@ class RNSkView : public std::enable_shared_from_this { std::shared_ptr canvasProvider, std::shared_ptr renderer) : _platformContext(context), _canvasProvider(canvasProvider), - _renderer(renderer) {} + _renderer(renderer), + _framePresentationObserver( + std::make_shared()) { + _canvasProvider->setFramePresentationObserver(_framePresentationObserver); + } /** Destructor */ - virtual ~RNSkView() {} + virtual ~RNSkView() { _framePresentationObserver->setCallback(nullptr); } virtual void setJsiProperties( std::unordered_map &props) = 0; @@ -175,6 +236,10 @@ class RNSkView : public std::enable_shared_from_this { _redrawRequested = false; } + void setOnFramePresented(std::function callback) { + _framePresentationObserver->setCallback(std::move(callback)); + } + /** Sets the native id of the view */ @@ -230,6 +295,7 @@ class RNSkView : public std::enable_shared_from_this { std::shared_ptr _platformContext; std::shared_ptr _canvasProvider; std::shared_ptr _renderer; + std::shared_ptr _framePresentationObserver; size_t _nativeId; diff --git a/packages/skia/cpp/rnskia/RNWindowContext.h b/packages/skia/cpp/rnskia/RNWindowContext.h index 6fe778fccf..a2a7012dda 100644 --- a/packages/skia/cpp/rnskia/RNWindowContext.h +++ b/packages/skia/cpp/rnskia/RNWindowContext.h @@ -1,5 +1,6 @@ #pragma once +#include #include #pragma clang diagnostic push @@ -16,7 +17,7 @@ class WindowContext { public: virtual ~WindowContext() = default; virtual sk_sp getSurface() = 0; - virtual void present() = 0; + virtual void present(std::function onPresented = nullptr) = 0; virtual void resize(int width, int height) = 0; virtual int getWidth() = 0; virtual int getHeight() = 0; diff --git a/packages/skia/package.json b/packages/skia/package.json index 4faa5baa4c..939f75e38c 100644 --- a/packages/skia/package.json +++ b/packages/skia/package.json @@ -105,6 +105,7 @@ "@types/pngjs": "6.0.1", "@types/react": "^19.0.0", "@types/react-reconciler": "0.28.9", + "@types/react-test-renderer": "^19.0.0", "@types/ws": "8.5.3", "eslint": "9.36.0", "eslint-config-react-native-wcandillon": "4.0.1", @@ -120,6 +121,7 @@ "react-native-builder-bob": "0.18.2", "react-native-reanimated": "4.3.1", "react-native-worklets": "0.8.3", + "react-test-renderer": "19.0.0", "rimraf": "3.0.2", "semantic-release": "^24.1.0", "semantic-release-yarn": "^3.0.2", diff --git a/packages/skia/src/renderer/Canvas.tsx b/packages/skia/src/renderer/Canvas.tsx index 184f763ab5..c540885748 100644 --- a/packages/skia/src/renderer/Canvas.tsx +++ b/packages/skia/src/renderer/Canvas.tsx @@ -18,6 +18,7 @@ import type { import type { SharedValue } from "react-native-reanimated"; import Rea from "../external/reanimated/ReanimatedProxy"; +import { SkiaViewApi } from "../views/api"; import { SkiaViewNativeId } from "../views/SkiaViewNativeId"; import SkiaPictureViewNativeComponent from "../specs/SkiaPictureViewNativeComponent"; import type { SkImage, SkRect, SkSize } from "../skia/types"; @@ -68,6 +69,16 @@ export interface CanvasProps extends Omit { onLayout?: ViewProps["onLayout"]; opaque?: boolean; onSize?: SharedValue; + /** + * Called after each successfully rendered frame crosses the platform + * presentation boundary. The callback runs on the JavaScript thread. + * + * On iOS and tvOS with the Metal backend, this means the drawable was + * displayed on screen. Android OpenGL, Graphite, macOS, and Mac Catalyst + * report the closest native boundary available; see the Canvas documentation + * for the exact guarantees. + */ + onFramePresented?: () => void; colorSpace?: "p3" | "srgb"; /** * Renders into a surface with more than 8 bits per channel (16-bit float on @@ -88,6 +99,7 @@ export const Canvas = ({ opaque, children, onSize, + onFramePresented, colorSpace = "p3", highBitDepth = false, androidWarmup = false, @@ -109,6 +121,17 @@ export const Canvas = ({ // Root const root = useMemo(() => new SkiaSGRoot(Skia, nativeId), [nativeId]); + useLayoutEffect(() => { + SkiaViewApi?.setJsiProperty( + nativeId, + "onFramePresented", + onFramePresented ?? null + ); + return () => { + SkiaViewApi?.setJsiProperty(nativeId, "onFramePresented", null); + }; + }, [nativeId, onFramePresented]); + useReanimatedFrame(() => { "worklet"; if (onSize && measure) { diff --git a/packages/skia/src/renderer/__tests__/CanvasPresentation.spec.tsx b/packages/skia/src/renderer/__tests__/CanvasPresentation.spec.tsx new file mode 100644 index 0000000000..1c01ff2ca0 --- /dev/null +++ b/packages/skia/src/renderer/__tests__/CanvasPresentation.spec.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import TestRenderer, { act } from "react-test-renderer"; + +jest.mock("../../external", () => ({ + HAS_REANIMATED_3: false, +})); + +jest.mock("../../external/reanimated/ReanimatedProxy", () => ({ + __esModule: true, + default: {}, +})); + +const render = jest.fn(); +const unmount = jest.fn(); +jest.mock("../../sksg/Reconciler", () => ({ + SkiaSGRoot: jest.fn().mockImplementation(() => ({ + render, + unmount, + })), +})); + +jest.mock("../../skia", () => ({ + Skia: {}, +})); + +jest.mock("../../Platform", () => ({ + Platform: { OS: "ios" }, +})); + +jest.mock("../../specs/SkiaPictureViewNativeComponent", () => ({ + __esModule: true, + default: () => null, +})); + +describe("Canvas frame presentation callback", () => { + beforeEach(() => { + jest.clearAllMocks(); + ( + global as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + it("registers, updates, and clears the callback", () => { + const setJsiProperty = jest.fn(); + global.SkiaViewApi = { + setJsiProperty, + requestRedraw: jest.fn(), + makeImageSnapshot: jest.fn(), + makeImageSnapshotAsync: jest.fn(), + size: jest.fn(), + }; + const { Canvas } = require("../Canvas"); + const first = jest.fn(); + const second = jest.fn(); + let canvas!: TestRenderer.ReactTestRenderer; + + act(() => { + canvas = TestRenderer.create(); + }); + + const nativeId = setJsiProperty.mock.calls[0][0] as number; + expect(setJsiProperty).toHaveBeenCalledWith( + nativeId, + "onFramePresented", + first + ); + + act(() => { + canvas.update(); + }); + expect(setJsiProperty).toHaveBeenLastCalledWith( + nativeId, + "onFramePresented", + second + ); + + act(() => { + canvas.unmount(); + }); + expect(setJsiProperty).toHaveBeenLastCalledWith( + nativeId, + "onFramePresented", + null + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index 9cfaa93b37..ad9764166e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9691,6 +9691,7 @@ __metadata: "@types/pngjs": 6.0.1 "@types/react": ^19.0.0 "@types/react-reconciler": 0.28.9 + "@types/react-test-renderer": ^19.0.0 "@types/ws": 8.5.3 canvaskit-wasm: 0.41.0 eslint: 9.36.0 @@ -9712,6 +9713,7 @@ __metadata: react-native-skia-apple-tvos: 150.0.0 react-native-worklets: 0.8.3 react-reconciler: 0.31.0 + react-test-renderer: 19.0.0 rimraf: 3.0.2 semantic-release: ^24.1.0 semantic-release-yarn: ^3.0.2