NativeScript Blog

NativeScript 9.1 Released

Technical Steering Committee (TSC) August 27, 2026

NativeScript 9.1 upgrades both runtimes to V8 14.9 and ships a mature module system (one loader over disk and HTTP, a ns: builtin namespace, Node-compatible require(esm)) that powers Vite 8 hot updates without a process restart. It also brings a web-standard error model, a real event loop, the standard globals from performance and structuredClone to TextEncoder, AbortController and requestAnimationFrame, Node-API addons, cross-platform NativeWindow, Vitest on device, and a CLI that manages the dev server for you.

A mobile runtime that loads JavaScript from disk is a solved problem. A runtime that loads modules from wherever they happen to come from (the app bundle, a dev server over HTTP, a blob URL) and treats all of them as one graph with one identity per module is not. NativeScript 9.0 taught the iOS and Android runtimes to speak ES modules. NativeScript 9.1 gives them a module system: one loader with two transports, a ns: builtin namespace with import maps resolved inside the engine, require(esm) that follows Node's rules, and freshness expressed as explicit eviction rather than URL tricks. That is what lets Vite 8 hot updates land on a device without ever restarting the process.

Underneath it, both runtimes moved from V8 10.3 to V8 14.9. On top of it, the runtimes gained the parts of the web platform an app actually leans on: a web-standard error model where an uncaught exception no longer kills the process, a real event loop, performance, structuredClone, TextEncoder, AbortController, DOMException, requestAnimationFrame and the rest of the standard globals, and a Node-API surface for native addons. @nativescript/core ships cross-platform NativeWindow, CSS max-width/max-height and flexbox gap, and measurable hot-path work. The CLI now owns the Vite dev server, and @nativescript/unit-test-runner 5 runs Vitest on device.

This post is long because the release is. The first half is about the module system, with three diagrams you can scrub through, and why that loop matters in a year when most code is written with an agent. The second half is everything else.

tl;dr - Updating to NativeScript 9.1

Note: Node 22 or higher is required.

Updating to any minor or major version of NativeScript starts with installing the latest CLI:

npm install -g nativescript

Confirm your installed version:

ns -v

You should see at least 9.1.0 or higher.

Then, from your app, run the migration:

ns migrate

Finally, clean your project:

ns clean

Here are the dependencies you can expect to see after a successful 9.1 migration (exact versions may vary per template and framework):

"dependencies": {
  "@nativescript/core": "~9.1.0"
},
"devDependencies": {
  "@nativescript/android": "~9.1.0",
  "@nativescript/ios": "~9.1.0",
  "@nativescript/types": "~9.1.0",
  "@nativescript/webpack": "~5.0.39"
}

If you use Vite, @nativescript/vite is now versioned alongside Vite itself:

"devDependencies": {
  "@nativescript/vite": "^8.0.0"
}

9.1 is a minor release and existing apps run unchanged, but several runtime defaults moved toward the web platform and a few long-broken behaviors now fail loudly instead of silently. Read Migration notes before you ship.


Why the module system is the whole story

The browser gets hot module replacement almost for free: module identity is the URL, the engine's loader is asynchronous and network-aware, and the dev server only has to tell the page which URLs changed. A native runtime embedding V8 gets none of that. V8's public API (still, in 14.9) hands the embedder a synchronous ResolveModuleCallback during InstantiateModule. There is no HostLoadImportedModule. If a module has to come over the network, the runtime has to have fetched it before V8 asks.

In 9.0 we worked around this. The resolver could evaluate a module while resolving it, which meant evaluation order followed resolver order rather than the spec's post-order, and a whole layer of re-entry guards, fallbacks and gating existed to compensate. Freshness was done the way most HMR clients do it: by changing the URL. That works until the same module exists under two URLs in one realm, at which point the second evaluation collides with the first and you get Cannot redefine property from deep inside a framework you didn't write.

9.1 replaces that with three invariants:

  1. The resolver only compiles and registers. It never instantiates or evaluates. Discovery runs ahead of V8 (on disk inline, over HTTP concurrently), and by the time InstantiateModule asks, every edge is already in a per-isolate registry. Evaluation happens once, at the root, in spec order. Cycles terminate through the registry.
  2. Module identity is the canonical URL, and it never changes. The server emits exactly one URL per module. The runtime canonicalizes only to absorb variance it didn't cause (Vite's ?v= and ?import, a file://http:// wrapping), and the vocabulary for that comes from the dev client, not from native code.
  3. Freshness is explicit eviction. A hot update evicts a key from the registry and re-imports under the same key; the URL itself never changes.

The pipeline is the same for every root, whether that root is the app's boot entry, a require(), or a dynamic import().

One loader, two transports

StartModuleGraphLoad → registry (canonical key) → InstantiateModule → EvaluateModuleGraph under kSyncPumping / kSyncStrict / kAsync

Step through the diagram and you are watching StartModuleGraphLoad. The root is read, compiled and registered under its canonical key, then its import edges are walked. Disk edges compile inline as they are found. HTTP edges are fetched concurrently (on NSURLSession background queues on iOS, capped at 16 in flight process-wide on both platforms) while the walk continues, and each response is classified by status and MIME type before it reaches the compiler. When the walk drains, InstantiateModule links the graph with a lookup-only resolver, and EvaluateModuleGraph runs it once under one of three named policies:

  • kSyncStrict is what require() uses. It is Node's require(esm): a graph containing top-level await is refused before evaluation, so the module stays instantiated and nothing is half-run. The error message tells you what to do instead.
  • kSyncPumping is what the boot entry and worker entries use. The runtime drives nestable V8 tasks in place until the graph settles, under a 60-second module deadline.
  • kAsync is import(). It hands back the capability promise. It is the only way a top-level-await graph ever runs outside boot.

require() of an ES module follows Node's populateCJSExportsFromESM cascade: a literal 'module.exports' export wins, a namespace with no default passes through, and otherwise you get a live-binding facade: the synthetic module is export * from "original"; export const __esModule = true;, byte-matched to Node's own, so _mod.__esModule ? _mod.default : _mod finds the real default and stays live. The refusal for top-level await is the one place we diverge from Node in identity rather than rule: it is a plain Error with NativeScript wording, not an ERR_REQUIRE_ASYNC_MODULE code.

The HTTP response classifier is where dev servers tend to go wrong. Both transports share one classifier, so they cannot disagree about what a response means. The HTML spec's 16 JavaScript MIME essences load as JS; application/json, text/json and any +json suffix load as JSON modules; an empty 2xx body with a JavaScript MIME type is a valid empty module (a type-only TypeScript file transforms to zero code, and that's fine); an empty JSON body is not. 204 and 205 are checked before MIME. A dev server's SPA fallback answering 200 text/html for a missing module now rejects with the real reason instead of compiling HTML as JavaScript.

Ahead of all of that sits a security gate. In release builds, remote module loading is deny-by-default. Debug builds always allow it. If you genuinely want remote ES modules in production, opt in:

// nativescript.config.ts
export default {
  // ...
  security: {
    allowRemoteModules: true,
    remoteModuleAllowlist: ["https://cdn.example.com/"],
  },
};

