The runtime implements the WHATWG error model at the global level: uncaught JavaScript exceptions and unhandled promise rejections are dispatched as cancelable events on globalThis, native NSExceptions round-trip into JavaScript with the original object attached, and interop.escapeException forwards a JavaScript throw to a native caller as a real @throw. By default nothing crashes the app — reporting is opt-out per error (preventDefault()), and rethrowing unprevented errors natively is opt-in (uncaughtErrorPolicy: "throw").
| Situation | Default behavior |
|---|---|
| Uncaught JS exception | Cancelable error event on globalThis → if unprevented: __onUncaughtError hook, fatal log, error modal in debug. App keeps running. |
| Unhandled promise rejection | Tracked per isolate, reported once per run-loop turn: cancelable unhandledrejection event → same fallback chain, prefixed Unhandled promise rejection:. |
.catch() added after the report |
rejectionhandled event (non-cancelable), carrying the original reason. |
| JS throw inside a native callback (overridden method, block, property accessor, adapter) | Reported through the same pipeline; the native caller receives a zero/nil default and continues. The process never aborts on a JS throw. |
throw interop.escapeException(x) inside a native callback |
Converted to a real native @throw at the boundary — no JS-side reporting; the native caller's @try/@catch sees the exception. |
Native @throw during a JS→native call |
Surfaced to JS as an Error carrying the original exception as error.nativeException. |
globalThis.addEventListener("error", (e) => {
// e is an ErrorEvent: { message, error, filename, lineno, colno }
// (filename/lineno/colno are not populated yet)
console.log(e.message, e.error);
e.preventDefault(); // marks the error handled: no hook, no fatal log, no modal
});
globalThis.addEventListener("unhandledrejection", (e) => {
// e is a PromiseRejectionEvent: { promise, reason }
console.log(e.reason);
e.preventDefault();
});
globalThis.addEventListener("rejectionhandled", (e) => {
// fired (as a task, on the next run-loop turn) when a handler is attached
// to a promise whose rejection was already reported; carries the original
// reason. Not cancelable.
});Notes:
errorandunhandledrejectionarecancelable;preventDefault()suppresses every downstream consequence (legacy hooks, fatal log, error modal, theuncaughtErrorPolicy: "throw"rethrow).- Events fire even if app code overwrites
globalThis.dispatchEvent— native dispatch goes through closures captured at startup. - A listener that throws does not stop the remaining listeners; the thrown value is routed to the fatal reporting tail directly (never recursively dispatched as another
errorevent). - The events also fire on worker globals. A worker's unhandled rejection dispatches
unhandledrejectionon the worker's own global first; only when unprevented does it continue to the worker-globalonerrorand then to the main isolate'sworker.onerror, mirroring uncaught worker errors.
Routes a caught-but-fatal error through the exact same pipeline as an uncaught exception:
reportError(new Error("something unrecoverable"));Event, EventTarget, ErrorEvent and PromiseRejectionEvent are installed as global constructors. Event/EventTarget are general-purpose (registration order, once/capture options, stopImmediatePropagation, handleEvent objects) and usable for your own eventing:
const target = new EventTarget();
target.addEventListener("tick", (e) => { ... }, { once: true });
target.dispatchEvent(new Event("tick")); // returns !defaultPreventedThe stacks live on the error/reason value, not on the event — and the thrown value can be anything, so shape-check before use. Object-valued errors/reasons additionally carry a combined stackTrace string property, populated before the event dispatches (e.error.stackTrace / e.reason.stackTrace — same contract on Android):
| You wrote | e.error / e.reason is |
JS stack | Native exception |
|---|---|---|---|
throw new Error("x") |
that Error |
e.error.stack |
— |
| called a native method that threw, without try/catch | an Error with name = the NSException name, message = its reason |
e.error.stack (the JS call site) |
e.error.nativeException — the original NSException (.name, .reason, .userInfo, .callStackSymbols captured at the native throw) |
throw NSException.exceptionWithNameReasonUserInfo(...) |
the wrapped NSException itself — not an Error, no .stack |
— | e.error directly (instanceof NSException); .callStackSymbols is only populated if it was actually thrown natively, not when merely constructed |
try {
NSArray.alloc().init().objectAtIndex(3);
} catch (e) {
e.nativeException instanceof NSException; // true
e.nativeException.name; // "NSRangeException"
e.nativeException.reason; // "... index 3 beyond bounds ..."
e.nativeException.callStackSymbols; // native frames at the throw site
}Methods with NSError** out-parameters are unchanged: they throw a JS Error with code, domain and nativeException set to the NSError.
A plain JS throw inside a native callback is reported and the native caller continues with a default value. When the native caller must observe the failure — a parent @try/@catch is waiting, or you want a real native crash — brand the throw:
const Delegate = NSObject.extend({
someNativeMethod() {
throw interop.escapeException(new Error("propagate me natively"));
},
}, { protocols: [SomeProtocol] });At the JS→native boundary the branded throw becomes a native @throw (executed only after every V8 scope has unwound). Semantics:
escapeException(err)returns a JSError(message/stack copied), so it behaves like a normal throw in pure-JS paths; the brand is an isolate-private symbol that user code cannot forge. Passing an already-branded value is a no-op; calling with no argument throwsTypeError.- If
erris (or carries via.nativeException) a nativeNSException, the original object is rethrown — a parent@catch (NSRangeException *)matches, and identity is preserved:
try {
riskyNativeCall();
} catch (e) {
throw interop.escapeException(e); // rethrows the exact original NSException
}- Otherwise an
NSExceptionis synthesized:namefrom the error's name,reasonfrom its message plus the JS stack, and the stacks inuserInfo(see the native section). - An escaped exception that nothing catches natively terminates the app with a real native crash report.
- An explicit escape always wins. A branded error that reaches the uncaught machinery anywhere — thrown uncaught in a timer/microtask, used as an unhandled rejection reason, or thrown from inside an
error/unhandledrejectionlistener — converts straight to the native throw: no events are dispatched, no hooks run, andpreventDefault()cannot veto it (matching Android, where the brand is checked before any dispatch). escapeExceptionis the per-call tool and works regardless ofuncaughtErrorPolicyand at every boundary (including native→JS blocks and overridden methods): it bypasses JS-side reporting entirely and converts that one throw into a native@throw.uncaughtErrorPolicy: "throw"is the global policy — it applies only to unprevented, unbranded errors, only after they are reported, and only rethrows synchronously at boundaries that report within their own frame (property accessors / adapters); block and overridden-method boundaries fall back to the deferred clean-frame throw. When you need a native handler around a specific block/method call to catch a JS failure, useescapeException, not the policy.
Limitations: two boundaries execute under the caller's live V8 scopes and therefore cannot convert a branded escape into a native throw — fast enumeration (for...in over a JS object from native) and DictionaryAdapter.getProperties. There, a branded escape surfaces as an ordinary JS exception.
@try {
[jsImplementedObject someNativeMethod];
} @catch (NSException *e) {
// For synthesized escapes: e.name/e.reason come from the JS error, and
// e.reason includes the JS stack. For rethrown originals: e is the very
// same object that was originally thrown natively.
}Every NSException produced by the escape/crash machinery carries the JavaScript context:
#import <NativeScript/NSExceptionSupport.h>
NSString *jsStack = exception.tns_javascriptStackTrace; // nil when no JS involvementThe accessor is uniform across all cases; underneath:
| Exception | Where the JS stack lives |
|---|---|
| Synthesized escape | userInfo[TNSJavaScriptStackTraceKey] (@"JavaScriptStack"), plus userInfo[TNSJavaScriptEscapeStackTraceKey] (@"JavaScriptEscapeStack") when the escapeException call site differs from the error's origin; also appended to reason |
Rethrown original NSException |
attached as an associated object (identity and userInfo are untouched); the string combines the origin stack and the escape site |
uncaughtErrorPolicy: "throw" fatal (NativeScriptFatalJSException) |
userInfo[TNSJavaScriptStackTraceKey], also in reason |
tns_javascriptStackTrace and the two userInfo keys are the stable contract for crash-SDK integrations.
uncaughtErrorPolicy in the app's package.json is the uncaught-error contract, unified across NativeScript runtimes (9.1+):
"report"— after the event dispatch and legacy hooks, log the fatal message and keep the app alive."throw"— additionally rethrow the error as a realNSException(NativeScriptFatalJSException, JS stack inreasonanduserInfo). When a JS→native boundary reports the error synchronously within its own frame (a property accessor orDictionaryAdapterread invoked from native), it is rethrown synchronously at that boundary — after every V8 scope unwinds — so a native@try/@catcharound that call can catch it, matching Android. Otherwise — loop-originated errors (timers, microtasks, unhandled rejections) and native→JS block / overridden-method callbacks (whose throw is surfaced to V8 and reported up-stack, not in the callback's own frame) — it is thrown from a clean frame on the runtime loop on the next turn. Either way this is a throw, not a crash guarantee: the app terminates only if nothing catches it — which also means crash reporters capture it properly when it does. To force a synchronous native@throwat any boundary regardless of policy, useinterop.escapeException.- Unknown values log a warning once and fall back to
"report".
Deprecated: discardUncaughtJsExceptions is still honored in full for now — unprevented errors route to __onDiscardedError instead of __onUncaughtError, the fatal log is skipped, and any "throw" policy is suppressed — but it logs a deprecation warning. Migrate to an error/unhandledrejection listener calling preventDefault() (per-error, more precise) and uncaughtErrorPolicy.
Terminal-path decision table for an uncaught error / unhandled rejection:
| Condition | legacy hook called | fatal log / modal | native throw |
|---|---|---|---|
"report" (default) |
__onUncaughtError |
yes | no |
"throw", unprevented |
__onUncaughtError |
yes | yes |
listener called preventDefault() |
no | no | no |
discardUncaughtJsExceptions (deprecated) |
__onDiscardedError |
no | no |
JS side — attach both the JS and native exception from one listener:
function nativeExceptionOf(value) {
if (value instanceof NSException) return value;
return (value && value.nativeException instanceof NSException) ? value.nativeException : null;
}
globalThis.addEventListener("error", (e) => {
const err = e.error;
const native = nativeExceptionOf(err);
crashReporter.capture(err instanceof Error ? err : new Error(e.message), {
nativeName: native?.name,
nativeReason: native?.reason,
nativeStack: native?.callStackSymbols, // NSArray of symbolicated frames
});
// e.preventDefault(); // only if the reporter fully owns error handling
});Native side — for the NSUncaughtExceptionHandler/signal layer, read exception.tns_javascriptStackTrace (or the userInfo keys) to attach the JS stack to crashes that never pass through the JS event layer (escaped exceptions, uncaughtErrorPolicy: "throw").
global.__onUncaughtError and global.__onDiscardedError keep working exactly as before and are what @nativescript/core currently installs (surfaced as Application.uncaughtErrorEvent / discardedErrorEvent). They are invoked only when no event listener called preventDefault(). New code should prefer globalThis.addEventListener("error" | "unhandledrejection", ...).
- Every error is reported exactly once: either the V8 message listener (synchronous throws), the rejection drain (once per run-loop turn,
kCFRunLoopBeforeWaiting), or the boundary handler — never two of them for the same error. - A rejection that gets a handler before the end-of-turn drain is never reported (and produces no
rejectionhandledeither). - Rejection events carry the underlying V8 promise.
Promise.reject(...)returns aPromiseProxy-wrapped object, so compare promises between events (unhandledrejection↔rejectionhandled), not against your proxy handle. - ObjC exceptions never unwind through live V8 frames: boundaries catch on the JS side and rethrow after scope teardown, and crash-mode throws are deferred to a clean run-loop frame. Preserve this invariant when touching the boundary code.
{ "uncaughtErrorPolicy": "report" } // default: report and keep running { "uncaughtErrorPolicy": "throw" } // unprevented errors are rethrown natively