From ee903741d59f43e2fbc7c407f1d2b46be47a214f Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Sun, 18 Oct 2026 18:53:19 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(=F0=9F=90=9B):=20register=20ImageBitmap?= =?UTF-8?q?=20so=20it=20can=20cross=20to=20worklet=20runtimes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RNWebGPUManager::installConstructors installed every WebGPU class except ImageBitmap, so its brand was never registered with NativeObjectRegistry. Sending an ImageBitmap to a worklet runtime then threw "No native class registered for brand: ImageBitmap" while unboxing it, inside the Worklets serializer, which aborts the app. Installing its constructor registers the brand, and also makes `instanceof ImageBitmap` work on the main runtime and on runtimes that call installWebGPU(). The worklet serialization spec round-trips a decoded ImageBitmap through a worklet runtime and checks its size, its class there and that the same object comes back. --- .../src/components/workletSerialization.ts | 53 +++++++++++++++++++ .../webgpu/cpp/rnwgpu/RNWebGPUManager.cpp | 2 + .../__tests__/WorkletSerialization.spec.ts | 43 +++++++++++++++ packages/webgpu/src/__tests__/setup.ts | 7 +++ 4 files changed, 105 insertions(+) diff --git a/apps/example/src/components/workletSerialization.ts b/apps/example/src/components/workletSerialization.ts index e1073afed..2dbddda8e 100644 --- a/apps/example/src/components/workletSerialization.ts +++ b/apps/example/src/components/workletSerialization.ts @@ -29,6 +29,15 @@ export interface RoundTripResult { error?: string; } +export interface BitmapRoundTripResult { + width: number; + height: number; + isImageBitmap: boolean; + sameObjectBack: boolean; + // Set when the worklet threw, with the error message. + error?: string; +} + export interface CaptureResult { threw: boolean; message: string; @@ -112,6 +121,50 @@ export const workletSerialization = { ); }), + // Sends `bitmap` to a worklet runtime and back, as roundTripBuffer does + // for a buffer. Unboxing it there rebuilds it from its class brand, which + // only installConstructor registers. + roundTripImageBitmap: (bitmap: ImageBitmap): Promise => + new Promise((resolve) => { + const done = ( + onWorklet: { width: number; height: number; isImageBitmap: boolean }, + back: ImageBitmap, + ) => { + resolve({ ...onWorklet, sameObjectBack: back === bitmap }); + }; + const failed = (error: string) => { + resolve({ + width: -1, + height: -1, + isImageBitmap: false, + sameObjectBack: false, + error, + }); + }; + scheduleOnRuntime( + getRuntime(), + (image: ImageBitmap) => { + "worklet"; + try { + installWebGPU(); + // Read through globalThis: the worklets babel plugin would + // otherwise capture the main runtime's constructor by closure. + const ctor = globalThis.ImageBitmap; + const onWorklet = { + width: image.width, + height: image.height, + isImageBitmap: + typeof ctor === "function" && image instanceof ctor, + }; + scheduleOnRN(done, onWorklet, image); + } catch (e) { + scheduleOnRN(failed, String(e)); + } + }, + bitmap, + ); + }), + // Calls installWebGPU() on a worklet runtime nothing else was installed on // and reports the class constructors it finds there (see // ConstructorsResult). diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp index 1d01b968c..cb7fb1458 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp @@ -41,6 +41,7 @@ #include "GPUTextureView.h" #include "GPUUncapturedErrorEvent.h" #include "GPUValidationError.h" +#include "ImageBitmap.h" #include "VideoFrame.h" #include "VideoPlayer.h" @@ -144,6 +145,7 @@ void RNWebGPUManager::installConstructors(jsi::Runtime &runtime) { GPUSupportedLimits::installConstructor(runtime); GPUTexture::installConstructor(runtime); GPUTextureView::installConstructor(runtime); + ImageBitmap::installConstructor(runtime); VideoFrame::installConstructor(runtime); VideoPlayer::installConstructor(runtime); } diff --git a/packages/webgpu/src/__tests__/WorkletSerialization.spec.ts b/packages/webgpu/src/__tests__/WorkletSerialization.spec.ts index 19cab8e95..273ff2f30 100644 --- a/packages/webgpu/src/__tests__/WorkletSerialization.spec.ts +++ b/packages/webgpu/src/__tests__/WorkletSerialization.spec.ts @@ -1,6 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import fs from "fs"; +import path from "path"; + import { client } from "./setup"; +// A 32x32 PNG, decoded on the main runtime for the ImageBitmap round trip. +const assetPath = path.resolve(__dirname, "./assets/alpha-gradient.png"); + // WebGPU objects are plain JS objects with jsi::NativeState and a per-runtime // prototype, which worklets cannot copy by itself. registerWebGPUForReanimated // registers a custom serializer whose native half is the @@ -111,6 +117,43 @@ describe("Worklet serialization", () => { }); }); + it("round-trips an ImageBitmap through a worklet runtime", async () => { + // ImageBitmap was missing from RNWebGPUManager::installConstructors, so + // its brand was never registered and unboxing it on a worklet runtime + // threw "No native class registered for brand: ImageBitmap". + const pngData = Array.from(fs.readFileSync(assetPath)); + const result = await client.eval< + { pngData: number[] }, + { + width: number; + height: number; + isImageBitmap: boolean; + sameObjectBack: boolean; + error?: string; + } | null + >( + ({ workletSerialization, pngData: encodedPng }) => { + if (!workletSerialization) { + return null; + } + const bytes = new Uint8Array(encodedPng); + return createImageBitmap(bytes.buffer).then((bitmap) => + workletSerialization.roundTripImageBitmap(bitmap), + ); + }, + { pngData }, + ); + if (result === null) { + return; + } + expect(result).toEqual({ + width: 32, + height: 32, + isImageBitmap: true, + sameObjectBack: true, + }); + }); + it("installs every class constructor on a worklet runtime", async () => { // Issue #564: three.js checks `pass instanceof GPURenderBundleEncoder` on // every render. Unboxing installs only the classes of the objects that diff --git a/packages/webgpu/src/__tests__/setup.ts b/packages/webgpu/src/__tests__/setup.ts index c639e139f..af72fab3d 100644 --- a/packages/webgpu/src/__tests__/setup.ts +++ b/packages/webgpu/src/__tests__/setup.ts @@ -66,6 +66,13 @@ interface GPUTestingContext { sameObjectBack: boolean; error?: string; }>; + roundTripImageBitmap: (bitmap: ImageBitmap) => Promise<{ + width: number; + height: number; + isImageBitmap: boolean; + sameObjectBack: boolean; + error?: string; + }>; captureForeign: ( value: object, ) => Promise<{ threw: boolean; message: string; typeOnWorklet: string }>; From e52e2ccafee2702c6a5b6b8b3b97b6c1c3ea5e1a Mon Sep 17 00:00:00 2001 From: William Candillon Date: Sun, 18 Oct 2026 20:12:00 +0200 Subject: [PATCH 2/2] :wrench: --- .../src/components/workletSerialization.ts | 42 ++++++++++- packages/webgpu/cpp/jsi/NativeObject.h | 74 +++++++++++-------- .../webgpu/cpp/rnwgpu/RNWebGPUManager.cpp | 5 -- packages/webgpu/cpp/rnwgpu/api/VideoFrame.h | 4 +- .../__tests__/WorkletSerialization.spec.ts | 31 ++++++++ packages/webgpu/src/__tests__/setup.ts | 5 ++ 6 files changed, 120 insertions(+), 41 deletions(-) diff --git a/apps/example/src/components/workletSerialization.ts b/apps/example/src/components/workletSerialization.ts index 2dbddda8e..fee57b8ec 100644 --- a/apps/example/src/components/workletSerialization.ts +++ b/apps/example/src/components/workletSerialization.ts @@ -38,6 +38,14 @@ export interface BitmapRoundTripResult { error?: string; } +export interface CanvasRoundTripResult { + // The width the worklet read, before setting it to width + 1. + width: number; + sameObjectBack: boolean; + // Set when the worklet threw, with the error message. + error?: string; +} + export interface CaptureResult { threw: boolean; message: string; @@ -122,8 +130,7 @@ export const workletSerialization = { }), // Sends `bitmap` to a worklet runtime and back, as roundTripBuffer does - // for a buffer. Unboxing it there rebuilds it from its class brand, which - // only installConstructor registers. + // for a buffer. Unboxing it there rebuilds it from its class brand. roundTripImageBitmap: (bitmap: ImageBitmap): Promise => new Promise((resolve) => { const done = ( @@ -165,6 +172,37 @@ export const workletSerialization = { ); }), + // Sends a native Canvas (RNWebGPU.getNativeSurface(), GPUCanvasContext's + // `canvas`) to a worklet runtime and back. Canvas has no global constructor + // in installConstructors, so this checks that unboxing does not depend on + // that list. Sets `width` there, on the native object the main runtime + // also reads. + roundTripCanvas: (canvas: { + width: number; + }): Promise => + new Promise((resolve) => { + const done = (onWorklet: { width: number }, back: { width: number }) => { + resolve({ ...onWorklet, sameObjectBack: back === canvas }); + }; + const failed = (error: string) => { + resolve({ width: -1, sameObjectBack: false, error }); + }; + scheduleOnRuntime( + getRuntime(), + (c: { width: number }) => { + "worklet"; + try { + const onWorklet = { width: c.width }; + c.width = onWorklet.width + 1; + scheduleOnRN(done, onWorklet, c); + } catch (e) { + scheduleOnRN(failed, String(e)); + } + }, + canvas, + ); + }), + // Calls installWebGPU() on a worklet runtime nothing else was installed on // and reports the class constructors it finds there (see // ConstructorsResult). diff --git a/packages/webgpu/cpp/jsi/NativeObject.h b/packages/webgpu/cpp/jsi/NativeObject.h index da9c0811d..8648ed1dc 100644 --- a/packages/webgpu/cpp/jsi/NativeObject.h +++ b/packages/webgpu/cpp/jsi/NativeObject.h @@ -328,6 +328,11 @@ class NativeObject : public NativeObjectBase, return; // Already installed on this runtime } + // Every wrapper is made by create(), which installs the prototype first, + // so the class is registered before any of its objects can be boxed, + // whether or not its constructor is ever installed as a global. + registerReconstructor(); + // Create prototype object jsi::Object prototype(runtime); @@ -411,40 +416,10 @@ class NativeObject : public NativeObjectBase, * Idempotent per runtime: once the global exists there, later calls return * without touching it, so the constructor object keeps its identity. * - * Also registers this class with NativeObjectRegistry so that - * BoxedWebGPUObject::unbox() can rebuild its objects on secondary runtimes. + * Not needed for objects to cross runtimes: installPrototype() registers + * the class with NativeObjectRegistry. */ static void installConstructor(jsi::Runtime &runtime) { - // Register the reconstructor used by BoxedWebGPUObject::unbox() to - // rebuild this object (prototype + native state) on another runtime. - static std::once_flag registryFlag; - std::call_once(registryFlag, []() { - NativeObjectRegistry::getInstance().registerClass( - Derived::CLASS_NAME, - [](jsi::Runtime &rt, - std::shared_ptr state) -> jsi::Value { - auto instance = std::dynamic_pointer_cast(state); - if (instance == nullptr) { - throw jsi::JSError(rt, "Invalid boxed native object state"); - } - // The constructor makes `instanceof` work on this runtime too - // (a no-op once it is installed there). - Derived::installConstructor(rt); - // Unboxing hands back the wrapper cached for the target runtime, - // creating it on first use. Keep the runtime the object was - // originally created on: async native code (GPUDevice error/lost - // events, GPUAdapter::requestDevice) delivers into the creation - // runtime, and rebinding it to a worklet runtime would invoke - // main-runtime jsi::Functions on the wrong runtime and thread. - auto *originalRuntime = instance->getCreationRuntime(); - auto value = Derived::create(rt, instance); - if (originalRuntime != nullptr) { - instance->setCreationRuntime(originalRuntime); - } - return value; - }); - }); - installPrototype(runtime); // Once per runtime. installWebGPU() runs this at the top of every worklet @@ -481,6 +456,41 @@ class NativeObject : public NativeObjectBase, runtime.global().setProperty(runtime, Derived::CLASS_NAME, std::move(ctor)); } + /** + * Registers the reconstructor BoxedWebGPUObject::unbox() uses to rebuild + * this class's objects (prototype + native state) on another runtime. + * Once per process: the registry outlives every runtime. + */ + static void registerReconstructor() { + static std::once_flag registryFlag; + std::call_once(registryFlag, []() { + NativeObjectRegistry::getInstance().registerClass( + Derived::CLASS_NAME, + [](jsi::Runtime &rt, + std::shared_ptr state) -> jsi::Value { + auto instance = std::dynamic_pointer_cast(state); + if (instance == nullptr) { + throw jsi::JSError(rt, "Invalid boxed native object state"); + } + // The constructor makes `instanceof` work on this runtime too + // (a no-op once it is installed there). + Derived::installConstructor(rt); + // Unboxing hands back the wrapper cached for the target runtime, + // creating it on first use. Keep the runtime the object was + // originally created on: async native code (GPUDevice error/lost + // events, GPUAdapter::requestDevice) delivers into the creation + // runtime, and rebinding it to a worklet runtime would invoke + // main-runtime jsi::Functions on the wrong runtime and thread. + auto *originalRuntime = instance->getCreationRuntime(); + auto value = Derived::create(rt, instance); + if (originalRuntime != nullptr) { + instance->setCreationRuntime(originalRuntime); + } + return value; + }); + }); + } + /** * Returns the JS wrapper of `instance` on `runtime`, creating it on first * use. diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp index cb7fb1458..a55b6e816 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp @@ -79,11 +79,6 @@ RNWebGPUManager::RNWebGPUManager( std::make_shared(gpu, _platformContext, _jsCallInvoker); _gpu = gpu->get(); - // RNWebGPU needs its brand registered in NativeObjectRegistry so the boxing - // path can install the prototype on worklet runtimes. installConstructor - // does that registration but also sets globalThis.RNWebGPU = ctor, so we - // call it FIRST and then overwrite the global with the actual instance. - RNWebGPU::installConstructor(*_jsRuntime); _jsRuntime->global().setProperty(*_jsRuntime, "RNWebGPU", RNWebGPU::create(*_jsRuntime, rnWebGPU)); diff --git a/packages/webgpu/cpp/rnwgpu/api/VideoFrame.h b/packages/webgpu/cpp/rnwgpu/api/VideoFrame.h index 79cb9afc6..f1f40c887 100644 --- a/packages/webgpu/cpp/rnwgpu/api/VideoFrame.h +++ b/packages/webgpu/cpp/rnwgpu/api/VideoFrame.h @@ -21,8 +21,8 @@ namespace jsi = facebook::jsi; class VideoFrame : public NativeObject { public: // JS-facing brand. installConstructor() exposes this as - // globalThis[CLASS_NAME] and keys the NativeObjectRegistry / worklet - // serializer off it, so it must NOT be "VideoFrame": that would install our + // globalThis[CLASS_NAME], and the NativeObjectRegistry / worklet serializer + // are keyed off it, so it must NOT be "VideoFrame": that would install our // constructor over the WebCodecs `VideoFrame` global and shadow it at // runtime. "NativeVideoFrame" matches the public TypeScript type (see // src/types.ts) and keeps the two namespaces distinct. The C++ class stays diff --git a/packages/webgpu/src/__tests__/WorkletSerialization.spec.ts b/packages/webgpu/src/__tests__/WorkletSerialization.spec.ts index 273ff2f30..28fedc1c0 100644 --- a/packages/webgpu/src/__tests__/WorkletSerialization.spec.ts +++ b/packages/webgpu/src/__tests__/WorkletSerialization.spec.ts @@ -154,6 +154,37 @@ describe("Worklet serialization", () => { }); }); + it("round-trips a Canvas through a worklet runtime", async () => { + // Classes used to be registered for unboxing only by installConstructor, + // and Canvas, which has no global constructor, never was. The native + // surface of an unknown context is a 0x0 Canvas. + const result = await client.eval< + Record, + { + width: number; + sameObjectBack: boolean; + widthOnMain: number; + error?: string; + } | null + >(({ workletSerialization }) => { + if (!workletSerialization) { + return null; + } + const canvas = RNWebGPU.getNativeSurface(-1); + return workletSerialization + .roundTripCanvas(canvas) + .then((r) => ({ ...r, widthOnMain: canvas.width })); + }); + if (result === null) { + return; + } + expect(result).toEqual({ + width: 0, + sameObjectBack: true, + widthOnMain: 1, + }); + }); + it("installs every class constructor on a worklet runtime", async () => { // Issue #564: three.js checks `pass instanceof GPURenderBundleEncoder` on // every render. Unboxing installs only the classes of the objects that diff --git a/packages/webgpu/src/__tests__/setup.ts b/packages/webgpu/src/__tests__/setup.ts index af72fab3d..9e54054d0 100644 --- a/packages/webgpu/src/__tests__/setup.ts +++ b/packages/webgpu/src/__tests__/setup.ts @@ -73,6 +73,11 @@ interface GPUTestingContext { sameObjectBack: boolean; error?: string; }>; + roundTripCanvas: (canvas: { width: number }) => Promise<{ + width: number; + sameObjectBack: boolean; + error?: string; + }>; captureForeign: ( value: object, ) => Promise<{ threw: boolean; message: string; typeOnWorklet: string }>;