Skip to content

Commit 05389dd

Browse files
committed
feat: uncaughtErrorPolicy config, deprecate discardUncaughtJsExceptions
Replace the never-released crashOnUncaughtJsExceptions flag with the cross-runtime uncaughtErrorPolicy contract: { "uncaughtErrorPolicy": "report" } // default: report, keep running { "uncaughtErrorPolicy": "throw" } // rethrow unprevented errors natively "throw" keeps the same mechanics (deferred NativeScriptFatalJSException from a clean scope-free frame, JS stack in reason/userInfo) under an honest name: it is a throw, not a crash guarantee - the app terminates only if no native handler catches it. Unknown values warn once and fall back to "report". discardUncaughtJsExceptions is deprecated but still honored in full for now (routes to __onDiscardedError, skips the fatal log, suppresses the throw policy); it logs a one-time deprecation warning pointing at preventDefault() and uncaughtErrorPolicy.
1 parent 9ab61e3 commit 05389dd

4 files changed

Lines changed: 53 additions & 22 deletions

File tree

‎NativeScript/runtime/ArgConverter.mm‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@
263263
}
264264

265265
// Unbranded: report exactly once through the uncaught path (error-event
266-
// dispatch + shims + log; honors crashOnUncaughtJsExceptions). This is the
266+
// dispatch + shims + log; honors uncaughtErrorPolicy). This is the
267267
// path for the Assert-hardened sites that today never reach the message
268268
// listener at all.
269269
Local<v8::Message> message = tc.Message();

‎NativeScript/runtime/NativeScriptException.mm‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,20 @@ static void ScheduleDeferredThrow(Isolate* isolate, NSException* e) {
184184
}
185185
}
186186
Local<Value> handler;
187+
// Deprecated: still honored in full (selects the __onDiscardedError callback
188+
// and skips the fatal log), but new code should handle errors with an
189+
// error/unhandledrejection listener calling preventDefault(), or set
190+
// uncaughtErrorPolicy.
187191
id value = Runtime::GetAppConfigValue("discardUncaughtJsExceptions");
188192
bool isDiscarded = value ? [value boolValue] : false;
193+
if (isDiscarded) {
194+
static std::once_flag warnedDeprecated;
195+
std::call_once(warnedDeprecated, []() {
196+
Log(@"NativeScript: \"discardUncaughtJsExceptions\" is deprecated. Handle errors with a "
197+
@"globalThis \"error\"/\"unhandledrejection\" listener calling preventDefault(), or "
198+
@"configure \"uncaughtErrorPolicy\".");
199+
});
200+
}
189201

190202
std::string cbName = isDiscarded ? "__onDiscardedError" : "__onUncaughtError";
191203
bool success = global->Get(context, tns::ToV8String(isolate, cbName)).ToLocal(&handler);
@@ -278,13 +290,20 @@ static void ScheduleDeferredThrow(Isolate* isolate, NSException* e) {
278290
Log(@"NativeScript discarding uncaught JS exception!");
279291
}
280292

281-
// Opt-in (default off): after reporting, crash the app with a real native
282-
// crash report by scheduling an uncaught @throw on a scope-free frame. Skipped
283-
// when discarding (discardUncaughtJsExceptions is an explicit "do not crash").
293+
// uncaughtErrorPolicy — the cross-platform uncaught-error contract:
294+
// "report" (default): report and keep the app running;
295+
// "throw": after reporting, rethrow the unprevented error
296+
// natively (deferred to a clean scope-free frame). This
297+
// is a throw, not a crash guarantee — the app terminates
298+
// only if no native handler catches it.
299+
// The deprecated discardUncaughtJsExceptions flag suppresses the throw (an
300+
// explicit "keep the app alive").
284301
if (!isDiscarded) {
285-
id crashValue = Runtime::GetAppConfigValue("crashOnUncaughtJsExceptions");
286-
bool shouldCrash = crashValue ? [crashValue boolValue] : false;
287-
if (shouldCrash) {
302+
id policyValue = Runtime::GetAppConfigValue("uncaughtErrorPolicy");
303+
std::string policy = [policyValue isKindOfClass:[NSString class]]
304+
? std::string([(NSString*)policyValue UTF8String])
305+
: "report";
306+
if (policy == "throw") {
288307
NSString* reasonText = tns::ToNSString(fullMessage);
289308
NSDictionary* userInfo =
290309
stackTrace.empty() ? nil : @{TNSJavaScriptStackTraceKey : tns::ToNSString(stackTrace)};
@@ -297,6 +316,12 @@ static void ScheduleDeferredThrow(Isolate* isolate, NSException* e) {
297316
tns::SetJSStackOnException(fatal, tns::ToNSString(stackTrace));
298317
}
299318
ScheduleDeferredThrow(isolate, fatal);
319+
} else if (policy != "report") {
320+
static std::once_flag warnedPolicy;
321+
std::call_once(warnedPolicy, [&policy]() {
322+
Log(@"NativeScript: unknown uncaughtErrorPolicy \"%s\" — falling back to \"report\".",
323+
policy.c_str());
324+
});
300325
}
301326
}
302327
}

‎TestRunner/app/tests/EscapeExceptionTests.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,9 @@ describe("interop.escapeException and boundary hardening", function () {
250250
});
251251
});
252252

