Skip to content

feat(dotnet): C# interop parity, faster startup and fixes across the napi engines - #33

Merged
triniwiz merged 10 commits into
mainfrom
feat/dotnet-interop-parity
Oct 23, 2026
Merged

triniwiz merged 10 commits into
mainfrom
feat/dotnet-interop-parity

Conversation

@triniwiz

Copy link
Copy Markdown
Member

C# interop from JavaScript now covers what the Android runtime offers for Java, plus the C# features Java has no counterpart for. The .NET bridge and the napi engines also start faster. Each commit stands on its own; they are listed in order below.

Changes

fix(jsc): BigInt values in the JSC Node-API shim. The shim had no BigInt functions, so any BigInt crossing the boundary (a .NET long, a WinRT __native_ptr) aborted the process. They are now implemented on the BigInt C API that the bundled JavaScriptCore.dll exports.

feat(typings): .NET typings that match the runtime. Nested types as Outer.Inner, type parameters and of() for generics, number | bigint for longs, Uint8Array for byte[], optional, params and out/ref parameters, BCL delegates and Tasks as JS function and promise types, protected members, and virtual properties as accessors so subclasses can override them.

feat(dotnet): fields, generics, events and out parameters of .NET types from JS. The NSWinRT.dotnet JS layer was hand-copied into both engines; it now lives once in runtime/src/js and both include it. New on both engines:

  • fields, constants and enum members (DayOfWeek.Monday, tuple Item1), and nested types
  • one JS object per .NET object (the bridge counts the exports of each handle, so a release can't race a new one)
  • long/ulong beyond 2^53 as BigInt in both directions (BigInt arguments used to arrive as null), and byte[] as Uint8Array, with typed arrays, ArrayBuffers and DataViews accepted as arguments
  • optional and named parameters (NSWinRT.dotnet.named), ref/out through NSWinRT.interop.out(), and [result, ...outs] when out arguments are left off, as WinRT methods do
  • generic types (List.of(System.String)), generic methods (inferred, or .of(T)) and extension methods (list.Where(fn).ToList())
  • events: obj.Event = fn replaces the handler like a WinRT event, and remove_X(fn) removes the delegate added for fn
  • errors carrying the .NET type, stack and error.nativeException; faulted Tasks reject with an Error; JS errors reach C# with their stack; unhandled .NET exceptions reach __onUncaughtError
  • protected members and generic virtual overrides in JS subclasses, toString() as ToString(), string-keyed dictionaries as dict.key, and NSWinRT.dotnet.loadAssembly(path)

It also fixes the fallback for an overload that isn't cached, which passed an oversized pooled array to MethodInfo.Invoke and always threw.

perf(dotnet): faster bridge startup, first calls and calls into JS.

  • The namespace map JS asks for at startup is read from assembly metadata instead of loading every DLL. The test app's first bridge call went from ~505 ms to ~124 ms, CLR start included.
  • Methods start on MethodInvoker and get a compiled delegate after 32 calls. A method's first call went from ~80 µs to ~0.3 µs.
  • Calls from .NET into JS overrides went from 233 to 167 ns, and from 424 to 80 B allocated.
  • The app build now republishes the bridge when any bridge source changes, not only Bridge.cs.

perf(napi): compiled-script caches. V8 (code cache) and QuickJS (bytecode) keep compiled scripts in the app's local folder, and Hermes's own script cache is turned on. require() compiles modules through a new __nsCompileScript instead of new Function, so required chunks are cached too. A 9 MB script's require() on later launches: V8 232 → ~64 ms, QuickJS 871 → ~350 ms.

feat(dotnet): .NET code can create instances of JS classes. NSWinRT.dotnet.typeOf(JsClass) returns the class's .NET type, named NSWinRTDynamicProxies.<ClassName>. Activator.CreateInstance, a DI container or Type.GetType by name can create it, and the bridge asks the JS class for each instance's JS object. This covers what Android's static binding generator is for.

fix(dotnet): release .NET handles on Hermes/JSC; revived JS objects on QuickJS. Hermes and JSC never run FinalizationRegistry callbacks in this host, so .NET handles were never released there; the JS layer now also sweeps collected proxies. QuickJS frees an object as soon as nothing holds it strongly, so a revived subclass instance was gone before the caller got it. That fixes Lifetime specs that already failed on main.

fix(jsc): weak references that survive a garbage collection. A weak napi_ref tracked its object by address, so after a collection it could return whatever object reused that memory. That lost WinRT object identity and caused the intermittent 0xC000027B crash in the WinRT subclass specs. References now use JavaScriptCore's JSWeak handles, and the test-only gc() runs a real collection. The JSC benchmark is ~10% faster.

perf(hermes): precompile the app to Hermes bytecode. Hermes compiles big sources lazily and its cache keeps none of them. Release builds of a Hermes app now run hermesc over the app (tools/hermes-precompile.ps1, from the app csproj), and the runtime runs the bytecode when it isn't older than the script, so a livesync edit runs from source. A 9 MB script's require(): ~530–660 ms → ~33 ms.

chore: the engine packages' lockfiles gain metadata-filter, which the metadata crate already depends on.

Testing

Windows 11, x64, test app (pwsh test-app/run.ps1, with -EngineDll for the napi engines):

Runtime Specs
classic V8 134/134
napi V8 126/126 (8 worker/gc specs pending, as before)
QuickJS 129/129
Hermes 129/129, also as a Release build running from bytecode
JSC 129/129, three runs

The new specs are in test-app/app/tests/csharp-interop.js, with fixtures in TestFixtures/Interop.cs. dotnet-bridge-tests: 136/136, 16 of them new (InteropTests.cs). windows-napi npm test: 427/427. The generated typings type-check under tsc --strict against code mirroring the specs.

Timings come from the test app and from dotnet-bridge-benchmarks (the new CallbackBenchmarks and EntryBenchmarks).

Notes

  • packages/windows-hermes/vendor/x64/tools/hermesc.exe (3.6 MB) is vendored from the same Microsoft.JavaScript.Hermes package version as hermes.dll, because bytecode is version-specific. The vendor README has the refresh steps.
  • The bridge wire format gained tags (0x10/0x11 BigInt, 0x12 binary data, a longer 0xFF error) and an export count on release. Runtime and bridge ship together, as before.
  • template/framework/dotnet-bridge and the other built framework files aren't regenerated here; template/build.ps1 does that.
  • Tried and dropped after measuring: ReadyToRun for the bridge (no difference in the app) and a V8 startup snapshot for the classic engine (the runtime's built-in JS compiles in ~3 ms).

The shim declared no BigInt support, so napi_create_bigint_* and
napi_get_value_bigint_* were missing and any BigInt crossing the boundary
(a .NET long, a WinRT __native_ptr) aborted the process. The
JavaScriptCore.dll this package ships exports the BigInt C API, so the
shim now implements them on it and napi_typeof reports napi_bigint.
- nested types in a namespace named after their outer type (Outer.Inner)
- type parameters on generic classes and methods, plus static of()
- long/ulong as number | bigint, byte[] as Uint8Array (and what JS can pass)
- optional and params parameters, out/ref references, and the
  [result, ...outs] overload for out parameters left off
- BCL delegates, Task and Nullable as JS function, PromiseLike and T | null
- protected members, abstract classes, virtual properties as accessors so
  JS subclasses can override them
- parameter names read by sequence number (a return row no longer shifts them)
…es from JS


The .NET JS layer (NSWinRT.dotnet and JS subclasses of .NET/WinRT classes)
was hand-copied into both engines; it now lives once in runtime/src/js and
both include it.

What C# code can now do from JS, on the classic and all napi engines:
- fields, constants and enum members (DayOfWeek.Monday, tuple Item1,
  struct fields), read-only ones refusing assignment
- nested types as Outer.Inner
- one JS object per .NET object: the bridge hands out one handle per object
  and counts its exports, so a release racing a new export can't drop it
- long/ulong beyond 2^53 as BigInt both ways; BigInt arguments were null
- byte[] (and Memory<byte>, ArraySegment<byte>) as Uint8Array; typed arrays,
  ArrayBuffers and DataViews as arrays, Memory<T> or Stream
- optional/default parameters, named arguments (NSWinRT.dotnet.named),
  ref/out through NSWinRT.interop.out() and [result, ...outs] when out
  parameters are left off, as WinRT methods do
- generic types (List.of(System.String)) and methods (inferred, or .of(T))
- extension methods (list.Where(fn).ToList())
- events: obj.Event = fn replaces the handler as a WinRT event does, and
  add_X/remove_X remove the delegate that was added for a function
- string-keyed dictionaries by key (dict.key)
- errors carrying the .NET type, stack and exception (error.nativeException);
  faulted Tasks reject with an Error; JS errors reach C# with their stack,
  from void callbacks too; unhandled .NET exceptions reach __onUncaughtError
- protected members and generic virtuals in JS subclasses
- toString() is the object's ToString()
- NSWinRT.dotnet.loadAssembly(path); a type in an assembly loaded later now
  resolves (failed lookups were cached forever)

Also fixes the fallback for an overload that isn't cached, which passed a
pooled oversized array to MethodInfo.Invoke and always threw.

Tests: dotnet-bridge-tests/InteropTests.cs (136 xunit), test-app
tests/csharp-interop.js (42 specs; classic 131/131, napi V8 123/123;
QuickJS, Hermes and JSC fail only pre-existing Lifetime specs).
- The namespace map JS asks for at startup is read from assembly metadata
  instead of loading every DLL of the app and listing its types: the first
  bridge call of the test app went from ~505 ms to ~124 ms (CLR start
  included). Paths the map lists as namespaces no longer ask the bridge for
  type members (each miss scanned every assembly).
- Methods start on MethodInvoker and get a compiled delegate after 32 calls:
  a method's first call went from ~80 us (Expression.Compile) to ~0.3 us;
  warm calls cost 1-2 ns more.
- Calls from .NET into JS reuse a per-thread request buffer and write the
  instance and member of an overridden member directly: 200/176/424 B ->
  80/56/80 B per delegate / void delegate / override call, overrides
  233 -> 167 ns (dotnet-bridge-benchmarks CallbackBenchmarks).
- The app build republishes the bridge when any of its sources changes, not
  only Bridge.cs.
The classic engine keeps V8's code cache for app scripts; the napi engines
compiled every script from source on every launch. Each now keeps compiled
code in the app's local folder, keyed by script name and source (and engine
build), so a later launch skips compiling the bundle:

- V8: ScriptCompiler code cache, made after the first run (covers the
  functions run at startup); V8 rejects a stale one and it is rewritten.
- QuickJS: bytecode from a compile-only eval (JS_WriteObject/JS_ReadObject),
  checksummed, keyed to the engine build.
- Hermes: its own jsr script cache. Hermes compiles large sources lazily and
  doesn't store those, so it helps module-sized scripts; big bundles need
  hermesc at build time.

CommonJS modules compile through a new __nsCompileScript(code, filename)
instead of new Function, so required modules (vendor.js) are cached too.

require() of a 9 MB script in the test app, first launch -> later launches:
V8 232 -> ~64 ms, QuickJS 871 -> ~350 ms.
What the Android runtime's static binding generator gives Java code:
constructing a JS-implemented class by type, the JS object made on demand.

- NSWinRT.dotnet.typeOf(JsClass) returns the class's .NET type, named
  NSWinRTDynamicProxies.<ClassName> (new JsClass() from JS uses the same
  type). Activator.CreateInstance, a DI container or
  Type.GetType("NSWinRTDynamicProxies.X, NSWinRTDynamicProxies") can create
  it; the emitted constructor asks the JS class for the instance's JS object
  (its init() runs, as for an object revived after collection).
- Emitted types are named after their JS class instead of
  <Base>_JsProxy_<n> when the class has a name.

Specs: csharp-interop.js "JS classes created by .NET code" (classic 134/134,
napi V8 126/126).
…n QuickJS


- Hermes and JSC (in this host) never run FinalizationRegistry callbacks, so
  .NET objects JS had dropped were never released there. Their WeakRefs do
  clear: every 250 ms at most, a bridge call also releases the handles of
  collected proxies and JS subclass instances (a callback that does run finds
  nothing left to do).
- QuickJS frees an object as soon as nothing holds it strongly, so the JS
  object made for an instance only native code held (after its first one was
  collected, or one .NET created) was gone before the caller got it. It is
  now held until handed out.

Fixes the Lifetime specs that failed on QuickJS ("keeps working when only
native code holds the instance") and Hermes ("releases instances neither JS
nor native code references"): classic 134/134, napi V8 126/126, QuickJS and
Hermes 129/129. JSC still fails that release spec (its gc() only hints at a
collection) and intermittently crashes in extend-winrt, both pre-existing.
A weak napi_ref tracked its object by address in a set, cleared when a
holder object attached to the target was finalized. Holder and target die
together but are swept separately, so a dead object's address could be
reused (by an external, a new wrapper) while still in the set: the ref then
returned whatever lived there. The runtime's WinRT identity cache is built
on weak refs, so after a collection a WinRT object could come back as a bare
external, or as a fresh wrapper without its JS subclass prototype (XAML then
called the native MeasureOverride from super.MeasureOverride(): the
intermittent 0xC000027B crash in extend-winrt).

References now hold their object through JavaScriptCore's JSWeak handles,
which the collector clears before the memory is reused; no holder object or
property is added per referenced object anymore. gc() runs a synchronous
full collection (JSGarbageCollect only hinted at one, so the Lifetime specs
never really collected on JSC).

JSC test app: 129/129, three runs. NSWIN_BENCH ~10% faster across the board.
Hermes compiles big sources lazily and its script cache keeps none of
them, so the Hermes runtime compiled the app's bundles on every launch.

- Release builds of an @nativescript/windows-hermes app run hermesc over the
  app's JavaScript (tools/hermes-precompile.ps1, from the app csproj):
  <file>.hbc for scripts the host runs as is (runtime.js, vendor.js,
  bundle.js, the package.json main), <file>.cjs.hbc for modules, compiled
  inside the CommonJS wrapper. Unchanged files are skipped.
- The runtime runs that bytecode (jsr_run_script_buffer) when it is at least
  as new as the script, so a livesync push runs from source. Entry scripts
  the host names by file name are found in the app folder.
- hermesc.exe is vendored from the same Microsoft.JavaScript.Hermes package
  as hermes.dll (bytecode is version-specific) and staged into the framework
  tools by build.ps1 -Engine hermes; the test app picks it up too.

require() of a 9 MB script in the test app: ~530-660 ms -> ~33 ms. Hermes
test app as a Release build, running from bytecode: 129/129.
The metadata crate depends on metadata-filter; the engine packages' lockfiles
predated that.
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. 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
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d6c18c5e-e4c9-4bd1-9455-c657e651a993

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
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@triniwiz
triniwiz merged commit 2da9321 into main Oct 23, 2026
3 checks passed
@triniwiz
triniwiz deleted the feat/dotnet-interop-parity branch October 23, 2026 20:27
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