Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions content/guide/pathways.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
title: Two pathways
description: NativeScript is your development runtime, not your production runtime. Write SwiftUI and develop it live, or write TypeScript and compile the release to native code.
contributors:
- NathanWalker
---

NativeScript has always run your app on a JavaScript runtime with direct access to every platform API. It now offers
two pathways that keep that development experience and ship **no JavaScript runtime at all**:

| | 1. SwiftUI, live | 2. TypeScript, compiled |
| --- | --- | --- |
| **You write** | SwiftUI, in an Xcode project | TypeScript with `@nativescript/core` and the framework you like (Angular, Vue, React, Svelte, Solid, plain TypeScript) |
| **While developing** | NativeScript runs your SwiftUI views live: each save is on screen in about 0.15–0.5 s, state kept | `ns run`: the JavaScript runtime with HMR, Chrome DevTools and the whole npm ecosystem, exactly as today |
| **What ships** | Your Swift, built by Xcode as usual | Your app compiled to Swift (iOS) and Kotlin (Android) by `ns build --compiled` |
| **Platforms** | iOS; Android live development, rendered by Jetpack Compose | iOS and Android |
| **Status** | [Preview](/guide/swiftui-live) | [Preview](/guide/publishing/compiled) |

Both rest on the same idea: **develop live, ship native.** The development runtime gives you instant feedback; the
release is the native code a platform developer would have shipped.

## Which one fits

- **You know SwiftUI, or you are starting an iOS-first app in Swift**: take [SwiftUI, live](/guide/swiftui-live).
You keep Xcode, Swift and your project as they are; NativeScript only shortens the edit-to-screen loop.
- **You know web development, or you already have a NativeScript app**: take
[TypeScript, compiled](/guide/publishing/compiled). Nothing changes while you develop. The release build is
compiled instead of bundled.

The two meet at the release: both ship native code with no engine inside, and both are checked the same way,
pixel by pixel against the build they replace.

## The JavaScript runtime is not going anywhere

A release on the JavaScript runtime (`ns build --release`) remains the default and is fully supported. Compiled
releases are opt-in, per build or per platform, and you can switch back with `--no-compiled` at any time. See
[Publishing](/guide/publishing/).
254 changes: 254 additions & 0 deletions content/guide/publishing/compiled-differences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
---
title: "Compiled releases: supported subset and known differences"
description: What you trade for a compiled release, what the build refuses, and where a compiled release still behaves differently from the JavaScript release.
contributors:
- NathanWalker
---

<!-- Each run-time entry was checked on 2026-10-13 by a probe run under Node and compiled for iOS and Android, with the compiler's differential harness (packages/compiler/tests/diff). Re-check an entry before changing it, and remove it when its fix lands. -->

A [compiled release](/guide/publishing/compiled) runs your TypeScript as Swift and Kotlin, against a runtime that
reproduces JavaScript's semantics. This page lists where that stops: what the build refuses, and where a compiled
release still behaves differently from the JavaScript release of the same app. Each entry names the platform it
affects.

## Before you choose `--compiled`

What you gain:

