Skip to content

refactor: move embedded runtime JS to real .js files (js2c) + enum eval caching - #411

Draft
edusperoni wants to merge 9 commits into
mainfrom
feat/js-builtins
Draft

refactor: move embedded runtime JS to real .js files (js2c) + enum eval caching#411
edusperoni wants to merge 9 commits into
mainfrom
feat/js-builtins

Conversation

@edusperoni

@edusperoni edusperoni commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

Moves the ~26KB of JavaScript that was embedded as C++ string literals into real, version-controlled .js files under NativeScript/runtime/js/, compiled into the framework at build time (Node-style js2c), and adds two steady-state perf fixes for jitless mode.

Stacked on #409; draft until that lands.

Extraction & build

  • tools/js2c.mjs (Node CLI) converts runtime/js/*.js into a generated RuntimeBuiltins.{h,cpp} table (byte arrays, deterministic output). Supports --minify via esbuild (whitespace+syntax only) — implemented but not enabled in any configuration.
  • New "Generate RuntimeBuiltins" shell build phase on the NativeScript target, incremental via input/output xcfilelists (skips when nothing changed; verified). Output dir NativeScript/runtime/generated/ is gitignored.
  • Extraction fidelity was machine-verified: prettified files were proven AST-identical to the original runtime strings by byte-comparing esbuild-minified output of both.

Loader

  • BuiltinLoader::RunBuiltin is the single compile-and-run path: proper internal/<name>.js script origins (runtime frames are now identifiable in stack traces) and a process-wide bytecode cache — first isolate compiles with kEagerCompile, workers/subsequent isolates consume via kConsumeCodeCache (with rejected-cache fallback), so the ~26KB is parsed once per process instead of once per isolate.
  • The placeholder-module proxy is now a static builtin (placeholder-module.js) parameterized with the error message instead of splicing it into source text.

Perf (jitless steady-state)

  • Interop::WriteValue no longer recompiles + re-runs an enum's __tsEnum snippet on every FFI marshal — the numeric value is memoized on EnumDataWrapper.
  • GlobalPropertyGetter no longer recompiles a JsCode global's snippet on every read — the evaluated result is defined as a real own property on the global (interceptor is kNonMasking, so later reads bypass it entirely). Includes a reentrancy guard: CreateDataProperty re-enters the interceptor before the property exists.

Behavior notes

  • The legacy placeholder proxy source never compiled — the spliced error message's quotes made it a guaranteed SyntaxError, so the first require() of a missing optional module always threw via the leaked pending exception (which the shared require tests rely on). CreatePlaceholderModule now keeps that contract explicitly with a proper Error: Cannot find module '<name>'; repeat requires still get the cached placeholder, whose exports now actually throw on access as originally designed.
  • Stack traces from runtime internals now show internal/<name>.js origins instead of anonymous <eval> frames.

Testing

  • Full TestRunner suite on iOS simulator (rebased on latest feat/error-handling): 903 tests, 0 failures (includes worker tests, which exercise the cross-isolate code cache).
  • Incremental build check: codegen phase skipped on no-change rebuild, reruns when a .js input is touched.

Register SetPromiseRejectCallback and track rejected promises without
handlers in a per-isolate PromiseRejectionTracker (owned by Caches).
A kCFRunLoopBeforeWaiting observer drains the pending list once per
runloop turn and reports each rejection through the same
__onUncaughtError/__onDiscardedError machinery as uncaught sync
exceptions (log-prefixed 'Unhandled promise rejection:'). A handler
attached before the drain cancels the report. Worker isolates give the
worker-global onerror a chance first, then forward to the main
isolate's worker.onerror like uncaught worker errors.

OnUncaughtError's reporting tail is refactored into the shared
ReportToJsHandlersAndLog helper (behavior preserved); the observer
polls an atomic pending count since rejections can arrive from any
thread holding the v8::Locker, and the drain catches NSExceptions so
they cannot unwind through the observer's live V8 scopes.
Add a JS bootstrap (InitErrorEvents, evaluated for main and worker
isolates right after PromiseProxy::Init) providing spec-shaped Event,
EventTarget, ErrorEvent and PromiseRejectionEvent constructors, the
EventTarget methods on globalThis, and a global reportError().

Uncaught exceptions now dispatch a cancelable 'error' ErrorEvent and
unhandled rejections a cancelable 'unhandledrejection'
PromiseRejectionEvent before reporting; preventDefault() fully handles
the error (no __on* shim, no fatal log, no modal). Unprevented errors
keep the existing behavior byte-for-byte, so NativeScript core's
__onUncaughtError/__onDiscardedError hooks continue to work unchanged.
A handler attached after reporting fires 'rejectionhandled' as a task
on the next drain turn; already-reported promises are held phantom-weak
so GC'd promises drop out.

Native code dispatches through closures returned by the bootstrap IIFE
(stashed as Persistents in Caches), so the events keep firing even if
app code overwrites globalThis.dispatchEvent. Listener-thrown errors
route to the fatal tail without recursive dispatch.
Preserve the original NSException when a native call throws: the JS
error now carries name/message from the exception and the wrapped
original as error.nativeException (re-enables the long-dormant
Throw_ObjC_exceptions_to_JavaScript test everywhere - libffi 3.7.1
fixed simulator unwinding).

Add interop.escapeException(x): returns a JS Error branded with an
isolate-private symbol carrying either the original NSException or
synthesis info. When a branded throw reaches a JS-to-native boundary
(overridden methods and JS-backed blocks via ArgConverter's
MethodCallback, property accessors, adapters), the boundary converts it
to a real @throw into the native caller - always after every V8 scope
has destructed (pendingThrow captured before the scopes, thrown after
the inner block). Unbranded throws keep today's semantics: ReThrow to
the message listener in MethodCallback, and report-once-plus-defined-
default at the former tns::Assert abort sites (property getters/
setters, Array/Dictionary/FastEnumeration adapters no longer abort the
process on a JS throw).

Add opt-in crashOnUncaughtJsExceptions (default off): unprevented fatal
errors schedule an uncaught @throw on the runtime loop from a clean,
V8-scope-free frame so crash reporters capture a real native crash with
the JS stack in userInfo. Branded escapes reaching the fatal tail (e.g.
thrown from an error-event listener) always take that path.
…jectionhandled reason)

- GetEscapeExceptionBrand now checks Caches::IsValid() — Caches::Get
  returns a dummy cache (never null) after isolate disposal, so the
  nullptr check could not honor the documented empty-handle contract.
- The property setter's fallback ObjCDataWrapper is now released via
  DeleteWrapperIfUnused, mirroring the getter (pre-existing asymmetry).
- rejectionhandled now carries the original rejection reason:
  reportedOutstanding_/pendingRejectionHandled_ retain {promise, reason}
  pairs (promise phantom-weak, so a GC'd promise still drops the entry
  and its reason), per PromiseRejectionEvent semantics.
…tException

Event and EventTarget are general-purpose web primitives, not exception
machinery. The generic bootstrap (Event, EventTarget, the globalThis
mixin and the internal backing target, now stashed in
Caches::GlobalEventTarget for future native consumers like AbortSignal)
moves to Events.{h,cpp}; the error layer (ErrorEvent,
PromiseRejectionEvent, reportError and the native dispatch closures)
moves to ErrorEvents.{h,cpp}. NativeScriptException keeps only the
reporting machinery and the rejection tracker.

Pure code motion aside from the cross-layer handoff: the error layer
installs the listener-error reporter into the Events closure through a
one-shot _installListenerErrorReporter hook (the backing target is
reachable from app code via event.target, so the hook removes itself
after runtime init). No behavior change; both new .cpp files are
registered in the Xcode project.
interop.escapeException now captures the escape-site JS stack alongside
the wrapping error's own stack, and every NSException leaving the
escape/crash machinery carries the JavaScript context:

- synthesized escapes keep the stack in the reason and under the
  documented userInfo keys (JavaScriptStack, plus JavaScriptEscapeStack
  when the escape site differs from the origin);
- rethrown ORIGINAL NSExceptions get a combined 'JS stack / Escaped at'
  string attached as an associated object, preserving the exception's
  identity and userInfo for parent @catch matching;
- the crashOnUncaughtJsExceptions fatal exception is attached the same
  way.

New NSException (NativeScript) category exposes it uniformly to native
code and crash-SDK hooks via -[NSException tns_javascriptStackTrace]
(associated object first, userInfo fallback); the userInfo keys are
exported as TNSJavaScriptStackTraceKey/TNSJavaScriptEscapeStackTraceKey
and are the stable integration contract. Non-Error escapes
(escapeException("boom")) now carry the escape-site stack instead of
nothing.
New docs folder with the first runtime feature doc: the WHATWG error
model (error/unhandledrejection/rejectionhandled events, reportError),
what lands on the events for each throw shape, catching native
exceptions in JS via error.nativeException, forwarding JS throws to
native with interop.escapeException, JS stacks on NSException
(tns_javascriptStackTrace and the userInfo keys), configuration flags,
crash-reporter integration on both layers, legacy hook status and the
behavioral invariants.
…js2c

The ~26KB of JavaScript previously embedded as C++ string literals now
lives in NativeScript/runtime/js/*.js. A "Generate RuntimeBuiltins"
build phase runs tools/js2c.mjs to emit a generated source table, and
call sites go through BuiltinLoader::RunBuiltin, which sets proper
internal/<name>.js script origins and shares an in-process bytecode
cache across isolates so workers stop re-parsing the builtins.

The placeholder-module proxy is now a static builtin parameterized with
the error message. The legacy inline proxy source never compiled (the
spliced message's quotes made it a guaranteed SyntaxError), so the first
require() of a missing optional module always threw via the leaked
pending exception; CreatePlaceholderModule now throws an explicit
"Cannot find module" error to keep that contract.
Interop::WriteValue recompiled and re-ran an enum wrapper's __tsEnum
snippet on every FFI marshal; the value is now memoized on the
EnumDataWrapper. GlobalPropertyGetter did the same on every read of a
JsCode global; the evaluated result is now defined as a real own
property on the global (the interceptor is kNonMasking, so later reads
bypass it entirely), with a reentrancy guard because CreateDataProperty
re-enters the interceptor before the property exists.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 74dc8b92-396c-4be4-bf32-7068d45169cf

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from feat/error-handling to main July 27, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant