|
| 1 | +# Error handling |
| 2 | + |
| 3 | +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`, Java exceptions round-trip into JavaScript with the original `Throwable` attached, and `interop.escapeException` forwards a JavaScript throw to the Java caller as the **original** Java exception. Unlike iOS, a truly-uncaught exception crashes the app by default (the Java default uncaught-exception handler ends the process) — `preventDefault()` and `discardUncaughtJsExceptions` are the opt-outs. Unhandled rejections and `reportError` only report; they never crash. |
| 4 | + |
| 5 | +## Quick reference |
| 6 | + |
| 7 | +| Situation | Default behavior | |
| 8 | +|---|---| |
| 9 | +| Uncaught JS exception during a Java→JS call (overridden method, interface implementation) | Becomes a real `com.tns.NativeScriptException` thrown to the Java caller. If nothing catches it, the thread's uncaught-exception handler reports it (cancelable `error` event → `__onUncaughtError` hook → error activity in debug builds) and the process exits — unless a listener called `preventDefault()`. | |
| 10 | +| Unhandled promise rejection | Tracked per isolate, reported once per looper turn: cancelable `unhandledrejection` event → `__onUncaughtError` hook, logcat entry prefixed `Unhandled promise rejection:`. The app keeps running. | |
| 11 | +| `.catch()` added after the report | `rejectionhandled` event (non-cancelable), carrying the original reason. | |
| 12 | +| Java exception during a JS→Java call | Surfaced to JS as an `Error` carrying the original as `error.nativeException`. | |
| 13 | +| `throw interop.escapeException(x)` in JS called from Java | The original Java `Throwable` carried by `x` is rethrown **unwrapped** to the Java caller (JS trace attached as a suppressed `com.tns.JavaScriptStackTrace`); with no underlying `Throwable`, a `com.tns.NativeScriptException` whose stack trace is the JS frames. | |
| 14 | +| `reportError(x)` | Routed through the same pipeline as an uncaught error; never crashes. | |
| 15 | + |
| 16 | +## JavaScript API |
| 17 | + |
| 18 | +### Global error events |
| 19 | + |
| 20 | +```js |
| 21 | +globalThis.addEventListener("error", (e) => { |
| 22 | + // e is an ErrorEvent: { message, error, filename, lineno, colno } |
| 23 | + // (filename/lineno/colno are not populated yet) |
| 24 | + console.log(e.message, e.error); |
| 25 | + e.preventDefault(); // marks the error handled: no hook, no crash, no error activity |
| 26 | +}); |
| 27 | + |
| 28 | +globalThis.addEventListener("unhandledrejection", (e) => { |
| 29 | + // e is a PromiseRejectionEvent: { promise, reason } |
| 30 | + console.log(e.reason); |
| 31 | + e.preventDefault(); |
| 32 | +}); |
| 33 | + |
| 34 | +globalThis.addEventListener("rejectionhandled", (e) => { |
| 35 | + // fired (as a task, on a following looper turn) when a handler is attached |
| 36 | + // to a promise whose rejection was already reported; carries the original |
| 37 | + // reason. Not cancelable. |
| 38 | +}); |
| 39 | +``` |
| 40 | + |
| 41 | +Notes: |
| 42 | + |
| 43 | +- `error` and `unhandledrejection` are `cancelable`; `preventDefault()` suppresses every downstream consequence (legacy hooks, logcat report, the error activity, the process crash). |
| 44 | +- Events fire even if app code overwrites `globalThis.dispatchEvent` — native dispatch goes through closures captured at startup. |
| 45 | +- 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 `error` event). |
| 46 | +- The events also fire on worker globals. A worker's unhandled rejection dispatches `unhandledrejection` on the worker's own global first; only when unprevented does it continue to the worker-global `onerror` and then to the parent's `worker.onerror`, mirroring uncaught worker errors. |
| 47 | + |
| 48 | +### `reportError` |
| 49 | + |
| 50 | +Routes a caught-but-fatal error through the exact same pipeline as an uncaught exception: |
| 51 | + |
| 52 | +```js |
| 53 | +reportError(new Error("something unrecoverable")); |
| 54 | +``` |
| 55 | + |
| 56 | +### Event classes |
| 57 | + |
| 58 | +`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: |
| 59 | + |
| 60 | +```js |
| 61 | +const target = new EventTarget(); |
| 62 | +target.addEventListener("tick", (e) => { /* ... */ }, { once: true }); |
| 63 | +target.dispatchEvent(new Event("tick")); // returns !defaultPrevented |
| 64 | +``` |
| 65 | + |
| 66 | +### What lands on the events |
| 67 | + |
| 68 | +The stacks live on the error/reason **value**, not on the event — and the thrown value can be anything, so shape-check before use: |
| 69 | + |
| 70 | +| You wrote | `e.error` / `e.reason` is | JS stack | Native exception | |
| 71 | +|---|---|---|---| |
| 72 | +| `throw new Error("x")` | that `Error` | `e.error.stack` | — | |
| 73 | +| called a Java method that threw, without try/catch | an `Error` with `message` from the Java exception's message | `e.error.stack` (the JS call site); `e.error.stackTrace` combines it with the Java frames | `e.error.nativeException` — the original `Throwable` (call `.getClass()`, `.getMessage()`, `.getCause()`, ... on it) | |
| 74 | + |
| 75 | +### Catching native exceptions |
| 76 | + |
| 77 | +```js |
| 78 | +try { |
| 79 | + someJavaObject.methodThatThrowsIOException(); |
| 80 | +} catch (e) { |
| 81 | + e.nativeException instanceof java.io.IOException; // true |
| 82 | + e.nativeException.getMessage(); // the Java message |
| 83 | + e.stackTrace; // combined JS + Java stack as a string |
| 84 | +} |
| 85 | +``` |
| 86 | + |
| 87 | +### Forwarding a throw to native: `interop.escapeException` |
| 88 | + |
| 89 | +A plain JS throw inside a Java-invoked callback already escapes to the Java caller — as a `com.tns.NativeScriptException`. That is the right default, but when the caller is waiting for a *concrete* exception type, the wrapper doesn't match its `catch`. Branding the throw forwards the **original** Java exception instead: |
| 90 | + |
| 91 | +```js |
| 92 | +const listener = new some.api.Listener({ |
| 93 | + onEvent() { |
| 94 | + try { |
| 95 | + riskyJavaCall(); // throws java.io.IOException |
| 96 | + } catch (e) { |
| 97 | + throw interop.escapeException(e); // the Java caller catches the ORIGINAL IOException |
| 98 | + } |
| 99 | + }, |
| 100 | +}); |
| 101 | +``` |
| 102 | + |
| 103 | +Semantics: |
| 104 | + |
| 105 | +- `escapeException(err)` returns a JS `Error` (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 throws `TypeError`. |
| 106 | +- If `err` is (or carries via `.nativeException`) a Java `Throwable`, the **original object** is rethrown at the boundary — a Java `catch (IOException e)` above the caller matches, and `Throwable` identity is preserved (same object, untouched class/stack/cause chain). The JS journey rides along as a suppressed `com.tns.JavaScriptStackTrace` (see the native section). |
| 107 | +- Otherwise a `com.tns.NativeScriptException` is thrown as usual, but with its stack trace replaced by frames synthesized from the JS stack, so crash reporters group it by where it actually happened in JS. |
| 108 | +- The `escapeException()` call site's stack is recorded too — for non-Error values (`escapeException("boom")`) it is the only stack available. |
| 109 | +- Branded escapes bypass `discardUncaughtJsExceptions` (an explicit forward request must reach the caller). |
| 110 | + |
| 111 | +## Native (Java) API |
| 112 | + |
| 113 | +### Catching escaped exceptions |
| 114 | + |
| 115 | +```java |
| 116 | +try { |
| 117 | + listener.onEvent(); // implemented in JS |
| 118 | +} catch (java.io.IOException e) { |
| 119 | + // For rethrown originals: e is the very same object the JS code caught. |
| 120 | + // For synthesized escapes: catch com.tns.NativeScriptException instead - |
| 121 | + // its message is the JS error's message and its stack trace is the JS frames. |
| 122 | +} |
| 123 | +``` |
| 124 | + |
| 125 | +### JS stack traces on Java exceptions: `com.tns.JavaScriptStackTrace` |
| 126 | + |
| 127 | +An escaped original exception carries its JavaScript journey as a suppressed throwable, so it renders automatically in `printStackTrace()`, logcat fatal logs and crash reporters: |
| 128 | + |
| 129 | +``` |
| 130 | +java.io.IOException: original-io-exception |
| 131 | + at com.example.SomeApi.riskyJavaCall(SomeApi.java:42) |
| 132 | + ... |
| 133 | + Suppressed: com.tns.JavaScriptStackTrace: Error: original-io-exception |
| 134 | + at <js>.onEvent(main-view-model.js:17) |
| 135 | + ... |
| 136 | +``` |
| 137 | + |
| 138 | +`JavaScriptStackTrace` is never thrown — only attached — and its stack trace elements are synthesized from the V8 frames. Crash-SDK integrations can look it up and read the raw stacks: |
| 139 | + |
| 140 | +```java |
| 141 | +for (Throwable suppressed : caught.getSuppressed()) { |
| 142 | + if (suppressed instanceof com.tns.JavaScriptStackTrace) { |
| 143 | + com.tns.JavaScriptStackTrace jsTrace = (com.tns.JavaScriptStackTrace) suppressed; |
| 144 | + String originStack = jsTrace.getJavaScriptStack(); // where the JS error was created |
| 145 | + String escapeStack = jsTrace.getEscapeSiteStack(); // where interop.escapeException() was called |
| 146 | + } |
| 147 | +} |
| 148 | +``` |
| 149 | + |
| 150 | +| Exception | Where the JS stack lives | |
| 151 | +|---|---| |
| 152 | +| Rethrown original `Throwable` | suppressed `com.tns.JavaScriptStackTrace` (identity, stack and cause chain of the original are untouched) | |
| 153 | +| Synthesized escape (`com.tns.NativeScriptException`) | the exception's own stack trace elements are the JS frames; the message is the JS error's message | |
| 154 | + |
| 155 | +`JavaScriptStackTrace` and its two accessors are the stable contract for crash-SDK integrations; other exception paths may adopt the carrier in the future. |
| 156 | + |
| 157 | +## Configuration |
| 158 | + |
| 159 | +| Flag (app `package.json`, default off) | Effect | |
| 160 | +|---|---| |
| 161 | +| `discardUncaughtJsExceptions` | JS exceptions escaping an overridden-method call are swallowed on the Java side and reported through `__onDiscardedError` instead of crashing the app. Branded `interop.escapeException` throws bypass it. | |
| 162 | + |
| 163 | +There is no `crashOnUncaughtJsExceptions` flag (unlike iOS): crashing on truly-uncaught exceptions is already Android's default. |
| 164 | + |
| 165 | +Terminal-path decision table: |
| 166 | + |
| 167 | +| Condition | legacy hook called | process crash | |
| 168 | +|---|---|---| |
| 169 | +| uncaught exception, default | `__onUncaughtError` | yes | |
| 170 | +| uncaught exception, `discardUncaughtJsExceptions` | `__onDiscardedError` | no | |
| 171 | +| uncaught exception, listener called `preventDefault()` | none | no | |
| 172 | +| unhandled rejection / `reportError`, unprevented | `__onUncaughtError` | no | |
| 173 | +| unhandled rejection / `reportError`, `preventDefault()` | none | no | |
| 174 | + |
| 175 | +## Crash reporter integration |
| 176 | + |
| 177 | +JS side — attach both the JS and native exception from one listener: |
| 178 | + |
| 179 | +```js |
| 180 | +globalThis.addEventListener("error", (e) => { |
| 181 | + const err = e.error; |
| 182 | + const native = err && err.nativeException; |
| 183 | + crashReporter.capture(err instanceof Error ? err : new Error(e.message), { |
| 184 | + nativeClass: native ? native.getClass().getName() : undefined, |
| 185 | + nativeMessage: native ? native.getMessage() : undefined, |
| 186 | + }); |
| 187 | + // e.preventDefault(); // only if the reporter fully owns error handling |
| 188 | +}); |
| 189 | +``` |
| 190 | + |
| 191 | +Java side — for exceptions that never pass through the JS event layer (escaped originals crashing a thread), walk `getSuppressed()` for `com.tns.JavaScriptStackTrace` to attach the JS frames. For embedders with a custom `Thread.UncaughtExceptionHandler`: `Runtime.passUncaughtExceptionToJs(...)` returns `true` when a listener called `preventDefault()` — honor it by not killing the process (see `NativeScriptUncaughtExceptionHandler`). |
| 192 | + |
| 193 | +## Legacy hooks (deprecated) |
| 194 | + |
| 195 | +`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", ...)`. |
| 196 | + |
| 197 | +## Behavior details |
| 198 | + |
| 199 | +- Every error is reported exactly once: either the JS→Java boundary (synchronous throws during Java-invoked JS), the rejection drain (once per looper turn, scheduled on the runtime's `ALooper`), or `reportError` — never two of them for the same error. |
| 200 | +- A rejection that gets a handler before the end-of-turn drain is never reported (and produces no `rejectionhandled` either). |
| 201 | +- The `error` event for uncaught exceptions fires when the exception is reported to JS (from the uncaught-exception handler via `passUncaughtExceptionToJs`, or the discard path) — after the Java stack has already unwound. `preventDefault()` prevents the crash, but on the main thread the app's looper has exited by then; for background and JS-only threads the process genuinely keeps running. |
| 202 | +- Worker isolates run the same machinery: each worker has its own tracker, drain, and event layer. |
0 commit comments