- **No JavaScript engine in the app.** Your TypeScript, your framework and `@nativescript/core` ship as Swift and
Kotlin.
- **Smaller apps that use less memory.** Recipes (Vue): `.ipa` 2.35 MB instead of 14 MB, 18.9 MB of memory after
launch instead of 47.3 MB. openjs-app (Angular): `.ipa` 17.3 MB instead of 27.8 MB, 40.0 MB installed instead of
79.9 MB. On Google Play, about half the download ([measurements](/guide/publishing/compiled#the-short-version)).
- **Not a faster launch.** Recipes reaches its first frame in 191 ms compiled and 190 ms on the JavaScript runtime.

What you give up:

- **Some JavaScript semantics.** Most are reproduced; the [differences below](#known-differences-at-run-time) are
not, and they show only when the app runs.
- **Code made or loaded at run time.** `eval`, `new Function`, `Proxy`, `Reflect` and a computed `import()` are
refused.
- **Over-the-air updates.** There is no JavaScript bundle to replace. Updates go through the stores.
- **Build time.** The first iOS build compiles NativeScriptKit, about three minutes for a blank app. A rebuild after a
small change takes about a minute on iOS, and 45 seconds on Android with R8.
- **Plugins built from their own source.** The first build fetches each plugin's TypeScript source with git, and the
source must match the published JavaScript. Some native packaging is not carried yet (see
[What stops a build](/guide/publishing/compiled#what-stops-a-build)).
- **JavaScript debugging.** `ns debug` and Chrome DevTools stay with development builds. A compiled release is
debugged as a native app in Xcode or Android Studio, and `error.stack` lists native frames.
- **Care with R8 on Android.** A member reached by a name computed at run time can be renamed or removed by
minification ([Android release builds](#android-release-builds-r8)).
- **`fetch` and `XMLHttpRequest` on Android.** They are available on iOS only.

You develop on the JavaScript runtime and ship compiled code, so a difference shows only in the release. Check every
compiled release against its JavaScript release before you ship it
([Checking a compiled release](/guide/publishing/compiled#checking-a-compiled-release)).

## What the build refuses

Before anything is compiled, the compiler checks the app (and the plugin code the app reaches) and stops on each of
these with the file, the line and what to write instead. All of them are reported at once.

| Construct | Why | Instead |
| --- | --- | --- |
| `new Proxy(…)` | No traps without a JavaScript engine | A class with explicit getters and setters, or a `Map` for dynamic keys |
| `Reflect.*` | Not provided by the runtime | The operation itself: `obj[key]`, `key in obj`, `Object.keys(obj)`, `new Cls(...args)` |
| `eval(…)`, `new Function(…)` | There is no engine to evaluate code | A lookup of known functions; or keep this app on the JavaScript release |
| `FinalizationRegistry` | Finalizers do not run | Release the resource explicitly (`dispose()`, `unloaded`, `disposeNativeView`) |
| `SharedArrayBuffer`, `Atomics` | No shared memory between workers | An `ArrayBuffer`, with copies posted between workers |
| `with (obj) { … }` | | Name the object explicitly |
| `import(expr)`, `require(expr)` with a computed specifier | Every module is linked when the app is built | Import each module by a literal path, and choose among those at run time |
| `arguments.callee`, `__proto__` | | A named function; `Object.getPrototypeOf` |
| A bundler constant nothing defines (`declare const __API_URL__`, `process.env.X`) | It would read as undefined at run time | Define it in the bundler config: Vite's `define` and webpack's `DefinePlugin` values are read and compiled in |

The build also stops, before compiling, when:

- **The app's `@nativescript/core` is not the core the compiler's kit was generated from.** The kit is core's own
code, compiled; the native widgets it drives come from your installed core, so the two must be the same release.
Install the core version the message names, or the `@nativescript/compiler` released with your core.
`release: { allowCoreMismatch: true }` builds anyway, with a warning.
- **A framework package is outside the versions its support is tested with** (table below).
`release: { allowUntestedFrameworks: true }` builds anyway, with a warning.
- **The toolchain is missing or too old:** Xcode 26 or newer for iOS; JDK 17 or newer and the Android SDK for
Android.

Some library members are not provided yet, and the compiler stops where it meets them, with the file and line:
`Object.create`, `String.prototype.normalize`, `Intl.Collator`, `Intl.PluralRules`, `Intl.RelativeTimeFormat`, a
class expression (`const Mixin = (B) => class extends B {}`), an array literal with holes (`[1, , 3]`), and on iOS
`Number.prototype.toExponential`. `Intl.NumberFormat`, `Intl.DateTimeFormat` and `toLocaleString` are provided. A
few valid constructs still fail later, in the Swift or Kotlin compile, at your TypeScript line, such as reading a
function's `length` or `name`. Other unsupported language and framework constructs are
listed in [What stops a build](/guide/publishing/compiled#what-stops-a-build).

## Tested framework versions

| Framework | Packages and versions |
| --- | --- |
| Vue | `nativescript-vue` 3.1 and later 3.x |
| Angular | `@nativescript/angular` and `@angular/core` 22.x |
| React | `react-nativescript` 5.x with `react` 18.2 or later 18.x |
| Solid | `@nativescript-community/solid-js` and `solid-js` 2.0 (from 2.0.0-rc.0) |
| Svelte | `svelte-native` 1.x with `svelte` 4.2+, or `svelte-native` 5 (from 5.0.0-alpha.0) with `svelte` 5.50+ |
| Octane | `@nativescript-community/octane` 0.2.4 and later 0.2.x |

Framework features each front end does not support yet stop the build with a message. The main ones:

- **Angular:** under zone.js, a component that does not set `changeDetection: ChangeDetectionStrategy.Default`
(Angular 22's default is OnPush); pipes other than `async`; a pipe inside `*ngFor`/`@for`; more than one
`@Component` in a file. Templates are parsed with the compiler's own `@angular/compiler` 22.
- **React:** a component body may hold function declarations, variable declarations and the `return`; a bare
statement such as `useEffect(() => …, [])` on its own line is refused. JSX spread attributes are refused.
- **Vue:** `<style module>`, `<style src>`, a prop `default`, and Options API keys other than `name`, `components`,
`props`, `emits`, `data`, `computed`, `methods`, `watch` and `setup`.

## Known differences at run time

These compile and run, and behave differently from the JavaScript release. The most likely to affect an app come
first.

### `null` and `undefined` are one value in a typed optional (iOS and Android)

Where a type admits both (`name?: string | null`), `=== null` and `=== undefined` do not tell them apart:
depending on where the value is held, an explicit `null` reads as `undefined`, or as both, and `undefined` passed to
a parameter `x?: string | null` can read as `null`. A destructuring default applies to `null`. Where the value is
held typed (a variable, a return value, a class field, a write such as `o.name = null`), `JSON.stringify` leaves
the key out instead of writing `null`. On iOS, a `null` written in an object literal or
read by `JSON.parse` is kept by `JSON.stringify` and `Object.keys`; on Android it is not. On iOS,
`map.get(key)` of a `Map<K, V | null>` holding `null` is also `undefined`.

**Instead:** test with `== null`, which is true for both. Where an API needs an explicit `null` in JSON, write it in
the object literal you send (iOS), or build that JSON by hand.

### A class's getters, setters and methods are not found by name (iOS)

In an app built with a framework (Angular, Vue, React, Svelte, Solid, Octane), a member a class declares as a getter,
a setter or a method is undefined when read through `any` or a computed key: `(order as any).total`,
`order[key]`, `typeof x.render === 'function'`, `'total' in order`. An assignment through `any` does not run the
setter, and the value is lost. Fields are found. Android finds all of them; so does iOS in a plain TypeScript app
with XML, where core's bindings read them.

**Instead:** read members through a typed reference. For duck typing, test a field, or use `instanceof`.

### A value of the wrong type throws a `TypeError` (iOS and Android)

JavaScript keeps a value of the wrong type and carries on; a compiled release throws a `TypeError` you can catch. It
happens when an untyped value (`any`, parsed JSON) is assigned where a class, a string, a number or a boolean is
declared, and on a downcast to the wrong subclass (`shape as Circle` holding a `Square`). An out-of-union value in an
exhaustive `switch` throws on Android and gives the type's zero (`NaN`, `''`, `false`) on iOS.

Parsed JSON typed as an interface reads a missing string field as `''` and a missing number as `NaN`, where
JavaScript has `undefined`. A field holding the wrong JSON type is converted on iOS (`"x"` in a number field reads
`NaN`) and throws a `TypeError` on Android.

**Instead:** validate parsed data before you type it, and mark fields that may be missing optional (`age?: number`).

### Functions in arrays, `Set`s and `Map`s are not found again (iOS; Android for methods)

A function read twice is `===` to itself, so `obj.off('tap', handler)` and core's `removeEventListener` work. But on
iOS a function stored in an array, a `Set` or as a `Map` key is not found by `indexOf`, `includes`, `has`,
`delete` or `get`: an emitter that keeps its listeners in a list adds the same handler twice and never removes it.
On Android this applies to a method read as a value (`[this.onTap].indexOf(this.onTap)`); arrow functions are found.

**Instead:** have `on()` return a subscription object or an id, and remove by that. Keep listeners in a `Map`
keyed by a string or that id.

### Pages and views can stay in memory (iOS; `WeakMap` and `WeakRef` on both)

iOS frees objects by reference counting. A closure that a page's view holds and that captures the page
(`button.on('tap', () => this.save())`, an Angular or Vue handler) is a cycle, so a page you navigated away from can
stay in memory with its views. Core's own references (parent views, CSS state, gesture observers) do not cause this.

A `WeakMap` value that refers to its own key keeps both alive on iOS; on Android, only for keys other than plain
objects and class instances. On Android, a `WeakRef`'s target is held until memory runs low, not until the next
collection.

**Instead:** remove the listeners you add in `unloaded` or `navigatedFrom`, or have the closure reach the page
through a `WeakRef`.

### Native callbacks from other threads run on the main thread (iOS and Android)

A callback a native API calls on a background thread (an `NSURLSession` delegate queue, an audio tap,
`ConnectivityManager`) runs on the main thread; on Android, a callback on an object a worker made runs on that
worker's thread. One that returns nothing runs later, as a task. One that returns a
value runs while the calling thread waits for it, so it deadlocks if the main thread is waiting for that thread.

**Instead:** don't block the main thread on a native thread that calls back into your code. Do heavy work in a
[worker](/guide/multithreading) or in native code.

### A capture group that did not match is not `=== undefined` (iOS and Android)

For `/(a)|(b)/.exec('b')`, group 1 is not `=== undefined`, and `??` does not replace it. On iOS it reads `''`; on
Android it prints `undefined`. The same holds for `match()`, for a `replace` callback's parameter declared `string`,
and on iOS for `split()` with a capture.

**Instead:** test with `!m[1]` or `m[1] || fallback`. Declare a `replace` callback's group parameters optional
(`(all, x?: string) => …`): those read `undefined`.

### Arrays with holes (iOS and Android)

Writing past the end of an array of objects (`list[5] = item` on a shorter list) stops the app on iOS. On Android,
iterating a `number[]` that has holes (`forEach`, `map`, `for…of`) throws a `TypeError`. On iOS, `forEach`,
`filter` and `reduce` call their callback for the holes too, and `i in array` and `Object.keys` list them.

**Instead:** `push`, or create the array at its length with values (`new Array(n).fill(0)`).

### `valueOf` (iOS and Android)

On Android, an object literal's own `valueOf` is not called by arithmetic or comparisons: `obj - 1` is `NaN`. On
iOS, a `Date` held as `any` gives `NaN` in arithmetic (`date - 0`) and `false` in comparisons. Class instances with a
`valueOf` work on both.

**Instead:** call `.valueOf()` or `.getTime()`.

### Subclass fields are set before the base constructor runs (iOS)

A base constructor that calls a method the subclass overrides sees the subclass's fields already initialized; in
JavaScript they are still `undefined`. The build warns where an app's class does this.

### Reading past the end of a string (iOS and Android)

`str[i]` past the end is `undefined`, but a call on it does not throw: `str[9].toUpperCase()` gives `''` on iOS and
`'UNDEFINED'` on Android, where JavaScript throws a `TypeError`. In a `string[]` literal, `[str[9]]` holds `''` on
iOS and `'undefined'` on Android.

### Cutting a string inside a surrogate pair (iOS)

Swift strings cannot hold half of a surrogate pair. A cut between the two halves (`'a😀b'.slice(0, 2)`) gives
U+FFFD, so an emoji split and rejoined is corrupted, and `JSON.stringify('\ud83d')` writes U+FFFD.

### Deep mutual recursion stops the app (iOS)

A function that calls itself, or a method that calls the same method, throws a `RangeError` when the stack runs out.
Two functions that call each other stop the app instead.

### `error.stack` lists native frames (iOS and Android)

`error.stack` lists the native frames the error was made in, not JavaScript frames. On iOS a frame reads
`symbol (App+0xoffset)`, which the archive's dSYM resolves to your TypeScript lines. On Android a frame reads
`class.method(File.kt:line)`, which `ns-native-retrace` maps to your TypeScript lines.

### Smaller differences

- `typeof new String('x')` is `'string'`, not `'object'` (iOS and Android).
- `String(Symbol('s'))` gives `@@s#1` (iOS). `JSON.stringify` writes a symbol value as a string (iOS) or `{}`
(Android), where JavaScript leaves it out.
- `'ΑΣ'.toLowerCase()` ends in `σ`, not the final `ς` (iOS).
- `new RegExp('a/b').source` is `a/b`, not `a\/b` (iOS).
- A `TypeError`'s `constructor.name` is `JSTypeError` (Android). `new Array(-1)` and an over-long `padStart` throw
an `Error`, not a `RangeError` (Android).
- `new Date(y, m, d, h)` in the hour a daylight-saving change skips lands an hour early, not late (iOS).
- `Object.assign(target, …)` with a literal `null` among the sources stops the app (iOS).

### Android release builds (R8)

Members reached by name at run time (a method called on an `any` value, `typeof x.getFoo`, `'getFoo' in x`) are
kept from R8's renaming by a scan of the compiled code. A name computed at run time (`x[name]()` with `name` built
from strings) is not found by the scan; keep such members with a ProGuard rule in
`App_Resources/Android/app.gradle`, and check the minified release on a device.

By default every class and member of the kit is kept whole, which leaves most of the kit's code to R8 untouched.
`android: { release: { narrowKitKeeps: true } }` keeps only what the kit's reflection needs (class names,
constructors, the names of the members that remain) and lets R8 remove the rest: on Recipes (Vue) the kit's dex goes
from 3.3 to 2.0 MB and the APK from 2.44 to 1.92 MB, with both screens matching the JavaScript release. It is opt-in
until it has been verified on larger apps; under it, a kit member reached only through a computed name is removed.
Loading
Loading