253-
// manual: crashOnUncaughtJsExceptions is not exercised automatically because a
253+
// manual: uncaughtErrorPolicy "throw" is not exercised automatically because a
254254
// real crash would kill the test runner. To smoke it manually, set
255-
// { "crashOnUncaughtJsExceptions": true } in the app config and throw an
255+
// { "uncaughtErrorPolicy": "throw" } in the app config and throw an
256256
// unprevented error (or `throw interop.escapeException(err)` from an `error`
257257
// listener) — the app must terminate with a NativeScriptFatalJSException
258258
// crash report scheduled on the runtime loop. Default-off behavior (report +

‎docs/error-handling.md‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Error handling
22

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`, native `NSException`s 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 crashing is opt-in (`crashOnUncaughtJsExceptions`).
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`, native `NSException`s 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"`).
44

55
## Quick reference
66

@@ -40,7 +40,7 @@ globalThis.addEventListener("rejectionhandled", (e) => {
4040

4141
Notes:
4242

43-
- `error` and `unhandledrejection` are `cancelable`; `preventDefault()` suppresses every downstream consequence (legacy hooks, fatal log, error modal, `crashOnUncaughtJsExceptions`).
43+
- `error` and `unhandledrejection` are `cancelable`; `preventDefault()` suppresses every downstream consequence (legacy hooks, fatal log, error modal, the `uncaughtErrorPolicy: "throw"` rethrow).
4444
- Events fire even if app code overwrites `globalThis.dispatchEvent` — native dispatch goes through closures captured at startup.
4545
- 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).
4646
- 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 main isolate's `worker.onerror`, mirroring uncaught worker errors.
@@ -149,27 +149,33 @@ The accessor is uniform across all cases; underneath:
149149
|---|---|
150150
| Synthesized escape | `userInfo[TNSJavaScriptStackTraceKey]` (`@"JavaScriptStack"`), plus `userInfo[TNSJavaScriptEscapeStackTraceKey]` (`@"JavaScriptEscapeStack"`) when the `escapeException` call site differs from the error's origin; also appended to `reason` |
151151
| Rethrown original `NSException` | attached as an associated object (identity and `userInfo` are untouched); the string combines the origin stack and the escape site |
152-
| `crashOnUncaughtJsExceptions` fatal (`NativeScriptFatalJSException`) | `userInfo[TNSJavaScriptStackTraceKey]`, also in `reason` |
152+
| `uncaughtErrorPolicy: "throw"` fatal (`NativeScriptFatalJSException`) | `userInfo[TNSJavaScriptStackTraceKey]`, also in `reason` |
153153

154154
`tns_javascriptStackTrace` and the two `userInfo` keys are the stable contract for crash-SDK integrations.
155155

156156
## Configuration
157157

158-
Both flags live in the app's `package.json`, both default to off:
158+
`uncaughtErrorPolicy` in the app's `package.json` is the uncaught-error contract, unified across NativeScript runtimes (9.1+):
159159

160-
| Flag | Effect |
161-
|---|---|
162-
| `discardUncaughtJsExceptions` | Unprevented errors call `__onDiscardedError` instead of `__onUncaughtError` and skip the fatal log. Also disables `crashOnUncaughtJsExceptions`. |
163-
| `crashOnUncaughtJsExceptions` | After an unprevented error is reported, the app crashes with a real `NSException` (JS stack in `reason` and `userInfo`), thrown from a clean frame on the runtime loop so crash reporters capture it properly. |
160+
```jsonc
161+
{ "uncaughtErrorPolicy": "report" } // default: report and keep running
162+
{ "uncaughtErrorPolicy": "throw" } // unprevented errors are rethrown natively
163+
```
164+
165+
- `"report"` — after the event dispatch and legacy hooks, log the fatal message and keep the app alive.
166+
- `"throw"` — additionally rethrow the error as a real `NSException` (`NativeScriptFatalJSException`, JS stack in `reason` and `userInfo`), from a clean frame on the runtime loop. This is a **throw, not a crash guarantee**: the app terminates only if no native handler catches it — which also means crash reporters capture it properly when it does.
167+
- Unknown values log a warning once and fall back to `"report"`.
168+
169+
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`.
164170

165171
Terminal-path decision table for an uncaught error / unhandled rejection:
166172

167-
| Condition | legacy hook called | fatal log / modal | native crash |
173+
| Condition | legacy hook called | fatal log / modal | native throw |
168174
|---|---|---|---|
169-
| default | `__onUncaughtError` | yes | no |
170-
| `discardUncaughtJsExceptions` | `__onDiscardedError` | no | no |
175+
| `"report"` (default) | `__onUncaughtError` | yes | no |
176+
| `"throw"`, unprevented | `__onUncaughtError` | yes | yes |
171177
| listener called `preventDefault()` | no | no | no |
172-
| `crashOnUncaughtJsExceptions`, unprevented | `__onUncaughtError` | yes | yes |
178+
| `discardUncaughtJsExceptions` (deprecated) | `__onDiscardedError` | no | no |
173179

174180
## Crash reporter integration
175181

@@ -193,7 +199,7 @@ globalThis.addEventListener("error", (e) => {
193199
});
194200
```
195201
196-
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, `crashOnUncaughtJsExceptions`).
202+
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"`).
197203
198204
## Legacy hooks (deprecated)
199205

0 commit comments

Comments
 (0)