Skip to content

Tags: NativeScript/ios

Tags

v9.1.0-next.2026-10-13-38342327982

Toggle v9.1.0-next.2026-10-13-38342327982's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat(runtime): report native memory held by wrappers to V8, forward m…

…emory pressure (#507)

V8 only weighs its own heap (plus ArrayBuffer backing stores) when scheduling
collections, so a JS wrapper holding megabytes of native memory looks like a
few dozen bytes and can sit uncollected until the process is jetsammed.

- Wrappers can carry an ExternalMemoryCharge (a v8::ExternalMemoryAccounter)
  that is returned whenever the wrapper is deleted, from any thread; once the
  isolate's gate is closed the decrease is skipped since disposal drops it.
- interop.alloc(n) charges n bytes to the returned pointer.
- Objects JS likely owns (JS constructors, initializers, +1 returns, class
  factory methods) get an estimated size: NSData length, UIImage/CGImage
  bitmap size, CVPixelBuffer data size. CG/CV are resolved at runtime, so no
  new link dependencies. NSDataAdapter is excluded (V8 already counts it).
- interop.setExternalSize(obj, bytes) / interop.getExternalSize(obj) let
  libraries declare the native footprint of what they wrap.
- A process-wide dispatch memory-pressure source forwards normal/warn/critical
  to every live isolate via Isolate::MemoryPressureNotification.

v9.1.0-next.2026-10-12-38311872902

Toggle v9.1.0-next.2026-10-12-38311872902's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
perf(runtime): build struct objects from per-type templates with GC-o…

…wned storage (#499)

Each struct type gets a FunctionTemplate whose instance template carries a
native data property per field, replacing the shared empty constructor with
a named interceptor plus a SetPrototype per instance. Field reads become
inline-cacheable accessor calls instead of string-compared interceptor hits.

A struct object's wrapper and bytes live in one malloc block handed to V8 as
the external backing store of an ArrayBuffer held in an internal field, with
`free` as its deleter. V8's ArrayBuffer sweeper reclaims it once the struct
object is unreachable, so struct objects are no longer registered with
ObjectManager and have no finalizer, and a struct can never be reached after
its memory is gone, even through an object a refusing finalizer revives.

Nested struct fields stay live views into the root's bytes. Each view is
cached in an internal field of its parent, so `rect.size === rect.size`, and
keeps its parent alive through another internal field; its own storage block
holds only the wrapper. This removes the per-read child wrapper + weak
handle, the ChildCount re-arm logic and Caches::StructInstances. Views more
than one level deep are now views as well (they used to be detached copies).

Fields are own, enumerable, non-deletable properties, so Object.keys and
JSON.stringify see them, and methods added to a struct's prototype are
reachable (the interceptor used to answer undefined for every non-field name).
Because Object.assign now reaches every setter, none of them asserts:
- a struct-typed field takes a struct of the same layout and field names or
  a plain object, and throws a TypeError otherwise (it used to copy the
  field's size from any struct, over-reading a smaller one);
- a pointer-typed field takes a Pointer, null, an ArrayBuffer, a struct (its
  address) or an interop.Reference (the address it wraps, without re-pointing
  the Reference), and throws a TypeError for anything else;
- other encodings go through WriteValue.
Writes through a Proxy receiver land in the native buffer.

A struct built over a pointer (`CGRect(pointer)`) aliases the pointer's
memory instead of freeing it when collected, and keeps the Pointer object
alive. `new` without an initializer zero-fills.

v9.1.0-next.2026-10-09-38004501652

Toggle v9.1.0-next.2026-10-09-38004501652's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(worker): route worker_threads errors like the web surface, and fi…

…re once listeners once (#491)

* fix(worker): route parentPort listener throws and handled Node-style errors like the web surface

A parentPort listener that threw was dispatched without rethrowing, so the error went to the uncaught-error reporter and never reached the worker's onerror or its parent. The relay now dispatches the way worker-global message delivery does.

The worker_threads Worker's onerror handler returned nothing, so an error its 'error' listeners took was also reported to the parent's global scope as unhandled. It returns whether a listener ran, which cancels the event.

* fix(worker): fire a worker_threads once listener at most once under a reentrant emit

An earlier listener that emitted the same event again let the nested emit fire and remove a later once listener, and the outer emit then called it a second time from its snapshot. A once registration now records that it fired, as Node's once wrapper does.

* fix(worker): hand worker_threads 'error' listeners an Error rebuilt from the thrown value

A worker_threads 'error' listener received the runtime's ErrorEvent, not an Error, and the message it carried was V8's "Uncaught Error: ..." text. The worker now reads the thrown value's name and message and forwards them with the error payload. The parent rebuilds an Error from them with the worker's stack, using the built-in constructor the name belongs to, and the parent's global error event carries that same Error when nothing handled it.

The Worker shim cancels an error its listeners took with preventDefault() rather than through the truthy-return contract of onerror.

* fix(worker): keep a forwarded worker error exact when its stack getter throws

Reading the stack of the error being reported could run a `stack` getter, and a getter that threw replaced that error in the TryCatch holding it, so the parent rebuilt the getter's error instead. The stack reads now run under their own TryCatch.

The thrown value's name and message travel to the parent as UTF-16, so an unpaired surrogate arrives as thrown rather than as U+FFFD.

v9.1.0-next.2026-10-09-37981292968

Toggle v9.1.0-next.2026-10-09-37981292968's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat(runtime): ns:url builtin module mirroring node:url, with URL typ…

…es (#497)

* feat(runtime): ns:url builtin module mirroring node:url, with URL types

Adds `ns:url`, exporting the runtime's own URL and URLSearchParams plus the
`fileURLToPath`/`pathToFileURL` converters, so typed code can name the URL
classes by specifier instead of through whatever `globalThis.URL` resolves to
in the program's type environment. Its surface is exactly `node:url`'s: the
standard module now owns the converters, and `node:url` becomes a re-export
shim over it on a distinct, separately frozen module object, the way
`node:util` sits over `ns:util`. `require("node:url")` behaves as before and
additionally exports URL and URLSearchParams, as Node's does. URLPattern stays
a global only; Node's `node:url` has no counterpart and nothing here needs it.

The binding takes both constructors from the templates URLImpl::Init and
URLSearchParamsImpl::Init installed, held per isolate the way Worker's is.
V8 instantiates a template once per context, so the module hands out the very
functions the globals were created with even after an app reassigns them.

types/ns-url.d.ts declares the module and, like @types/node does for its own
globals, merges the module's interfaces into the global URL and
URLSearchParams and declares the global variables through the `onmessage`
probe, so a program with a DOM lib keeps the lib's declarations and one
without gets the module's constructors. Verified with tsc --strict against a
DOM and a non-DOM program: both are clean, and the non-DOM one no longer
reports the two missing-`URL` errors ns-module.d.ts used to produce there.

Experimental, iOS-only until the Android runtime ships the module.

* fix(runtime): URL toJSON and value-aware searchParams.delete

WHATWG URL defines toJSON as the href serialization, so JSON.stringify(url)
yields the href string. The runtime's URL lacked it, which also kept a
module URL from being assignable to lib.dom's URL in typed code. It is
registered next to toString and shares its callback, so both return the
same serialization. types/ns-url.d.ts declares it; tsc against the DOM and
the non-DOM program stays clean, and the DOM program now assigns
pathToFileURL()'s result to the global URL type.

The searchParams wrapper that keeps a URL's query in sync forwarded only the
name to the native delete, so url.searchParams.delete(name, value) removed
every pair with that name. It now forwards the value whenever the caller
passed one, by arity, so an explicit undefined still reaches the native
coercion.

v9.1.0-next.2026-10-08-37826160638

Toggle v9.1.0-next.2026-10-08-37826160638's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
perf(runtime): cache StructInfo, struct prototypes and the Class on t…

…he native call path (#496)

* perf(runtime): stop copying StructInfo and resolving the Class on every native call

StructInfo is now owned once by FFICall's process-wide cache (unique_ptr, non-copyable) and handed out by const reference; struct wrappers hold a pointer into it and Caches::StructInstances is keyed by that pointer instead of the struct name. The per-isolate struct constructor cache moves to a Caches::StateFor slot keyed by StructInfo* and also caches each constructor's prototype, whose property is now read-only so the cache cannot go stale. CacheItem caches the resolved Class and InvokeMethod takes it, so objc_getClass(name) no longer runs per call.

Release TestRunner, iPhone 16 Pro simulator: UIScreen.mainScreen.bounds.size.width 2035 -> 1436 ns, cached bounds.size.width 697 -> 352 ns, screen.scale 173 -> 153 ns. Suite 1743/0 incl. the ASan lane.

* perf(runtime): register root struct objects with ObjectManager once

CreateJsWrapper's struct branch registered every fresh struct object, and StructToValue and StructConstructorCallback registered the same object again to obtain the handle they keep for child-view lookups. The second finalizer found the wrapper already deleted and allocated an External inside the GC finalizer, forcing a nested collection. The struct branch now honours skipGCRegistration and the two callers that keep the handle register exactly once; child views are unchanged.

Release TestRunner: screen.bounds 618 -> 417 ns, UIScreen.mainScreen.bounds.size.width 1436 -> 1164 ns. Suite 1743/0, ASan lane clean.

v9.1.0-next.2026-10-07-37664278151

Toggle v9.1.0-next.2026-10-07-37664278151's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(runtime): keep clone-only message reads read-only, and see an unc…

…loneable brand created mid-clone (#490)

A clone-only message is read concurrently by every BroadcastChannel receiver and every getEnvironmentData call, and deserializing it cleared its transfer vectors, a write. Only the single receiver of a message with transferables writes them now.

The serializer cached the uncloneable brand when it was created, so the first markAsUncloneable call in an isolate, made by a getter in the graph being written, went unseen and the marked object was cloned. It now picks the brand up once it exists.

v9.1.0-next.2026-10-05-37361399047

Toggle v9.1.0-next.2026-10-05-37361399047's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(worker): carry the deserialization failure in a worker's messagee…

…rror data (#489)

A message a worker (or its parent) cannot read fires messageerror with
data === null, while the same failure on a MessagePort delivers the
thrown exception as data (NativeMessagePort::Drain), which is also what
Node hands worker.on('messageerror'). The node:worker_threads shim
forwards event.data unchanged, so its Worker and parentPort surfaces
received null where Node gives the Error.

Worker::OnMessageCallback now mirrors the port path: the caught
exception when there is one, null otherwise.

v9.1.0-next.2026-10-05-37355676239

Toggle v9.1.0-next.2026-10-05-37355676239's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(worker): isolate and wrapper lifetime fixes around startup and te…

…ardown (#478)

* fix(worker): isolate and wrapper lifetime fixes around startup and teardown

A throwing onclose run from the entry script read the worker isolate before it was published and crashed. Terminate() used the worker isolate with no synchronization against the worker thread deleting its runtime. EndWrapperLifetime kept using the wrapper after dispatching nsworkerended, whose listeners can shut the runtime down and delete it.

* fix(worker): serialize the message queue's run loop source with its teardown

Push() and Signal() read the run loop source and run loop with no lock while Terminate(), on another thread, invalidates and releases the source. They now go through the mutex Terminate() holds, and the worker thread terminates its queue before it leaves, since the queue borrows that thread's run loop. Also removes the unused GetWorkerIsolate() getter, which handed out the isolate without the mutex that now guards it.

* refactor(worker): rename the queue's run loop mutex and scope the isolate lock

initializationMutex_ guards the run loop source and run loop for the queue's whole life now that Signal() takes it, so it is named runLoopMutex_. Terminate() holds workerIsolateMutex_ in a scoped block, like the inspectorMutex_ block next to it, instead of a unique_lock released by hand. The comment in Push() said Terminate() flips the terminated flag under the queue mutex; it sets it under the run loop mutex, before emptying the queue.

---------

Co-authored-by: Eduardo Speroni <[email protected]>

v9.1.0-next.2026-10-05-37355213692

Toggle v9.1.0-next.2026-10-05-37355213692's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
perf(ios): ship default metadata as a separate framework for embeddin…

…g hosts only (#488)

* build(ios): stop copying unused default metadata into NativeScript.framework

The framework already embeds metadata-arm64.bin as its __DATA,__TNSMetadata section, which is the only copy the runtime reads. The loose 8.8 MB resource copy is never read, and the opt-in "Add default metadata" phase copied from a NativeScript/metadata/ directory that does not exist, gated on an INCLUDE_DEFAULT_METADATA setting nothing sets.

* feat(runtime): resolve metadata from the host executable when no pointer is given

When Config.MetadataPtr is nil, look for a non-empty __DATA,__TNSMetadata section in the host executable (or its <executable>.debug.dylib in Xcode debug-dylib builds) before falling back to the metadata embedded in NativeScript.framework. Hosts set up by 'ns embed ios' link their own metadata this way but never pass the pointer, so they were silently running on the framework's 2024 snapshot.

* feat(ios): move default metadata into a NativeScriptDefaultMetadata framework

NativeScript.framework no longer embeds the 8.8 MB default metadata snapshot. It now lives in a separate NativeScriptDefaultMetadata framework (iOS, simulator and Mac Catalyst) that only the NativeScriptSDK SwiftPM product links, so apps built by the NativeScript CLI, which use the NativeScript product and link their own metadata, stop shipping it.

When neither Config.MetadataPtr nor a host executable section is available, the runtime loads the accessor exported by NativeScriptDefaultMetadata.framework (already loaded, or found next to NativeScript.framework), and otherwise fails to start with an actionable message instead of running on metadata the host never asked for.

build_default_metadata.sh builds the xcframework, build_spm_artifacts.sh packages it with an NS_CHECKSUM_DEFAULTMETADATA_IOS checksum, and generate-spm-manifest.mjs declares it as a binary target of the NativeScriptSDK product. visionOS has no default metadata build.

* fix(build): accept numeric boolean flags and use $PWD in the framework build scripts

to_bool matched numbers with the case pattern [0-9]+, which is a glob for one digit followed by a literal plus, so BUILD_CATALYST=1 and friends were treated as invalid and turned the slice off. DIST was computed with $(PWD), which only resolves to /bin/pwd on a case-insensitive filesystem.

v9.1.0-next.2026-10-05-37352091060

Toggle v9.1.0-next.2026-10-05-37352091060's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
ci: run the test suite under AddressSanitizer (#487)

* ci: run the test suite under AddressSanitizer

The Test job becomes a two-entry matrix: the existing run, still named Test, and Test (ASan), the same xcodebuild invocation with -enableAddressSanitizer YES. Use-after-free and double-free bugs in the runtime are usually silent in a plain run, so a spec written for one (the nested-worker teardown spec from #479) passes there with or without its fix.

Artifact names are unique per workflow run, so the ASan entry uploads test-results-asan and test-diagnostics-asan, and collect-test-diagnostics takes the artifact name as an input.

Five worker quality-of-service specs cannot hold in an instrumented build: a worker asked for default or utility reads back user-initiated. They are reported as skipped there, keyed on a new TNSIsAddressSanitizerEnabled() test fixture, and run unchanged everywhere else.

* test: let the system assign the test server's port

The XCTest harness served the junit report and the HTTP module fixtures on a fixed loopback port. Simulators share the host's loopback interface, so two suite runs on one machine, even on different simulators, could deliver one run's report to the other's listener.

The listener now binds a port chosen by the system and hands it to the app through REPORT_BASEURL, which is already the only way the app learns the address. start() blocks until the listener is bound, since the port is only known then, and throws when binding fails instead of leaving the failure unobserved.

* test: log the test server's port

The port is assigned by the system at bind time, so the run's output is the only place to find it when poking at the server by hand.

* test: report test server startup errors as test failures

Setup moves to setUpWithError() so a listener that fails to bind, or does not become ready in time, is recorded by XCTest as a failure of the test instead of crashing the runner through try!. tearDown() tolerates a server that was never created, since it still runs after a throwing setup.