Skip to content

Emit unowned-this closures for synchronous @JS bindings#20

Merged
tsapeta merged 2 commits into
mainfrom
tsapeta/borrowing-unowned-value
Jun 17, 2026
Merged

Emit unowned-this closures for synchronous @JS bindings#20
tsapeta merged 2 commits into
mainfrom
tsapeta/borrowing-unowned-value

Conversation

@tsapeta

@tsapeta tsapeta commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Why

Synchronous @JS functions and property accessors never read the JS this — the receiver is the module's real self, and the synthesized body calls self.<name>(...) directly. Yet the generated setProperty closure bound this as an owning JavaScriptValue, which allocates the value and forms/destroys its weak runtime reference on every call. Profiling the no-op @JS call floor showed this per-call churn was a meaningful slice of the Swift-side cost.

How

Sync @JS functions and property get/set accessors now bind through the unowned-this setProperty overload added in expo-modules-jsi, which hands this to the closure as a borrowed JavaScriptUnownedValue instead of an owning JavaScriptValue. The closure's first parameter is typed borrowing JavaScriptUnownedValue so overload resolution selects that (otherwise @_disfavoredOverload) overload. Because Swift rejects a type annotation on a shorthand { [capture] name, name in } parameter, the synthesized parameter list is now parenthesized and fully typed.

Async functions are unchanged: they keep the untyped shorthand and the owning-this overload, since there is no unowned-this async variant and the arguments buffer escapes into the task anyway.

Requires the unowned-this createFunction/setProperty overloads in expo-modules-jsi: expo/expo#46949.

Test Plan

Existing macro expansion tests pass. End-to-end, built locally and verified against the Modules Core benchmarks: the synthesized @JS host-function path drops the per-call owning-this allocation and weak-runtime traffic, improving the no-op call floor ~28% and the argument cases ~15–18% on the iPhone simulator, and making @JS the fastest host-function tier (overtaking @OptimizedFunction).

tsapeta added 2 commits June 16, 2026 10:31
Synchronous `@JS` functions and property accessors never read the JS `this` (the receiver is the
module's real `self`), yet the synthesized `setProperty` closure bound `this` as an owning
`JavaScriptValue`, allocating it and forming/destroying its `weak` runtime reference on every call.
They now bind through the unowned-`this` `setProperty` overload, typing the closure's first parameter
`borrowing JavaScriptUnownedValue` to select it. The parameter list is parenthesized and fully typed
because Swift rejects a type annotation on a shorthand closure parameter. Async functions keep the
owning-`this` overload: there is no unowned-`this` async variant and the buffer escapes into the task.
The macro now emits sync `@JS` bindings with a fully-typed closure
parameter list (`(this: borrowing JavaScriptUnownedValue, arguments:
consuming JavaScriptValuesBuffer) in`) to select the unowned-`this`
`setProperty` overload. The fixture expectations still carried the old
`this, arguments in` shorthand, so every sync-binding expansion test was
failing. Update them to match. Async fixtures keep the shorthand and the
owning-`this` overload, so they are left untouched.
@tsapeta tsapeta marked this pull request as ready for review June 16, 2026 14:19
@tsapeta tsapeta force-pushed the tsapeta/borrowing-unowned-value branch from 58619e8 to 0edc92a Compare June 16, 2026 16:57
@tsapeta tsapeta requested a review from Kudo June 16, 2026 20:21
@tsapeta tsapeta merged commit 0ebb7d6 into main Jun 17, 2026
1 of 3 checks passed
@tsapeta tsapeta deleted the tsapeta/borrowing-unowned-value branch June 17, 2026 18:15
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