You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat!: contain uncaught JS errors by default (uncaughtErrorPolicy)
Adopts the web's model as the default: an erroring page doesn't crash
the browser, and an uncaught JS error no longer crashes the app. This is
the Android half of the unified NativeScript 9.1 error-handling spec.
Containment:
- An uncaught throw in a NATIVE-initiated callback (the OS invoking an
overridden method or interface implementation, a posted Runnable, a
timer, __runOnMainThread, a frame callback) is contained at the
boundary: reported through the WHATWG pipeline (cancelable `error`
event -> __onUncaughtError -> logcat) and the native caller resumes
with the return type's default value. preventDefault() suppresses the
report entirely.
- A JS-INITIATED chain (JS -> Java -> JS callback throws) keeps
propagating to the outer JS catch with the original JS error object -
correct JavaScript semantics, tracked via a per-isolate JS->Java call
depth around CallJavaMethod/RegisterInstance. Containment applies only
at an outermost native-initiated boundary.
- Contained primitive-returning overrides yield the type's default
(dispatchCallJSMethodNative substitutes it before unboxing), fixing
the NPE the old discard path produced.
- Branded interop.escapeException throws are never contained; module/
script evaluation (bootstrap) is not contained.
uncaughtErrorPolicy (app package.json, root level):
- "report" (default): report and continue, never crash.
- "throw": unprevented errors are thrown to the native layer as real
Java exceptions - com.tns.NativeScriptException with the JS frames as
its stack trace - restoring the pre-9.1 behavior (typically a crash,
via the thread's uncaught-exception path, which performs the single
report). Unhandled rejections are thrown from a clean Java frame
posted to the runtime's looper. Named for the mechanism, not a
guaranteed crash: native catch-alls above the boundary can still
intercept.
discardUncaughtJsExceptions is deprecated (logcat warning): `true` keeps
its legacy quiet routing (__onDiscardedError, no log) on top of the new
default; `false` - which used to mean "crash" - is ignored. Branded
escapes are now exempt from the discard handling (escapedFromJs marker).
Testing: 5 new specs (containment + app liveness with event/hook
identity, preventDefault suppression, timer containment, primitive
default instead of NPE, JS-initiated chain identity) plus the
direct-throw escape specs; "throw" policy is a documented manual smoke.
Full suite green on an API 35 arm64 emulator - every pre-existing suite
passes unchanged thanks to the JS-initiated-chain rule.
Copy file name to clipboardExpand all lines: docs/error-handling.md
+40-16Lines changed: 40 additions & 16 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,17 +1,19 @@
1
1
# Error handling
2
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.
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. Following the web's model — an erroring page doesn't crash the browser — **an uncaught error never crashes the app by default**: it is reported (event → hook → log) and execution continues. Crashing is opt-in via `uncaughtErrorPolicy: "throw"`; suppressing a report entirely is per-error via `preventDefault()`.
4
4
5
5
## Quick reference
6
6
7
7
| Situation | Default behavior |
8
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()`. |
9
+
| Uncaught JS exception in a **native-initiated** callback (the OS invoking an overridden method or interface implementation, a posted `Runnable`, a timer, `__runOnMainThread`, a frame callback) |**Contained at the boundary**: reported (cancelable `error` event → `__onUncaughtError` hook → logcat), the Java caller receives the default value for the return type, and the app keeps running. |
10
+
| Uncaught JS exception in a **JS-initiated** chain (JS → Java → JS callback throws) |**Propagates back to the outer JS `catch`** as the very same JS error object — correct JavaScript semantics; each JS frame on the way gets its chance to catch. Only if it reaches an outermost native-initiated boundary is it contained. |
10
11
| 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
12
|`.catch()` added after the report |`rejectionhandled` event (non-cancelable), carrying the original reason. |
12
13
| 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
+
|`throw interop.escapeException(x)` in JS called from Java |Never contained. 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
15
|`reportError(x)`| Routed through the same pipeline as an uncaught error; never crashes. |
16
+
|`uncaughtErrorPolicy: "throw"`| Restores the pre-9.1 behavior: unprevented uncaught errors are thrown to the native layer as real Java exceptions (which typically ends the process via the default uncaught-exception handler). |
15
17
16
18
## JavaScript API
17
19
@@ -69,8 +71,9 @@ The stacks live on the error/reason **value**, not on the event — and the thro
69
71
70
72
| You wrote |`e.error` / `e.reason` is | JS stack | Native exception |
71
73
|---|---|---|---|
72
-
|`throw new Error("x")`| that `Error`|`e.error.stack`| — |
74
+
|`throw new Error("x")`| that `Error`(the actual thrown value) |`e.error.stack`| — |
73
75
| 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) |
76
+
|`throw new java.io.IOException("x")` — a directly-thrown wrapped `Throwable`| the wrapped `Throwable` itself — not an `Error`, no `.stack`| — |`e.error` directly (`instanceof java.io.IOException`) |
74
77
75
78
### Catching native exceptions
76
79
@@ -103,7 +106,17 @@ const listener = new some.api.Listener({
103
106
Semantics:
104
107
105
108
-`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).
109
+
- 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). This includes directly-constructed exceptions:
110
+
111
+
```js
112
+
// The caller catches THIS exact IOException - no wrapper. Without the brand,
113
+
// a directly-thrown wrapped Throwable behaves like any other uncaught throw:
114
+
// contained (reported, caller resumes) in a native-initiated callback, or -
115
+
// in a JS-initiated chain - propagated to the outer JS catch (and, if it
116
+
// reaches Java code with no JS below, wrapped in a com.tns.NativeScriptException
- 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
121
- The `escapeException()` call site's stack is recorded too — for non-Error values (`escapeException("boom")`) it is the only stack available.
109
122
- Branded escapes bypass `discardUncaughtJsExceptions` (an explicit forward request must reach the caller).
@@ -156,20 +169,29 @@ for (Throwable suppressed : caught.getSuppressed()) {
156
169
157
170
## Configuration
158
171
159
-
| Flag (app `package.json`, default off) | Effect |
172
+
One policy key in the app's `package.json` (root level) governs what happens to an **unprevented** uncaught error or unhandled rejection:
173
+
174
+
|`uncaughtErrorPolicy`| Effect |
160
175
|---|---|
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. |
176
+
|`"report"` (default) | Report (event → hook → log) and continue. Never crashes. |
177
+
|`"throw"`| After the (cancelable) event, the error is thrown to the native layer as a real Java exception — `com.tns.NativeScriptException` with the JS frames as its stack trace — and reported through the thread's uncaught-exception path. This typically ends the process (the pre-9.1 default), though a native catch or a custom `UncaughtExceptionHandler` above the boundary can still intercept it, which is why the policy names the mechanism, not a guaranteed crash. Unhandled rejections are thrown from a clean frame on the runtime's looper. |
178
+
179
+
Deprecated (kept for the transition, both emit a logcat warning):
162
180
163
-
There is no `crashOnUncaughtJsExceptions` flag (unlike iOS): crashing on truly-uncaught exceptions is already Android's default.
181
+
| Flag | Behavior |
182
+
|---|---|
183
+
|`discardUncaughtJsExceptions: true`| Legacy quiet routing: contained reports call `__onDiscardedError` instead of `__onUncaughtError` and skip the logcat report. Branded `interop.escapeException` throws bypass it. |
184
+
|`discardUncaughtJsExceptions: false`| Ignored (this used to mean "crash on uncaught errors" — set `uncaughtErrorPolicy: "throw"` for that). |
164
185
165
186
Terminal-path decision table:
166
187
167
188
| Condition | legacy hook called | process crash |
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`).
213
+
Java side — for exceptions that never pass through the JS event layer (escaped originals crashing a thread, `"throw"`-policy fatals), 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
214
193
215
## Legacy hooks (deprecated)
194
216
195
217
`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
218
197
219
## Behavior details
198
220
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.
221
+
-**Containment is boundary-outermost.** The runtime tracks the depth of in-flight JS→Java calls; a throw with JS frames waiting below the boundary propagates (so `try { javaApi.call(cb) } catch` works, with the original JS error object restored across the crossing), and is contained only at an outermost native-initiated entry. `interop.escapeException` and `uncaughtErrorPolicy: "throw"` are the two ways an error crosses that outermost boundary.
222
+
-**Contained callbacks return type defaults.** A throwing overridden method hands its Java caller `null` for reference types and `0`/`false` for primitives (the runtime substitutes the default before unboxing, so no `NullPointerException` from the binding). The error is loudly reported *before* the caller resumes, so logcat shows the real failure ahead of any downstream symptom. If the Java contract genuinely needs the exception, use `interop.escapeException`.
223
+
- Every error is reported exactly once: either the containment point, the rejection drain (once per looper turn, scheduled on the runtime's `ALooper`), `reportError`, or — under `"throw"` — the thread's uncaught-exception path. Never two of them for the same error.
200
224
- 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.
225
+
-Module/script evaluation (`runModule`/`runScript`, app bootstrap) is not contained — an app whose main module fails to load still fails loudly.
226
+
- Worker isolates run the same machinery: each worker has its own tracker, drain, event layer and containment.
0 commit comments