Skip to content

Bind @SharedObject members directly into the JS object via _decorateSharedObject#22

Open
tsapeta wants to merge 1 commit into
mainfrom
shared-object-direct-binding
Open

Bind @SharedObject members directly into the JS object via _decorateSharedObject#22
tsapeta wants to merge 1 commit into
mainfrom
shared-object-direct-binding

Conversation

@tsapeta

@tsapeta tsapeta commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Why

@SharedObject still routed its @JS members through the DSL Class { Function/Property/Constructor } path, while @ExpoModule already binds module members directly into the JS object via _decorateModule. This brings shared objects onto the same direct-JSI strategy, so their methods, properties, and constructor are bound onto the class prototype without going through the dynamic definition path.

How

@SharedObject now synthesizes two static entry points alongside _synthesizedClassDefinition():

  • _decorateSharedObject(prototype:in:appContext:) binds each @JS func (an inlined setProperty closure) and @JS var (a defineProperty get/set accessor) onto the class prototype. Because a shared object has a distinct native instance behind each JS object, the bindings are static and recover the typed receiver from the JS this per call (try SharedObject.native(from: this.asObject(in: runtime), as: <Type>.self), bound to a _self local) rather than capturing a singleton self.
  • _constructSharedObject(this:arguments:in:appContext:) decodes the @JS init arguments and returns a freshly built instance.

The decode/encode fast path, range-based arity handling, and unowned-this closures are shared with the module path: JSFunction/JSProperty are parameterized by a Receiver (module self vs. shared-object _self). collectProperties moves to MacroHelpers since both macros use it.

The receiver recovery uses expo-modules-core's SharedObject.native(from:as:) and JavaScriptUnownedValue.asObject(in:), added in expo/expo#47054 (now merged).

Test Plan

swift test in apple/: 111 tests across 9 suites pass, including the @SharedObject macro expansion assertions, which pin the generated _decorateSharedObject / _constructSharedObject output (now emitting SharedObject.native(from:as:)).

Merge order

Land #23 (implicitly-unwrapped optional normalization) first. It adds expressionType(_:) and fixes the existing module/record type splices on main. This PR adds new shared-object property splices (_decorateSharedObject's get/set accessors) that have the same IUO bug, so when rebasing this branch onto a main that includes #23, apply expressionType(_:) to those shared-object splices too.

The expo-modules-core API this generates against (SharedObject.native(from:as:) / JavaScriptUnownedValue.asObject(in:), expo/expo#47054) has merged, so that dependency is no longer a blocker.

@tsapeta tsapeta force-pushed the shared-object-direct-binding branch from 63becea to 897715d Compare June 19, 2026 21:13
…teSharedObject`

Move `@SharedObject`'s `@JS func`s, `@JS var`s, and the `@JS init` off the DSL
`Class { Function/Property/Constructor }` path and bind them directly into the
shared object's JS object, the same direct-JSI strategy `@ExpoModule` already
uses for modules.

`@SharedObject` now synthesizes two static entry points alongside
`_synthesizedClassDefinition()`:

- `_decorateSharedObject(prototype:in:appContext:)` binds each `@JS func`
  (inlined `setProperty` closure) and `@JS var` (a `defineProperty` get/set
  accessor) onto the class prototype. The first parameter is named `prototype`
  (not `object` as on `_decorateModule`) because core hands in the shared class
  prototype, not an instance. Because a shared object has a distinct native
  instance behind each JS object, the bindings are static and recover the typed
  receiver from the JS `this` per call (`try SharedObject.native(from:
  this.asObject(in: runtime), as: <Type>.self)`, bound to a `_self` local)
  rather than capturing a singleton `self`.
- `_constructSharedObject(this:arguments:in:appContext:)` decodes the `@JS init`
  arguments and returns a freshly built instance.

The decode/encode fast path, arity handling (range-based check throwing
`Exceptions.ArgumentsRangeMismatch`), and unowned-`this` closures are shared
with the module path. To express both receivers, `JSFunction`/`JSProperty` are
parameterized by a `Receiver` (module `self` vs. shared-object `_self`): the
module bindings keep capturing `self` strong and ignore `this`; the
shared-object bindings capture nothing of the instance and recover `this`. The
`_self` local is named with a leading underscore so it never collides with a
user member (e.g. a `var owner`).

`collectProperties` moves to `MacroHelpers` since both macros now use it. The
`Class` block keeps only non-`@JS` definitions (none today), so it's empty when
every member is `@JS`.
@tsapeta tsapeta force-pushed the shared-object-direct-binding branch from 897715d to 62bce8f Compare June 19, 2026 21:19
@tsapeta tsapeta marked this pull request as ready for review June 19, 2026 22:15
@tsapeta tsapeta requested a review from Kudo June 19, 2026 22: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.

1 participant