Skip to content

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

Open
edusperoni wants to merge 4 commits into
mainfrom
feat/js-builtins
Open

refactor: move embedded runtime JS to real .js files (js2c) + enum eval caching#411
edusperoni wants to merge 4 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.

Rebased onto main after the V8 14.9.207.39 upgrade (#412); originally stacked on #409.

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 — including re-verification against main after the V8-upgrade reformatting.

Loader

  • Builtins follow Node's internals idiom: each file is compiled as a function body via ScriptCompiler::CompileFunction with one fixed parameter, binding — natives arrive as properties of a bag object built by the C++ call site and each file destructures what it needs (const { isRuntimeRunloop } = binding;). No IIFE wrappers; top-level return is how a builtin hands a value back to C++. Conventions are documented in NativeScript/runtime/js/README.md.
  • BuiltinLoader::RunBuiltin compiles with a proper internal/<name>.js script origin (runtime frames are identifiable in stack traces) and a process-wide bytecode cache — first isolate compiles with kEagerCompile + CreateCodeCacheForFunction, workers/subsequent isolates consume via kConsumeCodeCache (with rejected-cache fallback), so the builtins are parsed once per process instead of once per isolate.
  • The parameter name is fixed and hardcoded in the loader, so there is nothing per-builtin to typo in C++; on the JS side an ESLint gate (no-undef with binding and the reachable native globals declared, wired into lint-staged) catches typos in the destructures.

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

  • Stack traces from runtime internals now show internal/<name>.js origins instead of anonymous <eval> frames.
  • The placeholder-module extraction from earlier revisions of this PR was dropped: main removed CreatePlaceholderModule entirely (missing modules throw Cannot find module), which matches the de-facto behavior this PR had preserved.

Testing

  • Full TestRunner suite on iOS simulator, rebased on main (V8 14.9.207.39): 905 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.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@edusperoni, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 28 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ad28429-d7f9-40b5-b818-dc0dbaadb71c

📥 Commits

Reviewing files that changed from the base of the PR and between 885abe9 and a56b0d4.

📒 Files selected for processing (20)
  • NativeScript/runtime/BuiltinLoader.cpp
  • NativeScript/runtime/BuiltinLoader.h
  • NativeScript/runtime/ErrorEvents.cpp
  • NativeScript/runtime/Events.cpp
  • NativeScript/runtime/Helpers.mm
  • NativeScript/runtime/Interop.mm
  • NativeScript/runtime/PromiseProxy.cpp
  • NativeScript/runtime/WeakRef.cpp
  • NativeScript/runtime/js/README.md
  • NativeScript/runtime/js/class-extends.js
  • NativeScript/runtime/js/error-events.js
  • NativeScript/runtime/js/events.js
  • NativeScript/runtime/js/promise-proxy.js
  • NativeScript/runtime/js/require-factory.js
  • NativeScript/runtime/js/smart-stringify.js
  • NativeScript/runtime/js/ts-helpers.js
  • eslint.config.mjs
  • package.json
  • tools/js2c.mjs
  • v8ios.xcodeproj/project.pbxproj
📝 Walkthrough

Walkthrough

Runtime JavaScript implementations are moved into generated C++ builtin sources. A cached BuiltinLoader executes them across runtime initialization paths, while enum and global metadata evaluations gain memoization.

Changes

Runtime builtin generation

Layer / File(s) Summary
Builtin source generation and Xcode wiring
.gitignore, package.json, tools/*, v8ios.xcodeproj/project.pbxproj
Adds the js2c generator, input/output manifests, generated-source build phase, compiler wiring, and esbuild development dependency.
Cached builtin execution and runtime integration
NativeScript/runtime/BuiltinLoader.*, NativeScript/runtime/*
Adds mutex-protected V8 code-cache execution and replaces embedded JavaScript initialization with builtin loading.
Runtime JavaScript builtin implementations
NativeScript/runtime/js/*
Adds JavaScript implementations for events, error handling, Promise scheduling, blob URLs, inheritance, interop helpers, module loading, stringification, and WeakRef compatibility.
Enum and global property memoization
NativeScript/runtime/DataWrapper.h, Interop.mm, MetadataBuilder.*
Caches evaluated enum values and materializes JavaScript metadata results as global properties with recursion protection.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Runtime
  participant BuiltinLoader
  participant V8
  participant JavaScriptBuiltin
  Runtime->>BuiltinLoader: RunBuiltin(context, builtinId)
  BuiltinLoader->>V8: Consume cached code or compile source
  V8->>JavaScriptBuiltin: Execute builtin
  JavaScriptBuiltin-->>V8: Return initialization value
  V8-->>BuiltinLoader: Return value
  BuiltinLoader-->>Runtime: Provide function or dispatchers
Loading

Possibly related PRs

Poem

A rabbit hops through scripts anew,
Packed in bytes of runtime blue.
Caches hum and events cheer,
Promises find their runloop near.
“Generated magic!” the bunny sings,
While builtins sprout on codebound wings.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main changes: extracting embedded runtime JS into js2c-built files and adding enum evaluation caching.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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
@NathanWalker NathanWalker added this to the 9.1 milestone Jul 28, 2026
…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.
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.
@edusperoni
edusperoni marked this pull request as ready for review July 30, 2026 12:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
tools/js2c-inputs.xcfilelist (1)

1-12: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

New builtin .js files must be added here too, or incremental builds silently skip regeneration.

This file drives Xcode's incremental-build change detection for the "Generate RuntimeBuiltins" phase, but the phase's shell script actually globs NativeScript/runtime/js/*.js at runtime. If a contributor adds a new builtin script without updating this list, Xcode won't detect it as a changed input and may skip re-running js2c.mjs, leaving the new file out of the generated RuntimeBuiltins.{h,cpp} until an unrelated input changes or a clean build happens.

Consider adding a short comment here (or in tools/js2c.mjs's usage text) noting that this list must be kept in sync with NativeScript/runtime/js/*.js.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/js2c-inputs.xcfilelist` around lines 1 - 12, Add a concise maintenance
comment to the input list documenting that every new
NativeScript/runtime/js/*.js builtin must also be added here so Xcode
incremental builds rerun js2c.mjs. Keep the existing file entries and generation
behavior unchanged.
NativeScript/runtime/js/require-factory.js (1)

1-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: tidy the formatting carried over from the C++ string literal.

The stray line break inside the if body (Lines 4-5) and trailing whitespace are artifacts of the old embedded literal. Now that this is a real source file, normalizing it costs nothing and keeps future diffs readable. Semantics unchanged.

♻️ Proposed cleanup
-(function() { 
-    function require_factory(requireInternal, dirName) { 
-        return function require(modulePath) { 
-            if(global.__pauseOnNextRequire) {  debugger; 
-global.__pauseOnNextRequire = false; }
-            return requireInternal(modulePath, dirName); 
-        } 
-    } 
-    return require_factory; 
-})()
+(function () {
+  function require_factory(requireInternal, dirName) {
+    return function require(modulePath) {
+      if (global.__pauseOnNextRequire) {
+        debugger;
+        global.__pauseOnNextRequire = false;
+      }
+      return requireInternal(modulePath, dirName);
+    };
+  }
+  return require_factory;
+})();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NativeScript/runtime/js/require-factory.js` around lines 1 - 10, Normalize
formatting in the require_factory wrapper, especially the __pauseOnNextRequire
conditional, by removing the embedded line break and trailing whitespace while
preserving debugger invocation, flag reset, and requireInternal behavior.
NativeScript/runtime/js/smart-stringify.js (1)

3-13: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Optional: seen as an array yields O(n²) scans and false [Circular] hits for shared (non-cyclic) references.

seen.indexOf(value) is linear per visited object, and entries are never popped when leaving a subtree, so a repeated-but-acyclic reference is reported as circular. Swapping to a Set fixes the complexity; fixing the false positives needs an ancestor stack instead. Both change existing output, so this is fine to defer given the PR's extraction-fidelity goal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NativeScript/runtime/js/smart-stringify.js` around lines 3 - 13, Defer
changes to the circular-reference tracking in the replacer function: retain the
existing seen array and output behavior for this extraction-focused change. Do
not alter complexity or shared-reference handling unless a follow-up explicitly
scopes those behavioral changes.
NativeScript/runtime/Helpers.mm (1)

497-503: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider a TryCatch around the builtin run.

If RunBuiltin fails (compile/run error), the scheduled exception stays pending on the isolate while this function returns an empty handle. JsonStringifyObject then continues into JSON::Stringify, so the failure surfaces at an unrelated site. Wrapping this call in a local TryCatch (and logging via tns::LogError) keeps the failure contained, matching the pattern used elsewhere in this file.

♻️ Proposed refactor
   Local<Value> result;
-  bool success = BuiltinLoader::RunBuiltin(context, BuiltinId::kSmartStringify).ToLocal(&result);
-  tns::Assert(success, isolate);
-
-  if (result.IsEmpty() || !result->IsFunction()) {
+  TryCatch tc(isolate);
+  bool success = BuiltinLoader::RunBuiltin(context, BuiltinId::kSmartStringify).ToLocal(&result);
+  if (!success || tc.HasCaught()) {
+    tns::LogError(isolate, tc);
+    return Local<v8::Function>();
+  }
+
+  if (result.IsEmpty() || !result->IsFunction()) {
     return Local<v8::Function>();
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NativeScript/runtime/Helpers.mm` around lines 497 - 503, In the
builtin-loading flow around BuiltinLoader::RunBuiltin, add a local v8::TryCatch
and handle compile/run failures before returning an empty function handle. Log
the caught exception through tns::LogError, clear or contain the pending isolate
exception, and preserve the existing result.IsEmpty()/IsFunction() validation
for successful execution.
NativeScript/runtime/WeakRef.cpp (1)

10-19: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider capturing exception details before asserting, like TSHelpers::Init.

Unlike TSHelpers::Init (which wraps the call in a TryCatch and calls tns::LogError(isolate, tc) before asserting), this path asserts on bare success with no way to see why the WeakRef builtin failed to compile/run. Since WeakRef is treated as similarly critical (hard Assert), aligning the diagnostics would make a future failure easier to debug.

♻️ Proposed alignment with TSHelpers::Init
 void WeakRef::Init(Local<Context> context) {
   Isolate* isolate = v8::Isolate::GetCurrent();
+  TryCatch tc(isolate);

   Local<Value> result;
-  bool success =
-      BuiltinLoader::RunBuiltin(context, BuiltinId::kWeakRef).ToLocal(&result);
-  tns::Assert(success, isolate);
+  if (!BuiltinLoader::RunBuiltin(context, BuiltinId::kWeakRef).ToLocal(&result) &&
+      tc.HasCaught()) {
+    tns::LogError(isolate, tc);
+  }
+  tns::Assert(!result.IsEmpty(), isolate);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NativeScript/runtime/WeakRef.cpp` around lines 10 - 19, Update WeakRef::Init
to wrap BuiltinLoader::RunBuiltin in a v8::TryCatch, log the captured exception
with tns::LogError(isolate, tc) when execution fails, then retain the existing
tns::Assert(success, isolate) behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@NativeScript/runtime/Interop.mm`:
- Around line 630-635: Update the enum evaluation flow around
script->Run(context) so a failed ToLocal conversion is asserted or handled
before result is dereferenced. Remove the result.IsEmpty() condition that
suppresses failure handling, and ensure result->IsNumber() is reached only when
result contains a valid value.

---

Nitpick comments:
In `@NativeScript/runtime/Helpers.mm`:
- Around line 497-503: In the builtin-loading flow around
BuiltinLoader::RunBuiltin, add a local v8::TryCatch and handle compile/run
failures before returning an empty function handle. Log the caught exception
through tns::LogError, clear or contain the pending isolate exception, and
preserve the existing result.IsEmpty()/IsFunction() validation for successful
execution.

In `@NativeScript/runtime/js/require-factory.js`:
- Around line 1-10: Normalize formatting in the require_factory wrapper,
especially the __pauseOnNextRequire conditional, by removing the embedded line
break and trailing whitespace while preserving debugger invocation, flag reset,
and requireInternal behavior.

In `@NativeScript/runtime/js/smart-stringify.js`:
- Around line 3-13: Defer changes to the circular-reference tracking in the
replacer function: retain the existing seen array and output behavior for this
extraction-focused change. Do not alter complexity or shared-reference handling
unless a follow-up explicitly scopes those behavioral changes.

In `@NativeScript/runtime/WeakRef.cpp`:
- Around line 10-19: Update WeakRef::Init to wrap BuiltinLoader::RunBuiltin in a
v8::TryCatch, log the captured exception with tns::LogError(isolate, tc) when
execution fails, then retain the existing tns::Assert(success, isolate)
behavior.

In `@tools/js2c-inputs.xcfilelist`:
- Around line 1-12: Add a concise maintenance comment to the input list
documenting that every new NativeScript/runtime/js/*.js builtin must also be
added here so Xcode incremental builds rerun js2c.mjs. Keep the existing file
entries and generation behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 28ac2389-bd4d-4691-9d9f-00fe74a3aa30

📥 Commits

Reviewing files that changed from the base of the PR and between 2f47ad9 and 885abe9.

📒 Files selected for processing (32)
  • .gitignore
  • NativeScript/runtime/BuiltinLoader.cpp
  • NativeScript/runtime/BuiltinLoader.h
  • NativeScript/runtime/ClassBuilder.mm
  • NativeScript/runtime/DataWrapper.h
  • NativeScript/runtime/ErrorEvents.cpp
  • NativeScript/runtime/Events.cpp
  • NativeScript/runtime/Helpers.mm
  • NativeScript/runtime/InlineFunctions.cpp
  • NativeScript/runtime/Interop.mm
  • NativeScript/runtime/MetadataBuilder.h
  • NativeScript/runtime/MetadataBuilder.mm
  • NativeScript/runtime/ModuleInternal.mm
  • NativeScript/runtime/PromiseProxy.cpp
  • NativeScript/runtime/Runtime.mm
  • NativeScript/runtime/TSHelpers.cpp
  • NativeScript/runtime/WeakRef.cpp
  • NativeScript/runtime/js/blob-url.js
  • NativeScript/runtime/js/class-extends.js
  • NativeScript/runtime/js/error-events.js
  • NativeScript/runtime/js/events.js
  • NativeScript/runtime/js/inline-functions.js
  • NativeScript/runtime/js/promise-proxy.js
  • NativeScript/runtime/js/require-factory.js
  • NativeScript/runtime/js/smart-stringify.js
  • NativeScript/runtime/js/ts-helpers.js
  • NativeScript/runtime/js/weak-ref.js
  • package.json
  • tools/js2c-inputs.xcfilelist
  • tools/js2c-outputs.xcfilelist
  • tools/js2c.mjs
  • v8ios.xcodeproj/project.pbxproj

Comment thread NativeScript/runtime/Interop.mm
…rameter

Adopts Node's internals idiom: every builtin is compiled via
ScriptCompiler::CompileFunction with the single parameter `binding`, and
natives arrive as properties of one bag object that each file
destructures at the top (const { isRuntimeRunloop } = binding). The
visible IIFE wrappers are gone, top-level return is the way a builtin
hands a value back to C++, and the bytecode cache moves to
CreateCodeCacheForFunction.

The parameter name is fixed and hardcoded in BuiltinLoader, so there is
no per-builtin name to mistype in C++; on the JS side an ESLint gate
(no-undef with `binding` and the reachable native globals declared)
catches typos in the destructures, wired into lint-staged. Conventions
are documented in NativeScript/runtime/js/README.md.
The enum-evaluation branch in Interop::WriteValue could dereference an
empty handle when Run failed (the && !result.IsEmpty() condition
suppressed the assert); WeakRef::Init and GetSmartJSONStringifyFunction
now log the caught exception before asserting so a builtin failure is
diagnosable; js2c gains --filelist, wired into the build phase, so a
builtin missing from js2c-inputs.xcfilelist fails the build instead of
being silently skipped by incremental change detection.
@edusperoni

Copy link
Copy Markdown
Collaborator Author

Review feedback disposition (a56b0d4):

  • Interop.mm empty-handle deref: fixed — assert fires on Run failure before dereferencing.
  • WeakRef.cpp / Helpers.mm diagnostics: taken — both now TryCatch + tns::LogError before the assert, matching TSHelpers::Init.
  • xcfilelist sync: taken further than suggested — js2c.mjs --filelist (wired into the build phase) fails the build with an add/remove list when js2c-inputs.xcfilelist drifts, instead of relying on a comment.
  • require-factory.js formatting: cleaned up.
  • smart-stringify seen as Set: deliberately skipped in this PR — it changes output for shared acyclic references, and this PR keeps the extracted sources behavior-identical. Reasonable follow-up.

Suite after fixes: 905/905 on the V8 14 base.

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.

2 participants