-
-
Notifications
You must be signed in to change notification settings - Fork 144
feat: structuredClone global (HTML structured clone, ArrayBuffer transfer) #2000
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # structuredClone | ||
|
|
||
| The runtime exposes the WHATWG [`structuredClone(value, options)`](https://html.spec.whatwg.org/multipage/structured-data.html#dom-structuredclone) global. It performs a deep, structure-preserving copy of `value` using V8's structured clone serializer — the same one worker `postMessage` uses — optionally taking ownership of `ArrayBuffer`s named in `options.transfer`. | ||
|
|
||
| ```js | ||
| const clone = structuredClone({ when: new Date(), tags: new Set(["a"]) }); | ||
|
|
||
| const buffer = new ArrayBuffer(1024); | ||
| const moved = structuredClone(buffer, { transfer: [buffer] }); | ||
| buffer.byteLength; // 0 — the memory now belongs to `moved` | ||
| ``` | ||
|
|
||
| ## Surface | ||
|
|
||
| `structuredClone(value)` returns a clone of `value`. `structuredClone(value, { transfer })` additionally transfers every `ArrayBuffer` in `transfer`. | ||
|
|
||
| - `value` is required; calling with no arguments throws a `TypeError`. | ||
| - `options` may be `undefined` or `null` (both mean "no transfer"); anything else must be an object, or a `TypeError` is thrown. | ||
| - `options.transfer` is a WebIDL sequence: any object with a callable `Symbol.iterator` works (an array, a `Set`, a generator). A non-iterable value — including a string primitive — throws a `TypeError`. | ||
|
|
||
| Cloneable: every primitive value except symbols — numbers (including `-0`, `NaN` and the infinities), strings, booleans, `BigInt`, `null` and `undefined`; plain objects and arrays; `Date`, `RegExp`, `Map`, `Set`, `Error`; `Boolean`/`String`/`Number` wrapper objects; `ArrayBuffer`, every typed array and `DataView`. | ||
|
|
||
| The clone preserves the shape of the graph, not just the values: an object referenced twice in the input is a single object referenced twice in the output, and cycles round-trip. Prototypes do not survive — a class instance clones to a plain object with the same own properties. Getters are invoked during cloning and their result is stored as a plain data property. Property insertion order is preserved. | ||
|
|
||
| `SharedArrayBuffer` is **shared, not copied**: the clone is a second `SharedArrayBuffer` over the same memory, so writes through either are visible through the other. | ||
|
|
||
| Not cloneable — each throws (see the deviations below): functions, symbols, `WeakMap`/`WeakSet`/`WeakRef`, `Promise`, and every native/interop object (Java proxies and the objects the metadata layer hands out), which have no serialized form. | ||
|
|
||
| ## Transfer semantics | ||
|
|
||
| Listed buffers are validated before anything is serialized: each entry must be an `ArrayBuffer`, must not already be detached, must be detachable, and must appear at most once. A violation throws before the source buffers are touched, so a rejected call never leaves a half-transferred graph behind. | ||
|
|
||
| On success the memory changes hands rather than being copied: the source buffer is detached (`byteLength` becomes 0, and every typed array over it becomes zero-length) and the clone receives the original backing store. A transferred buffer need not appear inside `value` at all; a buffer reached through a typed array in `value` is transferred as a unit, so the cloned view sees the original bytes. | ||
|
|
||
| ## Worker `postMessage` | ||
|
|
||
| `structuredClone` and worker `postMessage` run on the same serialization core, so everything above — which types clone, graph identity, cycles, `SharedArrayBuffer` sharing — holds for messages too. `postMessage` takes the same transfer list as a second argument: | ||
|
|
||
| ```js | ||
| worker.postMessage({ pixels: buffer }, [buffer]); // buffer is detached here, | ||
| // its memory now in the worker | ||
| ``` | ||
|
|
||
| Two differences are intentional: | ||
|
|
||
| - **The transfer list must be an array.** Omitting it, or passing `undefined` or `null`, means "transfer nothing"; every other non-array value is a `TypeError`. The WebIDL iterable-to-sequence conversion that lets `structuredClone` take a `Set` or any iterable lives in the JavaScript wrapper around `structuredClone`; `postMessage` is native all the way down and has no such wrapper. | ||
| - **Host objects degrade instead of throwing.** Posting a native/interop object delivers an empty object to the receiver rather than raising a `DataCloneError`. This is long-standing shipped behavior, and app code relies on it; `structuredClone`, being new, follows the spec and rejects. The asymmetry is encoded in exactly one place — the `HostObjectPolicy` enum in `test-app/runtime/src/main/cpp/StructuredSerialization.h` — and unifying the two on rejection is a breaking change that needs the iOS runtime to move at the same time. | ||
|
|
||
| ## Deviations from the specification | ||
|
|
||
| - **`DataCloneError` is an `Error`, not a `DOMException`.** This runtime has no `DOMException`, so failures throw an `Error` whose `name` is set to `"DataCloneError"`. Detect failures with `e.name === "DataCloneError"`; `instanceof DOMException` cannot work. | ||
| - **Only `ArrayBuffer` is transferable.** The spec's other transferable types — `MessagePort`, `ImageBitmap`, `ReadableStream` and friends — do not exist here. A non-`ArrayBuffer` in the transfer list is a `DataCloneError`. | ||
| - **Host objects are not cloneable by `structuredClone`.** The spec leaves platform objects to each host; here every native/interop wrapper is rejected with a `DataCloneError`, because a JavaScript copy detached from its native counterpart would be a wrapper around nothing. Worker `postMessage` deliberately differs — see above. | ||
|
|
||
| `SharedArrayBuffer` follows the spec: it is shared rather than copied, and it is not transferable (listing one throws a `DataCloneError`). | ||
| +857 −0 | Performance/index.js | |
| +741 −0 | StructuredClone/index.js | |
| +10 −0 | index.js |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1222,12 +1222,18 @@ CallbackHandlers::WorkerObjectPostMessageCallback(const v8::FunctionCallbackInfo | |
| HandleScope scope(isolate); | ||
|
|
||
| try { | ||
| if (args.Length() != 1) { | ||
| if (args.Length() < 1) { | ||
| isolate->ThrowException(ArgConverter::ConvertToV8String(isolate, | ||
| "Failed to execute 'postMessage' on 'Worker': 1 argument required.")); | ||
| return; | ||
| } | ||
|
|
||
| if (args.Length() > 2) { | ||
| isolate->ThrowException(ArgConverter::ConvertToV8String(isolate, | ||
| "Failed to execute 'postMessage' on 'Worker': no more than 2 arguments accepted.")); | ||
| return; | ||
| } | ||
|
|
||
| auto thiz = args.This(); // Worker instance | ||
|
|
||
| Local<Value> jsId; | ||
|
|
@@ -1249,9 +1255,15 @@ CallbackHandlers::WorkerObjectPostMessageCallback(const v8::FunctionCallbackInfo | |
| return; | ||
| } | ||
|
|
||
| Local<Value> transferList = args.Length() > 1 | ||
| ? args[1] | ||
| : v8::Undefined(isolate).As<Value>(); | ||
| auto message = std::make_shared<worker::Message>(); | ||
| if (message->Serialize(isolate, context, args[0]).IsNothing()) { | ||
| // a DataCloneError is already pending on the isolate | ||
| if (message->Serialize(isolate, context, args[0], transferList, | ||
| serialization::HostObjectPolicy::kDegrade) | ||
| .IsNothing()) { | ||
| // The transfer list was rejected or the value could not be cloned; | ||
| // the exception is already pending and nothing may be posted. | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -1283,9 +1295,12 @@ CallbackHandlers::WorkerGlobalPostMessageCallback(const v8::FunctionCallbackInfo | |
| TryCatch tc(isolate); | ||
|
|
||
| // TODO: Pete: Discuss whether this is the way to go | ||
| if (args.Length() != 1) { | ||
| if (args.Length() < 1) { | ||
| isolate->ThrowException(ArgConverter::ConvertToV8String(isolate, | ||
| "Failed to execute 'postMessage' on WorkerGlobalScope: 1 argument required.")); | ||
| } else if (args.Length() > 2) { | ||
| isolate->ThrowException(ArgConverter::ConvertToV8String(isolate, | ||
| "Failed to execute 'postMessage' on WorkerGlobalScope: no more than 2 arguments accepted.")); | ||
| } | ||
|
|
||
| if (tc.HasCaught()) { | ||
|
|
@@ -1301,9 +1316,15 @@ CallbackHandlers::WorkerGlobalPostMessageCallback(const v8::FunctionCallbackInfo | |
| } | ||
|
|
||
| auto context = isolate->GetCurrentContext(); | ||
| Local<Value> transferList = args.Length() > 1 | ||
| ? args[1] | ||
| : v8::Undefined(isolate).As<Value>(); | ||
| auto message = std::make_shared<worker::Message>(); | ||
| if (message->Serialize(isolate, context, args[0]).IsNothing()) { | ||
| // a DataCloneError is already pending on the isolate | ||
| if (message->Serialize(isolate, context, args[0], transferList, | ||
| serialization::HostObjectPolicy::kDegrade) | ||
| .IsNothing()) { | ||
| // The transfer list was rejected or the value could not be cloned; | ||
| // the exception is already pending and nothing may be posted. | ||
| return; | ||
|
Comment on lines
+1323
to
1328
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect how worker callback errors are dispatched and whether worker clone
# failures have regression coverage.
rg -n -C 8 '\bWorkerGlobalPostMessageCallback\b|\bCallWorkerScopeOnErrorHandle\b' \
test-app/runtime/src/main/cpp/CallbackHandlers.cpp
rg -n -C 5 'DataCloneError|structuredClone|postMessage\s*\(' \
test-app/app/src/main/assets/appRepository: NativeScript/android Length of output: 10333 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the complete callback, error-dispatch helper, and Message::Serialize
# implementation without executing repository code.
rg -n -l 'CallWorkerScopeOnErrorHandle|class Message|Message::Serialize|\.Serialize\(' \
test-app/runtime/src/main/cpp
printf '\n--- callback and error helper ---\n'
sed -n '1288,1535p' test-app/runtime/src/main/cpp/CallbackHandlers.cpp
printf '\n--- serialization declarations and definitions ---\n'
rg -n -C 12 'Serialize\s*\(|class Message|struct Message' \
test-app/runtime/src/main/cpp test-app/runtime/src/mainRepository: NativeScript/android Length of output: 40340 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- complete worker error helper ---'
sed -n '1504,1585p' test-app/runtime/src/main/cpp/CallbackHandlers.cpp
printf '%s\n' '--- serialization failure paths ---'
sed -n '1,230p' test-app/runtime/src/main/cpp/StructuredSerialization.cpp
sed -n '230,430p' test-app/runtime/src/main/cpp/StructuredSerialization.cpp
printf '%s\n' '--- related postMessage callback and tests ---'
sed -n '1210,1290p' test-app/runtime/src/main/cpp/CallbackHandlers.cpp
rg -n -C 10 'postMessage|DataCloneError|transfer list|structured clone|clone' \
test-app/app/src/main/assets/app/tests test-app/runtime/src/main/cpp \
-g '*.js' -g '*.cpp' -g '*.h'Repository: NativeScript/android Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- worker execution and exception forwarding ---'
rg -n -C 12 'PassUncaughtExceptionFromWorkerToParent|TryCatch|Execute|RunScript|Script::Run|onerror' \
test-app/runtime/src/main/cpp \
-g '*.cpp' -g '*.h' \
| grep -v 'robin_hood.h' | head -n 500
printf '%s\n' '--- all local TryCatch rethrow patterns ---'
rg -n -C 5 'TryCatch|ReThrow|IsNothing\(\).*return|IsNothing\(\)' \
test-app/runtime/src/main/cpp \
-g '*.cpp' -g '*.h' | grep -v 'robin_hood.h' | head -n 500
printf '%s\n' '--- relevant test names and worker error assertions ---'
rg -n -C 8 'worker\.onerror|onerror\s*=|DataCloneError|postMessage.*catch|catch.*postMessage|could not be cloned|transfer list' \
test-app/app/src/main/assets/app/tests \
-g '*.js'Repository: NativeScript/android Length of output: 44955 🌐 Web query:
💡 Result: In the V8 engine, the v8::TryCatch class is an external exception handler used to catch JavaScript exceptions [1][2]. Its destructor (~TryCatch) is responsible for unregistering and deleting the try/catch block from V8 [3][4]. Regarding the specific behaviors you mentioned: 1. Destructor Functionality: The destructor ~TryCatch performs necessary cleanup by unregistering the block [3][1]. Because it is designed to be stack-allocated, its cleanup happens automatically when the object goes out of scope [3][5]. 2. ReThrow Method: The ReThrow method is used to re-throw an exception that has been caught by the current v8::TryCatch instance [1]. Crucially, it re-throws the exception in a manner that avoids it being caught again by the same TryCatch instance [3][4]. After calling ReThrow, it is illegal to execute any further JavaScript operations; the caller must return immediately to the point where the exception is propagated [1][2]. 3. Destructor Callback: V8 does not provide a specific callback mechanism tied to the destruction of a v8::TryCatch instance itself. If you are looking for callbacks related to object lifecycles (such as cleaning up C++ resources associated with a JavaScript object), V8 provides WeakPersistent handles and finalizers (e.g., v8::Global::SetWeak or v8::FinalizationRegistry) rather than hooks on the TryCatch destructor [6]. In summary, the v8::TryCatch destructor handles internal engine state management to ensure exception handling chains remain consistent [3][1]. It is not a user-extensible callback point [3][2]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- worker message-dispatch control flow ---'
sed -n '130,245p' test-app/runtime/src/main/cpp/WorkerWrapper.cpp
sed -n '365,415p' test-app/runtime/src/main/cpp/WorkerWrapper.cpp
printf '%s\n' '--- focused test inventory ---'
rg -n -C 6 'worker\.onerror|onerror\s*=|DataCloneError|postMessage.*catch|catch.*postMessage|transfer list|could not be cloned' \
test-app/app/src/main/assets/app/tests \
-g '*.js' || true
printf '%s\n' '--- static path check ---'
python3 - <<'PY'
from pathlib import Path
p = Path("test-app/runtime/src/main/cpp/CallbackHandlers.cpp")
text = p.read_text()
start = text.index("CallbackHandlers::WorkerGlobalPostMessageCallback")
end = text.index("void\nCallbackHandlers::WorkerObjectTerminateCallback", start)
fn = text[start:end]
checks = {
"callback creates TryCatch": "TryCatch tc(isolate);" in fn,
"serialization can return Nothing": ".IsNothing()" in fn,
"serialization failure returns directly": " return;" in fn[fn.index(".IsNothing()"):],
"serialization failure calls worker error helper": "CallWorkerScopeOnErrorHandle(isolate, tc);" in fn[fn.index(".IsNothing()"):],
"serialization failure rethrows": "tc.ReThrow();" in fn[fn.index(".IsNothing()"):],
}
for name, result in checks.items():
print(f"{name}: {result}")
PYRepository: NativeScript/android Length of output: 9333 Re-throw the serialization exception from When 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| #include "StructuredClone.h" | ||
|
|
||
| #include <cassert> | ||
|
|
||
| #include "ArgConverter.h" | ||
| #include "BuiltinLoader.h" | ||
| #include "StructuredSerialization.h" | ||
|
|
||
| using namespace v8; | ||
|
|
||
| namespace tns { | ||
|
|
||
| namespace { | ||
|
|
||
| /* | ||
| * binding.clone(value, transferArrayOrUndefined): serialize and deserialize in | ||
| * this one isolate, which is what StructuredDeserialize( | ||
| * StructuredSerializeWithTransfer(...)) amounts to when there is no second | ||
| * agent involved. | ||
| */ | ||
| void CloneCallback(const FunctionCallbackInfo<Value>& info) { | ||
| Isolate* isolate = info.GetIsolate(); | ||
| Local<Context> context = isolate->GetCurrentContext(); | ||
| Local<Value> value = | ||
| info.Length() > 0 ? info[0] : v8::Undefined(isolate).As<Value>(); | ||
| Local<Value> transferList = | ||
| info.Length() > 1 ? info[1] : v8::Undefined(isolate).As<Value>(); | ||
|
|
||
| serialization::SerializedValue serialized; | ||
| if (serialized | ||
| .Serialize(isolate, context, value, transferList, | ||
| serialization::HostObjectPolicy::kReject) | ||
| .IsNothing()) { | ||
| return; | ||
| } | ||
|
|
||
| Local<Value> result; | ||
| if (!serialized.Deserialize(isolate, context).ToLocal(&result)) { | ||
| return; | ||
| } | ||
| info.GetReturnValue().Set(result); | ||
| } | ||
|
|
||
| } // namespace | ||
|
|
||
| void StructuredClone::Init(Local<Context> context) { | ||
| Isolate* isolate = Isolate::GetCurrent(); | ||
|
|
||
| Local<v8::Function> clone; | ||
| bool success = v8::Function::New(context, CloneCallback).ToLocal(&clone); | ||
| assert(success); | ||
|
|
||
| Local<Object> binding = Object::New(isolate); | ||
| success = binding->Set(context, | ||
| ArgConverter::ConvertToV8String(isolate, "clone"), | ||
| clone) | ||
| .FromMaybe(false); | ||
| assert(success); | ||
|
|
||
| Local<Value> result; | ||
| success = BuiltinLoader::RunBuiltin(context, BuiltinId::kStructuredClone, | ||
| binding) | ||
| .ToLocal(&result); | ||
| assert(success); | ||
| } | ||
|
|
||
| } // namespace tns |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| #ifndef STRUCTUREDCLONE_H_ | ||
| #define STRUCTUREDCLONE_H_ | ||
|
|
||
| #include "v8.h" | ||
|
|
||
| namespace tns { | ||
|
|
||
| class StructuredClone { | ||
| public: | ||
| /* | ||
| * Installs the structuredClone global (internal/structured-clone.js). The | ||
| * builtin owns the argument coercion and hands the native side a value | ||
| * plus an already-materialized array of ArrayBuffers to transfer. | ||
| */ | ||
| static void Init(v8::Local<v8::Context> context); | ||
| }; | ||
|
|
||
| } // namespace tns | ||
|
|
||
| #endif /* STRUCTUREDCLONE_H_ */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Document both
postMessageentry points.The PR adds transfer-list support to both worker
postMessageentry points, but this section only names and demonstratesworker.postMessage. State both entry points and their shared array-only transfer-list rule so the worker-side API is also discoverable.🤖 Prompt for AI Agents