Allowlist matching is boundary-aware: https://cdn.example.com does not authorize https://cdn.example.com.attacker.com/x.js. Blocked fetches fail with HTTP import blocked: remote module loading is not allowed for <url>.

ns:, a builtin namespace the runtime owns

The loader's control surface is not a set of globals. It is a builtin module, in a new URL-style scheme that mirrors node::

import { configureLoader, invalidateModules, createRequire } from "ns:module";
import { inspect, format } from "ns:util";
import { setConfig, getConfig } from "ns:runtime";

The rules are deliberately strict, and they are the same on both runtimes because the builtin-module contract is written once and implemented twice:

  • ns: and node: specifiers resolve before any filesystem or npm resolution, in all three entry points: require(), static import, and import(). No file, path mapping or package can shadow them.
  • Builtins are singletons per realm. The main context and each worker get their own; exports are frozen.
  • The public registry holds exactly six entries: ns:module, ns:runtime, ns:util, node:module, node:url, node:util. Runtime internals (primordials, the console formatter, the event machinery, structuredClone, performance) have no specifier at all. You cannot accidentally import a runtime internal, because there is nothing to import.
  • Unimplemented members are absent, never present-but-throwing, so typeof x === "function" feature checks work.
  • Bare specifiers are untouched. require("util") still resolves through npm, exactly as before.

ns:module is the whole dev surface: configureLoader({ importMap, volatilePatterns, canonicalization }), invalidateModules(urls), getLoadedModuleUrls(), createRequire(base), and createPumpingRequire(base, { deadlineSeconds, onTimeout, pumpRunLoop }) for the rare task context that must drive a top-level-await graph synchronously.

configureLoader is the interesting one. The import map is WHATWG-shaped (imports + scopes) and it is installed into V8's synchronous resolver in the engine, not layered on as a JavaScript polyfill. A scope key matches as a prefix of the importing module's canonical key, longest prefix first; it is the runtime's analogue of the web's resolved referrer URL. The whole config is validated before any of it installs, so a rejected call installs nothing and the live session keeps resolving through the previous vocabulary. Each present section replaces its state wholesale; undefined leaves it alone. Loader vocabulary is per-isolate and snapshot-copied to workers at spawn, which means tooling that reconfigures the loader must restart workers for the change to reach them. That is written down as a rule, not left to be discovered.

The node: shims (node:util, node:url, node:module) are separate, separately-frozen module objects even where every member is re-exported unchanged. Bun, Deno and Cloudflare all keep their own surface apart from their Node compatibility layer; we do too. One consequence you will notice: the old generic fallback for an unshimmed node: import (a console.warn and an empty default export that broke at first use) is gone. import fs from "node:fs" now fails with No such built-in module: node:fs, identically through require(), import and import().

@nativescript/ios now ships TypeScript declarations for all of it (types/ns-module.d.ts, ns-runtime.d.ts, ns-util.d.ts), with ns:runtime's key map left augmentable so keys from a newer runtime still compile.

Dev boot over HTTP

With the loader in place, a dev session is a short, deterministic sequence, and "deterministic" is the property we cared about most, because a boot that sometimes works is worse than one that never does.

bundle.mjs → /ns/core-bundle.mjs → /__ns_dev__/session → /ns/import-map.json → configureLoader → HMR client + WebSocket → /ns/m/src/* + /ns/deps-bundle.mjs → real root

The app bundle ships a tiny local ES module, bundle.mjs. Its static imports are absolute, query-free /ns/core/* URLs, deliberately canonical before any loader policy exists, because an ES module's own static imports resolve before its body runs, and the import map is not installed yet. That ordering rule is normative: configureLoader must run before any ES module traffic it governs, so an ESM entry keeps its static imports to builtins and reaches everything else through import() after the config call. The entry body then:

  1. Fetches /__ns_dev__/session: one descriptor saying where the HMR client, the app entry, the WebSocket and the runtime config live, so the device never guesses.
  2. Fetches /ns/import-map.json and hands it to configureLoader.
  3. import()s the HMR client and the framework strategy. The client installs its hot registry and stays quiet until boot completes.
  4. import()s the app entry. The graph streams in through /ns/m/* with concurrent fetches.
  5. Replaces the boot placeholder with the real root view. Boot completion is derived natively from the entry's evaluation settling, not from a flag the client has to remember to set.

Two things in that sequence are single payloads by design. @nativescript/core is served as one prebundled ES module, /ns/core-bundle.mjs, built once with esbuild and disk-cached under node_modules/.ns-vite/; every /ns/core/<sub> shim re-exports from it, so core evaluates exactly once per app lifetime instead of as hundreds of serial per-file fetches. node_modules is served the same way, as /ns/deps-bundle.mjs, seeded by a boot recording of every deep dependency file the previous cold boot actually touched. Bundled deps join the live core realm rather than duplicating it.

An earlier version of this design shipped a server-side "prewarm": the dev server would compute the module closure ahead of time and push a boot archive, on the theory that breadth-first discovery costs one serial network wave per level of graph depth. Measuring it showed the opposite: once the runtime's own concurrent fetches overlapped the server's transform work, the prewarm was a pessimization, and the entire kickstart/boot-archive machinery was deleted. The runtime's async graph walk is now the only path.

While all of that happens, an on-device overlay reports real stages (probing-origin → configuring-import-map → loading-entry-runtime → importing-main → waiting-for-app) with a 250ms heartbeat so a cold boot over a slow link never looks hung.

A hot update, end to end

save → ns:hmr-pending → ns:hmr-delta → invalidateModules → import() + __ns_dev_nonce → same canonical key → framework strategy → UI

You save src/home-page.ts. Vite's watcher fires handleHotUpdate. Before any transform work, the server broadcasts ns:hmr-pending so the device can show feedback the instant you save; a UX hint, not the update. It then invalidates its transform cache, bumps the graph version, and sends ns:hmr-delta: changed ids, their deps, and an 8-hex content hash — no module source travels over the socket. The client checks baseVersion against its graph (a mismatch asks for a full resync via ns:hmr-full-graph), then does two things in an order that matters:

  1. Evict. invalidateModules([url]) drops the canonical key from V8's module registry and arms a one-shot "bust next fetch" mark.
  2. Re-import. The import() uses the unchanged canonical URL. The runtime itself appends a one-shot __ns_dev_nonce to the outgoing request to defeat the OS HTTP cache, and the fresh body registers under the same key.

Those are the two layers that could ever serve a stale byte (the engine's registry and the OS cache), and both are invalidated explicitly. The client never mutates an import URL.

A framework strategy then applies the module to the live UI. XML/TypeScript apps re-register the module and re-navigate the page in place (open modals are tracked and re-presented rather than dismissed). Angular routes template-only edits through its own ɵɵreplaceMetadata in-place swap via the angular:component-update event, bypassing a reboot entirely. Vue walks the reverse import graph to the nearest .vue boundary and remounts it. React reuses the TypeScript strategy with the app's __NS_HMR_ON_UPDATE__ hook driving the remount. Solid exposes a completion hook other integrations (TanStack Router, for one) subscribe to. CSS arrives as tagged ns:css-updates, scoped per source file. Worker teardown is fully userland: the session intercepts the global Worker constructor and sweeps every tracked instance before a framework reboot.

Core, node_modules, the session and the socket all stayed warm; only the changed module crossed the wire, with no process restart and no rebundle.

The CLI owns the dev server now

In 9.0, a Vite project needed concurrently and wait-on to run vite serve beside the CLI. That is gone. ns debug ios and ns debug android start the dev server themselves, as a child process the CLI owns, and HMR is the default:

npx nativescript-vite init   # generates vite.config.mts for your flavor
ns debug ios                 # HMR by default; the CLI starts the dev server
ns debug android --no-hmr    # standard non-HMR dev path

The CLI picks the first free port at or above NS_HMR_PORT (default 5173) the same way Vite does, bakes it into the device URLs, binds the dev server to it, and, on Android, tunnels it with adb reverse, so all three always agree. The tunnel is established in two phases, before the build and again right before launch, because the adb transport can drop a reverse mapping during a long install on a freshly-booted emulator; it is verified with adb reverse --list rather than trusted from an exit code. Each platform gets its own staging directory under .ns-vite-build/<platform>, so two terminals running iOS and Android at once need no configuration. NS_HMR_STRICT_PORT, NS_HMR_NO_ADB_REVERSE, NS_HMR_PREFER_LAN_HOST and NS_HMR_HOST cover tunnels, CI and physical devices.

Documentation for @nativescript/vite lives here.


NativeScript in the agent era

More code is now written with an agent than about it, and that changes what a framework is optimized for. An agent does not care how pleasant an API is to type. It cares how many tokens it costs to get a feature to build-green, how often the loop breaks, and how quickly it can see whether what it wrote is right. Those three things are what NativeScript's architecture has always been about, and 9.1 sharpens each of them.

The platform is the API

NativeScript's model has never had a bridge to author. iOS and Android APIs are exposed to TypeScript directly, through declarations generated from the platform SDKs, and the runtime marshals the call. For a human that is a convenience. For an agent it is a cost structure: the vocabulary of the platform is learned once, and every subsequent native feature reuses it instead of paying for a new native module, a new registration, a new set of callbacks to get wrong.

Agent Tokenomics measured this in July. Five trials per framework of the same three-phase spec: a Vue app shell, then HealthKit (authorization, step read/write, 7-day statistics), then live speech transcription. Every trial ran Claude Sonnet 5 through headless Claude Code, with each framework given its own official-docs MCP server, identical CLAUDE.md guidance, fresh sessions, no hidden retries. The NativeScript trials finished at a median of 81,697 output tokens against 153,110 for LynxJS (1.9×, with no overlap between the trial ranges), and zero lines of Swift against 226. What stands out is the shape of the per-phase curve. The first native feature cost the NativeScript agent 52.8K tokens to LynxJS's 67.5K. The second one cost 15.4K to 61.7K. In the study's words:

After learning the direct-TypeScript platform pattern in phase 2, the NativeScript agent's second native feature cost 71% less.

LynxJS's agent wrote a 106-line Swift module with bridge registration and streaming callbacks to get speech working. NativeScript's agent wrote two lines of configuration and called the Speech framework from TypeScript. That gap is not a one-off; it is the per-feature tax of a bridge architecture, and an agent pays it on every feature after the first. The usual caveats apply and the authors state them (one model, one harness, pinned versions, five trials), but the gap follows from the architecture rather than from sampling variance.

Several 9.1 changes were made with that loop in mind, even where the motivation was first a human one:

  • Typings an agent can trust. Generated iOS declarations now mark pointer returns and non-_Nonnull parameters | null; Android typings carry Kotlin nullability and no longer leak internal members. strict mode is viable, and a strict compiler is the cheapest reviewer an agent will ever have.
  • Errors that are precise and survivable. An uncaught exception reports instead of crashing, so one wrong call does not cost the agent a relaunch and a cold boot to find out. Unhandled rejections are reported instead of vanishing. The runtime's error strings are normative and tell you what to do: Cannot find module '<specifier>', No such built-in module: node:fs, and a top-level-await refusal that names import() and createPumpingRequire as the fix.
  • Logs that are bounded. console.log output is budgeted and cannot stall the app or flood a transcript with a 2 MB object dump.
  • Tests on the device, from the host. ns test ios runs Vitest inside the runtime and exits non-zero on failure; a verification step an agent can run without a human in the loop.

Realtime feedback is the multiplier

The other half of an agent loop is observation. An agent that edits a file and has to wait for a rebundle and a relaunch to see the result spends most of its wall-clock doing nothing, and loses app state every time. With the 9.1 module system and Vite 8, a save is a delta: ns:hmr-pending reaches the device before any transform work runs, the changed module is evicted and re-imported under the same key, and the framework strategy applies it in place, with navigation stack, scroll position and in-memory state intact — no rebundle, no restart. A visual change lands on the simulator in well under a second, and the agent can screenshot it, compare it against the spec, and iterate. When something goes wrong, the on-device overlay names the stage it failed at rather than showing a blank screen.

Because the CLI now owns the dev server, the whole loop is one command an agent can start and leave running: ns debug ios. Deterministic boot, auto-selected ports, adb reverse handled. Here is what that looks like side by side: an agent working through a feature on the left, the app updating live on the right:

Frameworks today in this space hot-reload in their own sandbox. NativeScript hot-reloads the platform. This is a Metal fragment shader on a real SceneKit surface, art-directed live from a Vue template: six edits, each under 50ms, into a presented modal, and the shader's state never resets. The Metal shader source, the SCNView setup, the orthographic camera, and the KVC uniform writes are all TypeScript; in other words, they live inside the module graph vite is updating in realtime.

Official Agent Skills

The third leg of the loop is knowledge. The expensive failure mode for an agent on a platform this large is not the feature it cannot build; it is the pitfall it rediscovers at four thousand tokens that a sentence would have prevented. So alongside 9.1 there is now NativeScript/skills: official Agent Skills for any agent that speaks the open standard, which today means Claude Code, Cursor, Codex, Copilot, Gemini CLI, Windsurf and the rest. Each skill is a folder with a SKILL.md: a one-line description the agent matches on, then concrete code that has actually run, plus the pitfalls around it. There are 37 at launch across core, iOS, Android, UI, testing and tooling: the iOS safe-area smudge on scrolling lists, line-height meaning additive spacing rather than the web's line box, the CoreSpotlight call that deadlocks V8, Android edge-to-edge insets on API 35, the 9.1 uncaughtErrorPolicy change, and which web globals exist (a question this release just changed the answer to).

They are not documentation exports. Every skill carries one realistic eval task in a skillgrade suite, with executable graders where the code can run headless and a discovery check that the agent actually finds the right skill among all of them. Install through whichever door your tooling prefers:

npx skills add NativeScript/skills   # any agent; npx skills update to stay current

or as a Claude Code plugin (/plugin marketplace add NativeScript/skills, then /plugin install nativescript-skills@nativescript), or vendored and pinned through npm as @nativescript/skills. And it is open: npm run new <category> <ns-name> scaffolds a skill with its eval stub, and the patterns you have had to teach your own agent twice are exactly the contributions we want.


V8 14.9

Both runtimes moved from V8 10.3.22 to 14.9.207.39. That is roughly three and a half years of engine work: Maglev (a whole extra JIT tier that did not exist in 10.3), the newer language features, and every GC and parser improvement in between. It is also the dependency baseline everything else in this post builds on.

The API migration was mostly mechanical. On iOS, Context::GetIsolate() was removed at 113 call sites, External values grew a type tag at 84, PropertyCallbackInfo::This() disappeared at 43, accessor callbacks changed shape at 44, interceptors now return v8::Intercepted at 13 functions. Android's counts are similar in shape. The migration notes record every one, with the reasoning, because the reasoning is the expensive part to reconstruct.

What this means for you:

  • iOS plugins that include v8.h must compile at C++20 or later. V8's v8config.h now rejects C++17. Templates moved from gnu++0x to gnu++20 (and gnu99 to gnu17 for C; verified metadata-neutral against the iOS 26.2 SDK). Watch for plugins that pin CLANG_CXX_LANGUAGE_STANDARD = c++17 in their build.xcconfig; see Migration notes.
  • The iOS runtime binary is now delivered through SwiftPM. The npm package no longer carries xcframeworks; project templates reference NativeScript/ios-spm at the exact runtime version, and SwiftPM links, embeds and stages the slice where the metadata generator finds it. The public v8:: symbols plugins link against are re-exported (+373 KB on the device slice).
  • Android moved to NDK r29. Not optional: V8 14.9's atomics use std::atomic_ref, which r27's libc++ does not implement. minSdk stays at 21, compileSdk at 35. Plugin authors building native .so files should know the runtime's libc++ is now r29-based.
  • Android's 32-bit ABIs can no longer be built on Apple Silicon (mksnapshot needs V8's simulator for a 32-bit target); they come from a Linux x64 builder.

A runtime that behaves like the web

The 9.0 announcement had a section titled "error handling that keeps the runtime alive." That was a development-time behavior. 9.1 adopts the web's error model outright, on both platforms, in every build.

Errors that report instead of crash

The web got one thing right twenty years ago: a page that throws does not crash the browser. In 9.1 an uncaught JavaScript error is reported and execution continues, by default, in release. The exception is dispatched as a cancelable error event on globalThis; an unhandled rejection as unhandledrejection; a late-attached handler fires rejectionhandled. Event, EventTarget, ErrorEvent and PromiseRejectionEvent are real global constructors. reportError(x) routes a caught-but-fatal error through the same pipeline. preventDefault() means "fully handled": no log, no modal.

globalThis.addEventListener("unhandledrejection", (e) => {
  crashReporter.record(e.reason);
  e.preventDefault();
});

Both iOS and Android now drain rejections once per run-loop turn and report each exactly once. If your app has been quietly rejecting promises for years, you are about to find out.

If you need the old behavior (a CI harness that must fail loudly, a crash SDK hooked on the old path), it is one key in the app's nativescript.config.ts, described in the docs as the cross-runtime 9.1 uncaught-error contract:

{ "uncaughtErrorPolicy": "throw" }

"report" is the default. "throw" rethrows an unprevented error as a real NSException / Java throwable at the native boundary. discardUncaughtJsExceptions is deprecated but still fully honored, with a one-time warning. __onUncaughtError and Application.uncaughtErrorEvent keep working byte-for-byte; @nativescript/core needed zero changes.

The boundary work goes both directions. A native throw during a JS→native call now surfaces as an Error carrying error.nativeException, the original NSException or Throwable. And throw interop.escapeException(err) from a JS override converts into a real native throw into the native caller, unwrapped, with identity preserved, so a parent @catch (NSRangeException *) or catch (IOException e) matches. JS stacks ride along for crash reporters: exception.tns_javascriptStackTrace on iOS, a suppressed com.tns.JavaScriptStackTrace on Android that renders in printStackTrace() and logcat. Getting ObjC exceptions to unwind through libffi closure trampolines required moving the iOS runtime off a 2019 libffi fork onto current upstream, which also moved SIMD marshaling onto libffi's own ABI classification.

Android also restored something it had silently lost: native crash tombstones. The old SIGSEGV/SIGABRT handler threw a C++ exception from a signal handler, undefined behavior that displaced debuggerd, so native crashes produced no tombstone at all. The replacement records a breadcrumb and hands the signal back to the real handler.

An event loop, finally

Nothing in the iOS runtime pumped V8's platform foreground task queue outside the two debugger pause loops. Concretely: Atomics.waitAsync promises never resolved, GC finalization tasks never ran, and a promise resolved by native code without entering JS could hang until unrelated JS happened to run. Android had its own version of the same story, plus a 100ms polling thread for timers.

Each runtime now owns an EventLoop (one per isolate, main and workers alike) with the same API and contracts on both platforms. It has two lanes. The ordered lane carries macrotasks and, now, setTimeout/setInterval themselves, strictly ordered by due time: one CFRunLoopTimer riding the timer phase on iOS so cross-ordering against a foreign NSTimer is preserved by fire date; Android's MessageQueue with a lock-free @CriticalNative CAS cell for cancelling short timers with zero JNI. The internal lane (a CFRunLoopSource on iOS, eventfd/timerfd on the ALooper on Android) carries V8 platform tasks, worker-to-parent messages, rejection drains and Node-API completions. Every entry ends with a microtask checkpoint. A self-rescheduling setTimeout(0) chain is guaranteed a full run-loop pass between consecutive tokens, so it starves neither rendering nor the autorelease pool.

There is also a new global, __ns__queueMacrotask(cb): the seam future spec'd macrotasks will route through, named now so it already exists when they arrive.

performance, structuredClone

performance is the full WHATWG surface (hr-time, User Timing Level 3, the Performance Timeline with PerformanceObserver) as globals in the main isolate and in every worker, each with its own timeOrigin. performance.now() is full double precision with no coarsening, on the platform monotonic clock: mach_absolute_time on iOS, which is the same base as CACurrentMediaTime and CADisplayLink; CLOCK_MONOTONIC on Android, which is the clock Choreographer stamps frames on. Android's __postFrameCallback now passes a second argument, performanceMillis, mapped exactly onto that timeline rather than resampled in JS. All of the spec logic is one portable file, performance.js; the native side feeds it now() and timeOrigin and nothing else. The documented deviations are small and deliberate: observer callbacks run from a microtask, and buffers are unbounded.

structuredClone(value, { transfer }) is built on V8's own ValueSerializer, sharing one serialization core with worker postMessage, which gained an ArrayBuffer transfer list on both platforms in the same change. Graph identity and cycles round-trip; prototypes do not. SharedArrayBuffer is shared, not copied. Host objects (native wrappers) throw DataCloneError from structuredClone, because a JavaScript copy detached from its native counterpart would be a wrapper around nothing. They still degrade to {} under postMessage, because that is shipped behavior apps rely on; the asymmetry is encoded in exactly one enum, and unifying it is a coordinated change for both runtimes.

The rest of the standard globals

The last mile of "will this library run here" is rarely performance; it is the short list of globals a package feature-checks on its first line. 9.1 ships that list natively, in both runtimes, in the main isolate and every worker:

  • TextEncoder / TextDecoder, WHATWG-conformant: utf-8, utf-16le, utf-16be and windows-1252 with their complete label sets (Node without ICU ships the first two; the last two are cheap and cover the ascii/latin1/iso-8859-1 aliases web code actually uses). Streaming decode(…, { stream }) carries incomplete sequences, split UTF-16 code units and split BOMs across calls; replacement is exact (one U+FFFD per maximal invalid subpart); encodeInto never splits an encoded code point at a partial-write boundary. Pure-ASCII decodes go straight through V8's one-byte string path, and the shared conformance suite (94 specs) was validated against Node 24 with full ICU as the reference.
  • atob / btoa with the spec's forgiving-base64: whitespace stripping, padding rules, alphabet validation.
  • AbortController / AbortSignal, including AbortSignal.abort(), AbortSignal.timeout() and AbortSignal.any(), with a Node-equivalent GC contract that is written down: a timeout signal nobody can observe is collectable, a composite signal never pins a long-lived source, and an abort someone could still observe is never dropped.
  • DOMException, per Web IDL: grafted onto Error.prototype so instanceof Error holds, with the full legacy code table and all 25 constants. The runtimes' own failure sites now throw real DOMExceptions (AbortError and TimeoutError reasons, atob's InvalidCharacterError, structuredClone's DataCloneError, the performance API's failures) where they previously threw name-patched Error stand-ins. err.name checks keep working; instanceof DOMException starts working.
  • CustomEvent, joining the Event / EventTarget / ErrorEvent / PromiseRejectionEvent family from the error-model work.
  • setTimeout / setInterval / clearTimeout / clearInterval under their standard names, owned by the runtime's event loop rather than installed by a JS layer above it.

They arrive as lazy globals: each is registered with V8's SetLazyDataProperty before the context exists, so the builtin behind a name is never compiled, run or allocated until app code first reads it, and after that read it is a plain data property. A native ObjC or Java symbol sharing a name can never shadow one. Matching Node, ns:util and node:util export the very TextEncoder/TextDecoder objects the globals hold, whichever is reached first. And @nativescript/core was taught to step aside: its polyfills now probe with the in operator, which does not trip lazy materialization, and install only when the runtime does not already provide a global, so a JS fallback never shadows a native implementation.

requestAnimationFrame

requestAnimationFrame / cancelAnimationFrame are standard globals on both platforms now, workers included, with spec semantics: every request is its own one-shot entry with a numeric handle, and the callback receives one DOMHighResTimeStamp on the isolate's performance timeline. On iOS that is one CADisplayLink per isolate, created paused and running only while callbacks are pending, so an idle isolate never wakes per frame; the link's timestamps share the mach_absolute_time base with the performance clock, so a callback observes the vsync instant rather than dispatch time, and every callback in a batch sees the same frame time. Android exposes the standard names over its existing Choreographer-backed frame machinery. __postFrameCallback / __removeFrameCallback remain as the compatibility surface, with the same contract on both platforms.

URL, URLSearchParams, URLPattern

Both runtimes moved their URL stack to Ada v4, and url.searchParams now honors the WHATWG same-object guarantee under mutation from either side: one URLSearchParams per URL for its lifetime, resynced in place when url.search or url.href is assigned, so a reference held across a reassignment never reads a stale query and never writes the old one back over the new. URLSearchParams construction and iteration were brought to spec, and URLPattern capture groups work in test()/exec(); the behavioral edges are in Migration notes.

Node-API for native addons

Both runtimes now expose a standard napi_* C ABI. Node.js v26.7.0's engine-independent js_native_api sources are vendored byte-identical; only what Node builds on libuv, node::Buffer and its module loader is reimplemented against CFRunLoop and the Android looper. napi_get_version reports 10. There is one napi_env per runtime (workers get their own, with their own addon instances), and the threading contract is Node's: every call on the thread that owns the env.

The async surface is real, not stubbed. Threadsafe functions have Node's queue semantics; napi_async_work runs on a 4-thread pool (libuv's default, so an addon that fans out more than four interdependent blocking executes deadlocks here exactly as it would on Node); completions and finalizer drains ride the event loop, and finalizers never run during GC. The divergences are tabulated in the doc: buffers are plain Uint8Arrays, there is no napi_get_uv_event_loop, and a blocking threadsafe call from the env's own thread with a full queue returns napi_would_deadlock rather than wedging the run loop. They are identical on both platforms by design, so one addon source targets both.

On Android the headers ship as a header-only Prefab package (find_package(NativeScript REQUIRED CONFIG)) and no useV8Symbols flag is needed. On iOS it is #include <node_api.h> and a static constructor calling napi_module_register; JS reaches the addon with require("<name>"). The test suites were ported from Node's own test/js-native-api.

console.log that cannot hang your app

console.log(obj) without DevTools attached used to serialize the whole object graph through JSON.stringify with an O(n²) cycle check and no depth or size limit. A large enough object could stall the app for seconds, and shared acyclic references printed as [Circular] when they were not.

The runtimes now share a budgeted util.inspect-style formatter: depth 2 (console.dir gets 4), 100 entries per collection, 10k characters per string, and a 16 KB hard cap per call with an explicit truncation marker. True cycles are detected with an ancestor set. Getters are never invoked, with two exceptions: a guarded error.stack read and custom toString overrides, so @nativescript/core's Button(42)-style debug names still print. Native wrappers render as short hints like [UILabel]. console.* also gained Node's % substitution. Output now looks like Node's inspect output rather than pretty-printed JSON; don't parse it.

Behind the formatter is a rebuild of how runtime JavaScript is shipped at all. The ~26 KB of JS that used to be embedded as C++ string literals is now real .js files compiled at build time into a builtins table, following Node's module-wrapper convention, with Node-style primordials: a frozen, null-prototype snapshot of the intrinsics that every builtin goes through, so app code that overwrites Array.prototype.slice or JSON.stringify can no longer break event dispatch or the console. Under --jitless (which is how the runtime always runs on device), uncurried primordial calls cost 5–12% per operation versus a raw method call; we took that for the guarantee. Builtins are compiled once per process with a shared bytecode cache, so each worker's startup consumes the cache instead of re-parsing. And two steady-state regressions fell out of the audit: the iOS runtime was recompiling an enum's __tsEnum snippet on every FFI marshal, and recompiling a global's getter snippet on every read. Both are memoized now.


Performance on iOS

Both V8 and CFString store text as either 8-bit or UTF-16. They never store UTF-8. The iOS bridge nevertheless encoded every string to UTF-8, allocated, and decoded it again to move between two representations that already matched: one malloc per conversion, 10,061 mallocs per 10k calls, ~880 KB held until the autorelease pool drained.

9.1 hands V8 the interior pointer. ASCII feeds NewFromOneByte; UTF-16 feeds NewFromTwoByte; shapes exposing neither are copied once into a stack buffer. Foundation-side, -O2, arm64, 300k iterations:

NSString → V8 before after
ASCII, 15 chars 47.8 ns 8.2 ns
ASCII, 4000 chars 1040 ns 8.0 ns
non-ASCII, 17 chars 148 ns 7.1 ns
CJK, 400 chars 1893 ns 7.3 ns
tagged pointer 54.2 ns 6.2 ns

Those are microbenchmarks with V8's own copy excluded; end to end, multi-kilobyte strings settle around 3–8× faster. Going the other way, tns::ToString reads V8's buffer with ValueView so property names, selectors and class names under 22 characters convert without touching the heap. Three real bugs fell out: lone surrogates were silently dropped on the round trip, a unichar return path read out of bounds, and nil from -UTF8String was being strlen'd at several error-reporting sites.

The metadata file every iOS app ships got 6.5% smaller (12,033,254 to 11,251,503 bytes against the full iOS 26.2 SDK, same 63,878 declarations from 197 modules) from three independent reductions in the generator: an interning map that was being duplicated (4,519 strings stored twice), 95,253 distinct empty arrays that are now one, and constructor tokens elided for the 57,933 of 60,589 methods that have none. Verified by rendering both files to an offset-independent form: 188,518 lines, zero differ. Mac Catalyst metadata generation also works now (UIKit lives under System/iOSSupport in the macOS SDK and was never found).


Workers

Most multi-isolate bugs turn up in workers, and 9.1 spent real time there on both platforms.

  • Chrome DevTools attaches to worker isolates on iOS. Each worker gets its own V8Inspector session on its own thread; the main session routes Target.setAutoAttach / Target.attachedToTarget natively, so a worker stays debuggable while the main isolate is paused, and vice versa. Console messages logged before the frontend attaches are stored and replayed as history, for the main isolate too.
  • new Worker(url, { iosPriority }) maps to NSQualityOfService (userInteractive through background).
  • A named extend() from a worker used to register its ObjC class name into the process-global namespace verbatim, so a worker that won the startup race could demote the main isolate's class to TimerTargetImpl1, breaking NSClassFromString, storyboards and symbolication. Worker-created names are now suffixed _<isolateId>; main-isolate names are byte-exact and deterministic regardless of startup order. A second race (two workers registering the same name between objc_allocateClassPair and objc_registerClassPair, then deadlocking on the class-init lock) wedged roughly one run in eight before; it is serialized under an os_unfair_lock now, 42 for 42 after.
  • On Android, a std::map keyed by Isolate* was racing across concurrent worker bootstraps and crashing about one run in five. Per-runtime state moved into a typed RuntimeState slot bag, destroyed with the isolate. ObjectManager got its first destructor; enough worker churn used to exhaust ART's JNI weak global reference table and abort the process, not just leak. A 1 MB buffer shared between concurrent module loads could silently corrupt module source without ever crashing; that is per-load now.
  • Worker termination during module load no longer aborts on Android. A worker entry whose top-level await rejects now follows the web's dispatch order on iOS: worker-scope onerror first, then the parent's error event. A known gap: worker.terminate() cannot reach a worker still parked inside its entry (#445).

DevTools

  • External source maps work again on iOS. DevTools stopped fetching maps itself for remote targets and instead issues Network.loadNetworkResource + IO.read, none of which V8's inspector implements, so every app was stuck with bloated inline maps. All three are now served natively, and sourceMapURL is rewritten to a custom nsruntime:// scheme because DevTools hard-excludes file:. Opt out with ios.disableSourceMapURLRewrite if you must.
  • The Tracing domain is protocol-correct and fast on both platforms: Tracing.tracingComplete is always sent (an empty trace no longer hangs DevTools), 1000 events per Tracing.dataCollected message instead of 20, and the domain is handled on the socket thread before taking the v8::Locker, so flushing a large trace never blocks JS. A use-after-free that the previous teardown always hit is fixed.
  • The iOS profiler generates its output entirely in C++ with far less string copying, so long, accurate traces are now practical. Debugger.pause actually interrupts V8 instead of waiting for the main thread to go idle.

Typings you can trust in strict mode

  • iOS: generated .d.ts now marks every pointer return type | null (the runtime returns null for a null pointer, never a pointer at address 0), and every pointer parameter | null except where the header says _Nonnull. Across the iOS 18.5 SDK, 20,419 parameters gained the union and 2,549 correctly did not. Expect new errors in strict apps; they are legitimate.
  • Android: Kotlin internal members no longer leak into metadata or typings; they were callable from JS under mangled, build-variant-dependent names. Nullable Kotlin types now generate | null unions: 4,565 of them at compileSdk 35 with no other change. kotlin-metadata-jvm 2.4.10 reads newer metadata leniently, which fixes a real startup failure ("Unable to create application") for apps on Kotlin 2.4.x whose classes were silently dropped.
  • On iOS, calling a method on an already-disposed native object now throws a catchable error naming the method, class and selector, instead of logging and returning undefined. And a resurrected wrapper touching a released native object (the one case the finalizer patch above cannot rule out) is governed by releasedObjectPolicy in ns:runtime: "report" (default) fires a cancelable releasednativeaccess event on globalThis with the touch site's stack; "throw" raises a ReferenceError at the touch site. Before 9.1 this was a fabricated pointer and, eventually, a segfault.

@nativescript/core 9.1

NativeWindow on both platforms

9.0 introduced multi-window on iOS. 9.1 makes NativeWindow a cross-platform primitive and the single model for how content reaches a screen. The motivation is practical: iOS 27 makes UIScene mandatory, Android's single-root-activity assumption only existed because everything bound to one activity, CarPlay and Android Auto need a home, and there are Windows runtimes coming. The new multi-window capabilities on Android are experimental and we welcome you to try them!

  • WindowBase carries identity, role and lifecycle; NativeWindow is the platform surface. Roles are application, embedded, carplay and externalDisplay.
  • Identity is session-scoped and survives the native surface going away: iOS keys off UISceneSession.persistentIdentifier, Android mints a UUID persisted in savedInstanceState. A scene disconnect or activity recreation raises detached; the window stays registered. A real close raises close once.
  • Content flows through window.setContent(), and Application.setWindowContentResolver() supplies each window's UI on demand. ready fires exactly once per JS context.
  • Application.primaryWindow, getWindows(role?), getWindowById(), and openWindow(options?), which is real on Android as a new activity and marked experimental there.
  • Orientation, appearance and layout direction are per window, as are the root CSS classes that follow them.

There are breaking renames (NativeWindow.iosWindow.ios, androidWindow.android, SceneEventData.window is now the NativeWindow with the UIWindow at .uiWindow), and getWindows() is role-filtered by default. The launch event and Application.orientation()/systemAppearance()/layoutDirection() are deprecated but still work. The test suite for this area went from 212 to 287 tests, and several long-standing bugs were fixed on the way; Android's exit event could be lost entirely when a third-party SDK activity outlived the main one.

CSS

  • max-width / max-height, fixed or percentage, on both platforms, applied after the min clamp so max wins when they conflict.
  • Flexbox gap, row-gap, column-gap. Android's FlexboxLayout.java was substantially rewritten to support them (net −670 lines).
  • corner-shape: round | squircle (CSS Borders Level 4) on iOS. squircle maps to kCACornerCurveContinuous, the smooth corner curve Apple uses across system UI; round keeps the circular default, so nothing changes for existing apps. The curve applies where corners render through layer.cornerRadius (the uniform-border fast path); path-drawn corners (non-uniform borders, outlined box-shadows, clip masks) stay circular for now, and Android always renders round. corner-shape only recently became addressable on the web at all (Chrome 139), so this subset is the supported one.
  • Sibling combinators are live. + and ~ selectors matched once at load and never re-matched on sibling insert or remove, which silently broke Tailwind's space-* and divide-* utilities (compiled to > * + *). They re-match now.
  • Localized text-transform. Casing respects the current locale instead of hardcoded English, with a cross-platform capitalize ported from Apache Commons.
  • ScrollView.iosContentInsetAdjustmentBehavior (default never) and ListView.iosSearchInsetBehavior for safe-area-aware layouts.

A cascade that follows the spec

A late-cycle overhaul of CSS matching, cascade and application fixed two long-standing correctness bugs and removed most of the redundant work a dynamic restyle used to do:

  • Shorthands expand into their longhands at parse time, which is how the spec defines the cascade (a shorthand declaration is equivalent to declaring each longhand in its place). Expansion never actually ran before (the expanding setter was defined on the property bag's constructor instead of its prototype), so label { margin: 1 } .a { margin-top: 8 } #x { margin: 4 } left margin-top at 8, ignoring the most specific rule. A shorthand containing var() or calc(), which cannot be expanded lexically, cascades as a pending-substitution value per the CSS Variables spec and resolves once per view at apply time.
  • The "skip unchanged values" diff on dynamic restyle was dead code; the previously applied entry was deleted before it was compared against, so tapping a button re-applied the view's entire computed style, re-parsing colors and lengths that had not changed. It works now, and shared values diff by identity.
  • Attribute selectors answered "may match" with an unconditional yes and subscribed to change events that plain expando assignment never raises. Under Angular's emulated encapsulation (the [_ngcontent-*] markers), every view carried selectors it could never match and subscriptions that could never fire; this is the main reason component-scoped CSS performed badly enough to be discouraged. They now test whether the view actually knows the attribute, and subscribe only to properties that can notify.
  • Application stylesheets are indexed once, globally, instead of once per style scope, mirroring Blink's scoped-resolver architecture; registering a component stylesheet appends to the shared index instead of re-sorting every scope in the app.
  • Stylesheet parsing is synchronous again, so a parse error is reported through the error path instead of leaving the stylesheet silently empty.

From the in-repo benchmarks (jitless, which is how V8 runs on device): re-applying styles to 200 views where nothing changed is 3.0× faster, toggling a class on 200 loaded views 2.7×, toggling a pseudo-class 2.3×, registering 30 stylesheets and restyling 2.3×. Building a style scope got about 10% slower, on purpose: !important stripping and shorthand expansion moved into parsing, paid once per stylesheet instead of once per view per update.

Hot-path performance

A pass over events, property resolution, CSS matching and iOS layout, with the numbers from the benchmark suite (vitest bench, Node 25, darwin arm64):

Benchmark before after Δ
Observable.notify, 1 listener 17.5M ops/s 23.4M ops/s +33.9%
Observable.notify, 3 listeners 11.75M 14.39M +22.5%
notifyPropertyChange 18.3M 22.35M +22%
selector query, button with id + 4 classes 282,946 392,871 +38.9%
selector query, plain label 434,778 518,608 +19.3%
AttributeSelector.match (ignoreCase) 22.2M 26.5M +19.2%

The individual changes are mundane: .call instead of .apply so a listener fire allocates no array, a typeof bail before string comparisons, push accumulation instead of reduce + concat, media-query strings split once and cached, and the iOS layout helper caching screen scale instead of resolving window → screen → scale through the runtime on every dp/px conversion. The automated iOS suite (1,808 tests) went from 49.9s to 43.0s, -13.8%.

A regression introduced in 9.0.0 is also fixed: Observable spliced listeners by a stale index, so a once listener removing itself during notify could leave later once listeners permanently armed. It deterministically broke Application.once('launch', …).

Shared element transitions

Interactive dismiss gained a morph option: the destination scales and translates with the finger, springs back on cancel, and morphs into the source element's frame on finish. Gesture phases are tracked so a vertical scroll no longer falsely engages dismiss, SharedProperties gained cornerRadius, and shadows carry through shared-element snapshots across present and dismiss, including inside TabView.

Stability

Fixes worth knowing about: Android activity recreation with tabs or complex hierarchies no longer crashes ("Don't keep activities" restores orphaned ViewPager2 fragments); CustomTransition no longer leaks the whole navigation history through a shared AnimatorSet; SegmentedBar/TabView items are detached before re-adoption so recycled list items stop throwing "View already has a parent"; iOS apps built with a pre-26 SDK running on iOS 26 devices no longer crash on the swipe-back gesture; duplicate suspend/resume events under UIScene are gone; and Image on Android finally updates imageSource after an async load (issue #6035; yes, that number is correct).

Two more took serious debugging to find. crypto.getRandomValues on iOS handed its caller's V8-owned buffer to Foundation to free (dataWithBytesNoCopy: defaults to freeWhenDone:YES), a deferred double free that surfaced as rotating worker-thread crashes half a minute into a session; it backs crypto.randomUUID(), so it fired constantly. The same audit fixed an iOS file-write path that freed an interior pointer of another NSData's buffer, stopped Android's getRandomValues from overwriting every byte from a typed-array view's offset to the end of the underlying ArrayBuffer, and made both platforms hand back the caller's own view, as WebCrypto requires. And sectioned ListViews resolve item templates correctly again.


Vitest on device

@nativescript/unit-test-runner 5 is a ground-up rewrite around Vitest, and it is the first version to run on visionOS.

It is not a browser-mode shim and not a jsdom adapter. nativeScript() is a Vitest pool plugin (PoolRunnerInitializer) that opens a WebSocket on 127.0.0.1:17878, launches the app with ns run <platform> --no-hmr --env.unitTesting, and then runs the actual @vitest/runner (collectTests and startTests) inside the NativeScript runtime on the device, bridging Vitest's worker protocol over birpc with circular-safe serialization. Slot 0 is the main thread, so specs can mount real Views, run a real layout pass and call native APIs; workers: N adds isolated Worker runtimes for parallel non-UI specs.

// vitest.config.mts
import { defineConfig } from "vitest/config";
import { nativeScript } from "@nativescript/unit-test-runner";

export default defineConfig({
  plugins: [nativeScript({ platform: process.env.NS_PLATFORM ?? "ios" })],
});
ns test init --framework vitest
ns test ios          # routes to Vitest when a vitest.config.* is present
ns test visionos     # new; Karma never supported visionOS
NS_PLATFORM=android npx vitest run

@nativescript/unit-test-runner/testing exports mount, tap, doubleTap, longPress, enterText, returnPress, waitForLayout, waitUntil and nextRenderPass for UI specs against real views. Coverage is Istanbul (device runtimes do not expose V8 coverage). The Karma path still works behind a deprecation notice; docs/migrating-from-karma.md maps Jasmine/Mocha/QUnit syntax across. Not there yet, and listed as such: vi.fn/vi.spyOn/fake timers, snapshots, watch mode; and vi.mock will not be, since specs are a static bundle; prefer injection. The host↔device design originated in @cross-code/vitest-ns by @listepo, and we are grateful for it.


CLI 9.1

Beyond owning the Vite dev server, the CLI had a large housekeeping release.

  • Project-local delegation. A global ns that finds a project-local nativescript install hands off to it, Angular-CLI/Nx-style version pinning. Opt out with --no-local-cli or NS_CLI_NO_LOCAL=1.
  • Option validation is back. The unknown-option validator had been silently dead for five years (it looked options up by value instead of name). It returns as a staged warning; NS_STRICT_OPTIONS=error makes it hard-fail, a preview of the future default.
  • Typed extension and hook authoring. defineHook({ name, run }) with ctx.fail() / ctx.skip() and explicit wrap() middleware; defineCommand plus a lazy-loaded nativescript.commands map in an extension's package.json; all exported from a side-effect-free nativescript/contracts entry. Two long-standing hook bugs went with it: hook gating used an ES2017-only parser, so any hook using ??, ?. or class fields silently never ran (exit code 0). See the hooks guide.
  • nativescript.config additions: buildPath (where native projects are generated, default platforms); per-platform runtimePackageName to pin an alternate runtime package; ns clean now refuses to delete anything outside the project directory.
  • Android: --gradleFlavor <name> builds a product flavor (assemble<Flavor><Debug|Release>); android-36.1 is a recognized target; bundletool is downloaded on demand with a pinned SHA-256 instead of vendored, removing ~32 MB from the npm package (NS_BUNDLETOOL_PATH to override).
  • iOS / Apple: Xcode 26's explicit-modules default broke Swift macro packages, so the CLI passes SWIFT_ENABLE_EXPLICIT_MODULES=NO and -skipMacroValidation; DEVELOPMENT_TEAM is forwarded so macro targets sign for device; NS_PACKAGE_AUTHORIZATION_PROVIDER reaches xcodebuild -packageAuthorizationProvider for private Swift package registries; the app's build.xcconfig now correctly wins over a plugin's, with a warning; generated modulemaps moved out of node_modules; watchOS targets gained a declarative config covering modules, frameworks, resources, linker flags and SPM packages; and full syncs to a physical iOS device are verified after transfer; the CLI used to report "Successfully synced" when the upload had silently failed.

Migration notes

Nothing here requires changes to a working 9.0 app to run. Most of this list is defaults that moved toward the web, or things that used to fail silently and now fail loudly.

Runtime behavior

  • Uncaught errors report and continue by default in release. Set uncaughtErrorPolicy: "throw" to restore crash-on-uncaught. discardUncaughtJsExceptions is deprecated (still honored).
  • Unhandled promise rejections are now reported. They used to vanish.
  • require() of an ES module containing top-level await throws. Use import() or createPumpingRequire from ns:module.
  • Unshimmed node: imports throw No such built-in module. The old empty-object polyfill is gone.
  • An unresolvable require()/import throws Cannot find module '<specifier>'. The old "optional module" placeholder never worked on any V8 version; it is removed.
  • console.log output is Node-inspect style with depth, entry and size budgets.
  • Remote ES modules are deny-by-default in release builds (security.allowRemoteModules).
  • TextEncoder/TextDecoder, atob/btoa, AbortController/AbortSignal, DOMException, CustomEvent, requestAnimationFrame and the standard timer names are provided by the runtimes as lazy globals; @nativescript/core's polyfills install only when a global is missing and no longer overwrite a runtime-provided crypto.
  • Failures from atob/btoa, AbortSignal default reasons, structuredClone and the performance API are real DOMExceptions (previously name-patched Errors). err.name comparisons are unaffected; instanceof DOMException now works.
  • iOS: URLSearchParams.get() returns null for a missing key (was undefined); iterators are live. URLPattern with capture groups actually works now.
  • iOS: availability filtering is real on visionOS; APIs that leaked through will now be correctly hidden. The runtime test suite passes 849/849 on visionOS 26.2.
  • iOS: worker-created named ObjC classes are suffixed _<isolateId>. Main-isolate names are unchanged.
  • Android: SIGSEGV/SIGABRT are no longer converted into catchable JS exceptions; native crashes produce real tombstones.

Build and toolchain

  • iOS plugins that include v8.h must compile at C++20+. If a plugin pins CLANG_CXX_LANGUAGE_STANDARD = c++17 in its build.xcconfig, the CLI's xcconfig merge (plugins first, first-writer-wins) will hold the whole app below the floor and the error will point at the runtime's headers. The escape hatch is to assign CLANG_CXX_LANGUAGE_STANDARD after the plugins-*.xcconfig include in your build-{debug,release}.xcconfig.
  • The iOS runtime binary resolves via SwiftPM from github.com/NativeScript/ios-spm. CI must be able to resolve a Swift package.
  • Android: NDK r29. minSdk 21 and compileSdk 35 unchanged.
  • Generated iOS typings mark pointer returns and non-_Nonnull parameters nullable; Android typings mark nullable Kotlin types | null. Strict-mode TypeScript will surface new errors.

@nativescript/core

  • NativeWindow.iosWindow.ios (.window.uiWindow); androidWindow.android; SceneEventData.window is the NativeWindow; getWindows() is role-filtered ('all' for everything); Android exit fires when the last window finishes; suspend/resume reflect whole-app state in scene mode; use per-window background/foreground.
  • Android edge-to-edge: pages consume overflow by default.
  • CSS shorthands expand at parse time, so the cascade resolves by specificity correctly; a rule that only won through the old shorthand bug stops winning.
  • crypto.getRandomValues(view) returns the view you passed in (it returned a coerced Uint8Array for non-byte arrays) and on Android no longer writes past the view's window.

What's next after 9.1?

The module system is the foundation this release was built to lay, and the next few are already shaped by it:

  • Native ES class instance identity. Today a JS peer for a native-born object (a cell from dequeueReusableCell, a view from initWithCoder:) is an empty object with the prototype grafted on, so #private fields and class-field initializers are not there. The fix (super() binds an existing native instance instead of allocating a second one) is implemented on a branch with a design record in the runtime repo. It inverts one contract (alloc().init() will run the JS constructor), which is why it is not in 9.1.
  • Liveness by tracing. Retiring the resurrecting-finalizer patch in favor of CppHeap/TracedReference, so the "released object" case above stops being possible.
  • Source-text ns: modules, worker waitForDebuggerOnStart, Node-API .node dylib loading, structured-clone support for DOMException per its Web IDL [Serializable] slot, and a Svelte strategy for Vite HMR.
  • @nativescript/foundation as the evolution of @nativescript/core, and continued cross-engine work now that runtimePackageName lets a project pin an alternate runtime.

We're excited to see what you build with 9.1; please share your feedback, questions and experiments in the community Discord.


Join our Discord Community

📣 Join us and say Hello!

Need professional help with your projects?

Contact any of our Partners for assistance.

Thank you

We would like to thank our thoughtful community for their continuous input, contributions and support across Open Collective and GitHub Sponsors ❤️ In addition to our family throughout the OpenJS Foundation, without all of you, this release would not have been possible.

Join the conversation

Share your feedback or ask follow-up questions below.