From ea8ef2b3115d21e8b7570eb3fc87369163fbf5cd Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Tue, 2 Jun 2026 18:12:47 +0200 Subject: [PATCH 01/15] Add Expo Modules v2 design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A staged design for extending the Swift macros to cover the full Expo Modules surface, in three phases: 1. DSL coverage — @ExpoModule / @JS / @SharedObject / @Record / @Union / @ViewProps / @ExpoView / @Event synthesizing today's definition DSL; typed events (no EventDispatcher), typed unions, view props as a struct with function-typed fields as events, fields-by-default with inferred requiredness. 2. Performance — direct JSI binding: the macro builds the JS object via expo-modules-jsi (createFunction + per-type casts) instead of the dynamic [Any]/toTuple path; generalizes the @OptimizedFunction PoC. 3. TypeScript type generation — reuse expo-type-information to emit .d.ts from the annotated source. Captures the resolved decisions, the verified core signatures, and the cross-repo core dependencies each phase needs. --- docs/expo-modules-v2.md | 1195 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1195 insertions(+) create mode 100644 docs/expo-modules-v2.md diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md new file mode 100644 index 0000000..307291c --- /dev/null +++ b/docs/expo-modules-v2.md @@ -0,0 +1,1195 @@ +# Expo Modules v2 + +- **Status:** Draft for review (signatures verified against `expo/main` core) +- **Author:** Tomasz Sapeta +- **Created:** 2026-05-31 · **Updated:** 2026-06-02 +- **Scope:** Apple/Swift macros in this repo (`apple/Sources/ExpoModulesMacros`) + +## Goal + +Use Swift macros to make authoring Expo modules and views simpler, faster, and +type-safe end to end — an author writes ordinary Swift (`@JS` members, a typed props +object, typed events) and the toolchain takes care of the rest. The work is staged +in three phases: + +## Phases + +1. **[DSL coverage](#phase-1--dsl-coverage)** — macros that cover the existing API + surface and synthesize the definition using **today's DSL** (`Function`, + `Property`, `Class`, `View`, `Events`, …). The macro reads `@JS`/`@ViewProps`/etc. + and emits the same `*Definition` element tree the runtime walks today. This is the + bulk of this document (Modules + Views below). +2. **[Performance — direct JSI binding](#phase-2--performance-direct-jsi-binding)** — + instead of emitting the DSL and letting the runtime build JS objects dynamically, + the macro generates **pure-Swift** code that **binds members directly into the JS object** via + the `expo-modules-jsi` API. This omits the `*FunctionDefinition` indirection and the + dynamic-type/`[Any]`/`toTuple` argument path — the biggest current bottleneck — in + favor of statically-typed, per-argument converters covering **all supported types**. + The existing `@OptimizedFunction` is an early proof of concept; the goal is for + *every* synthesized function to be optimized by default, after which + `@OptimizedFunction` is dropped. +3. **[TypeScript type generation](#phase-3--typescript-type-generation)** — generate + the module's `.d.ts` (functions, records, view props + event callbacks) from the + annotated Swift source, so JS types always match native. + +Phase 1 produces a correct, complete surface on the existing runtime; phase 2 swaps +the *implementation strategy* underneath the same author-facing API for speed; phase 3 +adds the JS-side type story on top of the same source annotations. Phases 2 and 3 both +build on the declarative description phase 1 establishes. + +Cross-cutting decisions referenced throughout: + +- **Events: typed payloads, no `EventDispatcher`.** An event's payload is typed at + the Swift level (payload type is `AnyArgument`, or none) and funnels into the + existing untyped runtime. See [Events model](#events-model). +- **Lifecycle: handled in core, not the macro.** Overridable instance methods that + core calls directly — the macro synthesizes nothing for lifecycle. See the + per-section lifecycle notes. +- **Macro declarations** for new attributes live in **`expo-modules-core`** + (`ios/Core/ExpoModulesMacros.swift`), alongside `@JS`/`@ExpoModule`. Core symbols + in this doc were read from `/Users/tsapeta/Work/expo/main/packages/expo-modules-core`. + +--- + +# Phase 1 — DSL coverage + +Cover the existing API by synthesizing the current definition DSL. The macros below +read the author's `@JS`/`@ViewProps`/etc. declarations and emit the same +`*Definition` element tree (`Function`, `AsyncFunction`, `Property`, `Class`, `View`, +`Events`, …) the runtime already consumes — no runtime changes beyond the core +dependencies noted per section. Organized as **Modules** and **Views**, each listing +its macros as subsections. + +## Modules + +A module is a class the macro turns into an Expo module: it synthesizes the +definition method, the `AnyModule` conformance, and the lifecycle/event plumbing, +discovering the JS surface from `@JS`-marked members. + +### `@ExpoModule` + +`@ExpoModule` / `@ExpoModule("CustomName")` on a module class. Synthesizes +`_synthesizedDefinition() -> [AnyDefinition]` from the module name plus the `@JS` +members in the body, and (when the class doesn't inherit `Module`/`BaseModule`) +the `appContext` storage, `init(appContext:)`, and the `AnyModule` conformance — +so inheriting from `Module` is optional. It also stamps `@ModuleDefinitionBuilder` +on a user-written `definition()`. + +```swift +@ExpoModule(views: [MyView.self]) +class MyModule: Module { + @JS var version: String { "1.0" } // getter-only → Property("version") { self.version } + @JS var ready: Bool = false // getter+setter (see below) + @JS func reset() {} + + override func onModuleCreate() {} // core calls directly — NOT macro-synthesized + override func onStartObserving() {} // core calls directly — NOT macro-synthesized +} +``` + +Arguments: +- `_ name: String? = nil` — JS module name (defaults to the class name). +- `classes: [Any.Type] = []` — shared-object classes whose + `_synthesizedClassDefinition()` is spliced in. +- `views: [Any.Type] = []` — view classes; each contributes + `MyView._synthesizedViewDefinition()` (see [Views](#views)). Read with the + existing `classListArgument(of:label:"views")` `.self` parser. + +#### Property getter + setter + +Extend the property-entry builder to emit `.set` for a settable stored/computed +var; getter-only (`{ get }` or `let`) stays as today. + +```swift +Property("ready") { self.ready }.set { (newValue: Bool) in self.ready = newValue } +``` + +Setter is a chained method on `PropertyDefinition` +(`Core/Objects/PropertyDefinition.swift:105`, `@discardableResult`, returns `Self`): +```swift +.set(_ setter: @escaping (_ newValue: ValueType) -> Void) -> Self +.set(_ setter: @escaping (_ this: OwnerType, _ newValue: ValueType) -> Void) -> Self +``` + +> **No `@Constant` macro.** `ConstantDefinition` differs from a getter-only +> `PropertyDefinition` only by also feeding the **legacy synchronous +> `getConstants()` bridge** (`getRawValue()` → `ObjectDefinition.legacyConstants`); +> on the JSI path a getter-only `Property` is equivalent. A getter-only `@JS var` +> therefore already exposes a read-only value. Revisit only if a real consumer +> needs the legacy map — and if so, prefer a `@JS(constant:)` modifier over a +> second attribute (avoids two ~95%-overlapping property attributes). + +#### Module events + +Typed, no `EventDispatcher` (see [Events model](#events-model)). An event is a +**function-typed property** marked `@Event` — the same "function = event" idea as view +props, but here the macro (not core) wires the dispatch, since a module has no props +object for core to populate. + +```swift +@ExpoModule +class MyModule: Module { + @Event var onProgress: (ProgressEvent) -> Void // ProgressEvent: @Record + @Event var onReady: () -> Void // no payload + + func work() { + self.onProgress(ProgressEvent(percent: 50)) // type-checked; dispatches to JS + } +} +``` + +`@Event` is an **accessor macro**: a function-typed `var` can't be a stored property +without an initializer, so it expands to a **computed property returning a closure** that +captures `self` and dispatches by name into the typed `emit`: + +```swift +var onProgress: (ProgressEvent) -> Void { + { payload in self.emit(event: "onProgress", payload: payload) } +} +``` + +This is the **same on modules and shared objects**: both expose +`emit(event:payload:)` (`SharedObject.swift:102`; assumed added to +`Module` to match — see [Core dependencies](#core-dependencies)), which converts the +payload via `(~P.self).castToJS`. So a `@Event` payload can be **any `AnyArgument`** — +primitive, `@Record`, shared object, `@Union`, etc. — with no dict requirement, and the +no-payload case (`() -> Void`) dispatches `.undefined`. + +`@ExpoModule`/`@SharedObject` separately collect `@Event` member names and emit +`Events("onProgress", …)` into the synthesized definition, the same way they collect +`@JS` members — so JS knows the event exists and `OnStartObserving` works. + +The synthesized closure allocates per access — negligible for events (not a hot loop). +`@Event` members are stamped `@JavaScriptActor` like other JS members. + +#### Module lifecycle + +Handled **entirely in core, not by the macro.** Core adds overridable instance +methods on `BaseModule` and calls the override directly at each lifecycle point; +the macro synthesizes nothing. + +```swift +override func onModuleCreate() { … } // core invokes at .moduleCreate +override func onModuleDestroy() { … } // core invokes at .moduleDestroy +override func onAppContextDestroys() { … } +override func onStartObserving() { … } +override func onStopObserving() { … } +``` + +Core-side work (out of this repo): add these as `open func … {}` on `BaseModule` +(`ios/Core/Modules/Module.swift`) and call the override from `ModuleHolder` where +it currently does `post(event: .moduleCreate)` / `.moduleDestroy` +(`ios/Core/ModuleHolder.swift:50,139`) and from the observer dispatch. The old +`OnCreate`/`OnDestroy`/`OnStartObserving`/`OnStopObserving`/`OnAppContextDestroys` +DSL factories + `EventListener(.moduleCreate, …)` path can be retired once nothing +emits them. + +### `@JS` + +Marker (peer macro) on a module / shared-object member that should be exposed to +JS; expands to nothing on its own. `@ExpoModule` and `@SharedObject` discover +`@JS`-marked members and generate the `Function` / `AsyncFunction` / `Property` / +`Constructor` registrations. `@JS("name")` overrides the JS name. + +```swift +@JS func greet(name: String) -> String { … } // Function("greet", greet) +@JS("doWork") func performWork() async throws {} // AsyncFunction("doWork", performWork) +@JS var status: String { "ok" } // Property("status") { self.status } +``` + +The macro stamps `@JavaScriptActor` on `@JS` members (skipping `nonisolated` or members +already on a global actor), reflecting that JS calls run on the JS thread. **This now +includes `async` members** — see below. + +#### Async functions + +Sync vs. async is expressed by the Swift `async` keyword; there is **no Promise +parameter**. + +- **A `@JS` function marked `async` in Swift automatically returns a `Promise` on the + JS side.** The macro emits an `AsyncFunction` whose closure is the `async` function + itself (core's `@Sendable async` overload). +- **Promise-based async functions are dropped.** We stop supporting the old form where + the author takes a trailing `Promise` argument and calls `promise.resolve(…)` + manually (core's `takesPromise` path). Async is the Swift `async` keyword, full stop. +- **Async functions are also stamped `@JavaScriptActor`** — a reversal of the current + macro, which skips async members. The stamp is what makes the next point work. +- **Execution matches JS async semantics: the body runs synchronously on the runtime + thread up to the first real suspension point** (everything before the first `await` + that actually suspends runs inline), then the continuation is scheduled. This holds + because the function is `@JavaScriptActor`-isolated *and* invoked from the JS-actor + context (the runtime thread): calling an actor-isolated `async` function from the same + actor runs synchronously until an actual suspension — no executor hop before the body + (SE-0306). Without the `@JavaScriptActor` stamp the call would hop to a generic + executor first and lose this property; that's why point 3 and point 4 are linked. + +```swift +@JS func fetchUser(id: String) async throws -> User { … } +// JS: const user = await module.fetchUser("42") → returns a Promise +// body up to the first awaited suspension runs synchronously on the JS thread +``` + +> **Core dependency.** Relies on `@JavaScriptActor`'s custom `SerialExecutor` correctly +> reporting "already on the JS thread" (SE-0471 `isIsolatingCurrentContext` / SE-0424 +> `checkIsolated`) so the runtime treats a same-thread call as no-hop. The +> Promise-param `AsyncFunction` overload + `takesPromise` path can be retired from core +> once nothing emits them. + +### `@SharedObject` + +Member macro on a `SharedObject` subclass; synthesizes +`_synthesizedClassDefinition() -> ClassDefinition` (Function / AsyncFunction / +Property / Constructor from `@JS` members) ready to drop into a module's +`Class { ... }` slot, with the same `@JavaScriptActor` stamping as `@ExpoModule`. + +```swift +@SharedObject +final class Cache: SharedObject { + @JS init(name: String) { … } // Constructor + @JS func get(_ key: String) -> String? { … } // instance → prototype + @JS var size: Int { … } // instance property + @JS static func open(_ path: String) -> Cache { … } // static → constructor (Cache.open) + @Event var onEvicted: (EvictedEvent) -> Void // event (see Module events) +} +``` + +#### Static vs. instance members + +Shared objects (unlike modules) can expose **static/class members** alongside instance +ones. The differentiator is the Swift **`static` modifier** — no separate attribute: the +macro reads `static func` / `static var` and routes them to the static side. + +This can't collide with instance members because JS places them differently, exactly as +core's `ClassDefinition` already does at install time (`ClassDefinition.swift:88–96`): +- **Instance** members (`@JS func`, `@JS var`) → decorate the **prototype** + (`decorateWithFunctions`/`decorateWithProperties` on `object.prototype`). +- **Static** members (`@JS static func`) → decorate the **constructor object** + (`decorateWithStaticFunctions`) — i.e. `Cache.open(...)`, callable on the class itself. + +DSL mapping: an instance function emits `Function("get", …)`; a static one emits +**`StaticFunction("open", …)`** (`StaticFunctionDefinition`, the static counterpart core +already provides). In phase 2 (direct JSI binding), statics decorate the constructor and +instance members the prototype — the same split, just done by the generated builder. + +**Binding names don't need a new sigil.** For a shared object, **all** `` `#…` `` +bindings are `static func`s on the type (the decoration is class-level — see +[Class-level decoration](#class-level-decoration-phase-2)) — they differ only in what the +body does: an instance-member binding recovers the native receiver from JS `this` +(`(~Cache.self).cast(jsValue: this, …)`), a static-member binding ignores `this` and calls +the Swift `static` member (`Cache.open(…)`). So a JS instance member and a JS static member +that share a name (`cache.get` vs `Cache.get`) are two distinct `static func \`#get\`` +*overloads-by-context* — but to keep their Swift names unambiguous, the static-member +binding can carry a qualifier (e.g. `` `#static.get` ``), the same scheme used for property +get/set (`` `#ready.get` ``). The single `#` sigil stays the only marker. + +> **Static *properties* need core support.** Core has `StaticFunction` but **no +> `StaticProperty`** today (class properties decorate an instance, not the prototype/ +> constructor — `ClassDefinition.swift:91`). So `@JS static var` is gated on a core +> `StaticProperty` addition; `@JS static func` works against today's core. + +#### Class-level decoration (phase 2) + +A shared object's phase-2 decoration is **class-level — once per class, no per-instance +pass** — because every member resolves its native receiver from JS `this`, not a captured +instance. This mirrors core's `ClassDefinition.decorate` (`ClassDefinition.swift:87–98`): + +- **static functions** → decorate the **constructor**. +- **instance functions** → decorate the **prototype**; the native receiver is resolved + per-call from `this` via the shared-object registry (core's `takesOwner` path, + `SyncFunctionDefinition`). +- **properties** → also decorate the **prototype**. The getter/setter take the owner + (`takesOwner`, the `(_ this: OwnerType)` accessor form) and resolve the native instance + from `this`, so a single prototype accessor serves all instances — no per-instance + binding needed. (Core does exactly this: `decorateWithProperties(object: prototype)`, + `ClassDefinition.swift:97`. A stale comment two lines above says properties decorate an + instance — the code below it puts them on the prototype.) + +So `@SharedObject` emits a **`static func _decorateJavaScriptClass(_ klass:…)`** that sets +up the prototype + constructor once. The only genuinely per-instance work is core's +internal native↔JS pairing (`sharedObjectId`, `SharedObjectRegistry.swift:114`), which the +macro doesn't generate. + +**How an instance member reaches its receiver without `self`.** This is the key +difference from modules. A shared-object binding is `static` — it has no `self` — so it +recovers the native instance from JS **`this`**: every instance's JS object is paired with +its native object in the `SharedObjectRegistry` at construction (`ClassDefinition.swift:68`), +and `(~Cache.self).cast(jsValue: this, …)` resolves it back (core's `takesOwner` path → +`DynamicSharedObjectType` → `sharedObjectRegistry.get(...).native`, +`DynamicSharedObjectType.swift:41`). The receiver is a *resolved parameter*, not the +binding's `self`: + +```swift +// shared-object instance function `get` — static, receiver resolved from `this` +static func `#get`(_ this: JavaScriptValue, _ arguments: borrowing JavaScriptValuesBuffer, + appContext: AppContext, in runtime: JavaScriptRuntime) throws -> JavaScriptValue { + let owner = try (~Cache.self).cast(jsValue: this, appContext: appContext) as! Cache + let key = try (~String.self).cast(jsValue: arguments[0], appContext: appContext) as! String + return try (~(String?).self).castToJS(owner.get(key), appContext: appContext, in: runtime) +} +``` + +**Modules differ exactly here:** a module *is* a singleton instance, so its binding is an +**instance method** using the real `self` (`self.add(…)`, the +[sketch above](#example-a-sync-function)) and ignores `this`. So: module binding = instance +method on real `self`; shared-object binding = `static`, receiver recovered from `this`. +The decode/convert of the *other* arguments is identical in both. + +**Events** use the same `@Event` function-typed property as modules +([Module events](#module-events)); the synthesized closure dispatches into +`emit(event:payload:)` (`SharedObject.swift:102`). `@SharedObject` +collects `@Event` names into the class definition's `Events(…)`. (Modules and shared +objects share one events model — same `@Event`, same typed `emit`.) + +For full parity, `@SharedObject` should also gain **property setters** (as modules do). + +### `@Record` + +`@Record` on a `Record` type synthesizes `_recordFields(of:)` — compile-time +field/key pairs that replace the runtime `Mirror` walk in `fieldsOf(_:)`. + +**All stored properties are fields by default** — `@Field` is no longer required to +make a property a field. The macro can see every stored property at expansion time, +so the common case needs no annotation: + +```swift +@Record +struct Options: Record { + var name: String = "" + var count: Int = 0 + var flag: Bool = false +} +``` + +**Requiredness is inferred from the declaration** (default value / optional type), so +`@Field(.required)` is rarely needed: + +| Property | JS requiredness | +|---|---| +| has a default value (`var x: T = …`) | **optional** — may be omitted; default applies | +| optional type (`var x: T?`) | **nullable** *and* optional — may be omitted or `null` | +| non-optional, no default (`var x: T`) | **required** — must be provided | + +`@Field` still exists to **set options** — force `.required` on a defaulted/optional +field, or remap the key: + +```swift +@Record +struct Options: Record { + var name: String // required (no default, non-optional) + var count: Int = 0 // optional (has default) + var note: String? // nullable + optional + @Field(.required) var token: String = "" // force-required despite the default + @Field(.keyed("custom_key")) var flag: Bool = false // custom JS key +} +``` + +Because the default flipped, **exclusion needs an explicit opt-out** (omitting +`@Field` no longer excludes a property). Open: the opt-out spelling — an `@Ignore` +attribute vs. a convention. Also: every stored property is now expected to be +`AnyArgument`/convertible; a non-convertible stored property must be opted out or it's +a diagnostic. + +`@Record`'s field synthesis is reused by `@ViewProps` for its value props — see +[`@ViewProps` vs `@Record`](#viewprops-vs-record). + +### `@Union` + +A **typed union of types** — the JS value may be any one of several types (`A | B | C`). +`@Union` is applied to a Swift `enum` whose cases carry the alternatives as associated +values; the macro synthesizes the decode and the JS/TS type. + +```swift +@Union +enum Media { + case url(URL) + case data(Data) + case config(MediaConfig) // a @Record +} + +@JS func load(_ media: Media) { … } +// JS type: Media = string | ArrayBuffer | MediaConfig +// author: switch media { case .url(let u): … } ← typed, exhaustive +``` + +Why an enum (vs. the existing `Either`): +- **Tagged + typed.** A Swift enum with associated values *is* a tagged union — no + `Any?` box, no `.get()` optionals, exhaustive `switch`. Each payload keeps its static + type. +- **N cases, named.** `Either` is fixed at two and anonymous (you nest + `Either>` for three); `@Union` is any number of named cases and maps + cleanly to a TS union (the case payloads → `A | B | C`). +- **Faster decode (phase 2).** The macro knows the cases statically, so it generates an + ordered, typed decode straight into the matching case — no `DynamicEitherType` walk, + no `Any?` erasure, no thrown-and-caught exception per failed candidate (`Either`'s + current trial loop). See [Phase 2](#phase-2--performance-direct-jsi-binding). + +**Discrimination is structural, in declaration order** (the same model `Either` uses +today): the decoder tries each case's payload converter top-to-bottom and takes the +first that succeeds. **Caveat:** when payload shapes overlap (e.g. two `@Record`s with +compatible fields, or `Int` vs `Double`), the match is order-dependent. A +*discriminated* mode — picking the case by a tag field, like a TS discriminated union — +is a **deferred follow-up** (`@Union(discriminator: "type")`), not v1. + +**`Either` stays** for the quick inline anonymous 2-type case +(`func a(_ x: Either)`); the macro optimizes its decode the same way. +`@Union` is the named, N-case option. (Two ways to express a union — `Either` for inline +2-type, `@Union` for named unions.) + +--- + +## Views + +A view is a `: ExpoView` (UIKit) class plus a typed **props object**. Props are +declared once as a `@ViewProps` record. The view exposes **`var props`** — always the +current props instance — and receives **batched, atomic** updates via an +`onViewPropsChanged(oldProps:)` lifecycle method (by the time it's called, `self.props` +is already the new value; the parameter is the previous one, for diffing — `nil` on the +first application). There is no per-property `Prop(…)` closure and **no `@Prop` +attribute**. + +> **Core dependency (UIKit).** This unified props-object model needs core runtime +> that doesn't exist yet on the UIKit path. The macro is designed against the +> contract below; core implements it separately. SwiftUI already has the model +> (`ExpoSwiftUI.ViewProps` is `open class ViewProps: ObservableObject, Record`); +> UIKit today has only per-prop closures (`ExpoFabricView.updateProps(_:)`, +> `ios/Fabric/ExpoFabricView.swift:84`). See +> [Core dependencies](#core-dependencies). + +### `@ExpoView` + +`@ExpoView(props: MyViewProps.self)` on a `: ExpoView` class. Synthesizes +`_synthesizedViewDefinition() -> ViewDefinition`, registering the props +type and the event names gathered from the props object: + +```swift +@ExpoView(props: MyViewProps.self) +class MyView: ExpoView { + // self.props is always current. The callback gets the OLD props for diffing — + // nil on the first application (initial mount, no previous value). + override func onViewPropsChanged(oldProps: MyViewProps?) { + if oldProps?.color != props.color { backgroundColor = props.color } + if oldProps?.radius != props.radius { layer.cornerRadius = props.radius } + } +} +``` + +Synthesized output: +```swift +public static func _synthesizedViewDefinition() -> ViewDefinition { + return View(MyView.self) { + Props(MyViewProps.self) // ← new core element; replaces per-Prop closures + Events("onTap") // names gathered from function-typed fields on the props object + // onViewPropsChanged is an override called by core — not emitted here + } +} +``` + +> `Props(_:)` is a **new core element** (see [Core dependencies](#core-dependencies)). +> If core instead exposes this via a `View` overload (as the +> SwiftUI path does), the synthesized wrapper changes shape — pin when core lands. + +The macro does a `: ExpoView` inheritance check (mirroring `@SharedObject`), reads +the props type from the `props: MyViewProps.self` metatype argument (reusing the +`classListArgument`-style `.self` parser), and wiring into a module is via +`@ExpoModule(views: [MyView.self])`. + +**Why the props type is a metatype arg, not a generic in the inheritance clause:** +the longer-term direction is to stop requiring the inheritance clause, so the props +type must not live there. Also `@ExpoView` is not legal Swift +(attributes can't take generic args), and a macro can only add conformances, not a +superclass. + +#### View lifecycle + +`onViewPropsChanged(oldProps:)` is a core-called **override** — not macro-synthesized. +Contract: core sets `view.props` to the new value **first**, then calls the override with +the **previous** props as `oldProps` (typed `Props?` — **`nil` on the first +application**, since there's no prior value); the user diffs `oldProps` against +`self.props` (= the new props). One typed, optional parameter, and a single +authoritative non-optional "current" — `self.props`. +(Depends on the core contract — see [Core dependencies](#core-dependencies).) Core today +exposes only +`OnViewDidUpdateProps`; there is no `OnViewDestroys`. + +### `@ViewProps` + +`@ViewProps` on a props **`struct`**. **Value stored properties are props; +function-typed properties are events** (see [Events model](#events-model)). Like +`@Record`, fields need no `@Field` annotation by default; `@Field` is only for +options. One record ⇄ one TS type, callbacks included — exact native/TS symmetry. No +`EventDispatcher`, no `@ViewEvent`. + +```swift +@ViewProps +struct MyViewProps { + var color: UIColor = .red // has default → optional in JS + var radius: CGFloat? // optional type → nullable + optional in JS + var title: String // no default, non-optional → required in JS + var onTap: (TapEvent) -> Void // event: payload TapEvent (AnyArgument), name "onTap" + var onClose: () -> Void // event: no payload +} +``` + +**Requiredness is inferred from the declaration** (so `@Field(.required)` is rarely +needed — see [`@Record`](#record)): + +| Property | JS requiredness | +|---|---| +| **has a default value** (`var x: T = …`) | **optional** — may be omitted; the default applies | +| **optional type** (`var x: T?`) | **nullable** *and* optional — may be omitted or `null` | +| **non-optional, no default** (`var x: T`) | **required** — must be provided | + +"Optional" (key may be omitted) and "nullable" (value may be `null`) are independent: a +property can be one, both, or neither. The rule maps to core's `isRequired` — a field is +required only in the last row. `@Field(.required)` can still force-require a defaulted or +optional property when an author wants that. + +**Struct, for performance.** Props are decoded on every React update; a value type +avoids per-update heap allocation + ARC churn and makes the `oldProps` vs `self.props` +comparison in `onViewPropsChanged(oldProps:)` a clean value diff. v1 is **UIKit-only**, and +the UIKit path (`ExpoFabricView.updateProps`) does **not** observe props — it compares +values and calls setters — so nothing forces a reference type here. + +#### Struct or class — the conformance carries the constraint + +SwiftUI can't observe a struct. Its props path shares **one props instance** between +the framework and the view: `HostingView` holds `private let props: Props` and on each +React update calls `props.updateRawProps(…)` → `objectWillChange.send()`; the view +observes that same instance (`@ObservedObject`, props a `class … ObservableObject`). +This "framework owns and pushes mutations" pattern is inherently reference-based — +`@State` on a struct can't replace it, and `ObservableObject` / `@Observable` (SE-0395) +are class-only. UIKit, by contrast, doesn't observe at all (`ExpoFabricView.updateProps` +compares values and calls setters), so a `struct` is fine — and faster (no per-update +heap alloc/ARC; clean value diff). + +**Decision: `@ViewProps` may be a `struct` or a `class`; the author picks per view, and +a wrong choice is a compile error — not via a macro type-check (a macro can't see +whether `MyViewProps.self` resolves to a struct or class), but via the conformance the +macro emits.** `@ViewProps` is attached directly to the type, so it *can* see the +`struct`/`class` keyword and emit the matching props protocol: + +- `@ViewProps struct` → a **value** props protocol (value-friendly; `Record` + fields). +- `@ViewProps class` → a **class-bound observable** props protocol + (`: AnyObject, ObservableObject`). The author's class uses real + `@Observable`/`ObservableObject` (author-written — so no macro-reexpansion problem, + and the author picks the mechanism for their deployment target). + +`@ExpoView` checks nothing itself. SwiftUI's `View` / `ExpoSwiftUIView` +`associatedtype Props` bound requires the **observable** protocol, so a `struct` props +simply doesn't conform → **clean compiler error at the SwiftUI view**. UIKit requires +only the value protocol (or takes the struct directly). Both frameworks coexist in one +target — a UIKit view uses a struct props, a SwiftUI view uses a class props; they're +just different types. + +```swift +@ViewProps struct CardProps { var color: UIColor = .red } // UIKit view ✓ ; SwiftUI view ✗ (compile error) +@ViewProps class PanelProps: ObservableObject { … } // SwiftUI view ✓ ; UIKit view ✓ +``` + +Trade-off: a props type **shared** by both a UIKit *and* a SwiftUI view must be the +common denominator — a `class`. The common case (props belongs to one view) is +unaffected, and the author opts into the class only when they actually need SwiftUI. + +This replaces the earlier "macro generates a nested observable class" idea, which would +have forced the macro to hand-emit observation plumbing (macro output isn't +re-expanded, so it couldn't reuse `@Observable`/`@Published`). Letting the author write +the class and enforcing via the conformance is simpler and avoids that cost. + +**Core dependency:** the props protocol must be **split** into a value-props and a +class-bound observable-props protocol, with SwiftUI's `View` bound on the latter. +Today there's one `ExpoSwiftUI.ViewProps` (already a class). This split is core-side +work — see [Core dependencies](#core-dependencies). SwiftUI support itself is deferred +past v1 (UIKit-only). + +Synthesis — the macro adds: +- **`Record`** + `@Record`'s value-field descriptor synthesis for the prop fields. +- The **view-props conformance** core requires (the `ExpoSwiftUI.ViewProps`-equivalent + / props marker — exact protocol TBD against the core work). +- **Function-typed-property event recognition**: their names feed the view + definition's `Events(...)`. The macro emits no closure body; core dispatches the + event by name. + +> **Struct ⇒ events wiring can't mutate the props instance.** Core's current +> `setUpEvents` finds `EventDispatcher` fields via `Mirror` and *assigns* their +> `.handler` — that needs reference semantics. With a `struct` props and +> function-typed event properties, core can't mutate a stored closure on a value copy; +> instead the synthesized event property must *call into* a core-held dispatcher +> (by event name). This sharpens the §0a core contract — see +> [Core dependencies](#core-dependencies). + +#### `@ViewProps` vs `@Record` + +A props object *is* a `Record` at the **runtime** level (same +`fieldsOf`/`update(withDict:)` machinery; there's no separate props marker protocol in +core today). Even so, `@ViewProps` is a **distinct attribute**, not a `@Record` alias: + +- **Property classification differs.** A function-typed property is *invalid* in a + `@Record` (Records are pure `toDictionary` data) but *is an event* in `@ViewProps`. + One attribute can't apply both rules to the same syntax. +- **TS generation reads the attribute from Swift source.** The generator keys on + `@ViewProps` to emit a *component-props type* (value properties → props, function + properties → event callbacks, plus the React view-props base) vs `@Record` → a plain + data object. A typealias would erase exactly this source-level signal. +- **Implementation is shared.** `@ViewProps` reuses `@Record`'s value-field + synthesis (factored out of `RecordMacro.swift`); it adds event-property recognition + and the view-props conformance — value-props for a `struct`, class-bound + observable-props for a `class` (see above). +- The runtime conformance is still emitted (core decodes/observes props through it) + but is **not** the TS distinction — that's the attribute. So the core side is + free to pick whatever conformance/marker it needs without affecting TS generation. + +--- + +# Phase 2 — Performance: direct JSI binding + +Phase 1 keeps the existing execution model: the macro emits `*Definition` elements, +and at call time the runtime walks the definition, builds a `[Any]` argument array, +resolves each argument through a **dynamic type** converter, and assembles a tuple +via `toTuple` before invoking the Swift function. That dynamic path — boxing every +argument to `Any` and the `toTuple` conversion — is the **biggest runtime +bottleneck** today. + +Phase 2 changes the *strategy*, not the author-facing API. Because the macro already +knows each member's **static Swift signature** at compile time, it can generate +**pure-Swift** code that **binds members directly into the JS object** via the `expo-modules-jsi` +API — creating the JS functions/properties itself instead of describing them with +`*FunctionDefinition` for the runtime to interpret. The generated binding can then: + +1. **Validate arity statically** — the argument count is known at expansion time, so + the generated function checks it directly (no definition lookup). +2. **Use per-argument typed converters** — each argument's concrete type is known, so + the macro emits a specific converter per argument and reads each from the JS call + **individually into its Swift type**, avoiding the `[Any]` boxing and the `toTuple` + step entirely (instead of today's runtime dynamic-type resolution). +3. **Skip the `*FunctionDefinition` indirection** — the JS-visible function is created + straight against JSI, so there's no per-call definition walk. + +This must cover **all supported argument/return types** — not just primitives, but +records, shared objects, arrays/typed-arrays, enums, unions (`@Union` / `Either`), +optionals, `Promise`, etc. (the full `AnyArgument` set) — each with a statically-selected +converter. Phase 2 isn't done until the optimized path is a complete replacement, not a +fast lane for a subset. + +Unions are a notable win: today `Either` boxes its payload in `Any?` and decodes by +*trying each candidate type in a `try?`/throw loop* through `DynamicEitherType`. With the +static type known, the macro emits an **ordered typed decode** straight into the matching +`@Union` enum case (or `Either`), eliminating the `Any?` erasure and the +exception-driven trial. See [`@Union`](#union). + +### Example: a sync function + +Author writes the same thing in both phases: + +```swift +@JS func add(a: Double, b: Double) -> Double { a + b } +@JS var ready: Bool = false // getter + setter (settable stored var) +``` + +**Phase 1** — the macro emits DSL entries; core builds the JS function/property and +resolves arguments dynamically at call time: + +```swift +// in _synthesizedDefinition(): +Function("add", add) +Property("ready") { self.ready }.set { (newValue: Bool) in self.ready = newValue } +// at every call, core: collects args into [Any] → resolves each via dynamic types +// → assembles a tuple (toTuple) → invokes the native fn/getter/setter. The [Any] boxing +// + toTuple is the cost. +``` + +**Phase 2** — the macro emits code that **builds the JS host function itself** with +`runtime.createFunction` (from `expo-modules-jsi`) and assigns it to the module's / +shared object's JS object, decoding each argument by its static type and calling the +Swift function directly — no `[Any]`/`toTuple` assembled by a generic call path, no +per-call definition walk. Illustrative expansion (the exact registration entry point is +part of the [core contract](#core-dependencies)): + +```swift +// One binding per @JS member. The binding IS the host-function body — it has the +// createFunction closure signature (this, arguments) -> JavaScriptValue — rather than +// wrapping it; `_decorateJavaScriptObject` does the createFunction call. Named with a backtick +// raw identifier `#add` so it's unmistakably generated and never spelled by users (see +// note). `self` is the native module instance; `this` is the JS owner; `arguments` is a +// JavaScriptValuesBuffer. appContext/runtime are threaded in (no longer captured). +@JavaScriptActor +private func `#add`(_ this: JavaScriptValue, _ arguments: borrowing JavaScriptValuesBuffer, + appContext: AppContext, in runtime: JavaScriptRuntime) throws -> JavaScriptValue { + // 1. static arity check — count known at expansion time + guard arguments.count == 2 else { throw InvalidArgCount(expected: 2, got: arguments.count) } + + // 2. per-argument decode, one cast per arg by its static type — no [Any] collection, + // no toTuple. (For now via the existing dynamic-type converters; see note.) + let a = try (~Double.self).cast(jsValue: arguments[0], appContext: appContext) as! Double + let b = try (~Double.self).cast(jsValue: arguments[1], appContext: appContext) as! Double + + // 3. call the Swift function directly, convert the typed result back to JS + let result: Double = self.add(a: a, b: b) + return try (~Double.self).castToJS(result, appContext: appContext, in: runtime) +} + +// A settable property → a getter binding + a setter binding. A getter-only property +// omits the setter. Same host-function-body shape as above. +@JavaScriptActor +private func `#ready.get`(_ this: JavaScriptValue, _ arguments: borrowing JavaScriptValuesBuffer, + appContext: AppContext, in runtime: JavaScriptRuntime) throws -> JavaScriptValue { + return try (~Bool.self).castToJS(self.ready, appContext: appContext, in: runtime) +} +@JavaScriptActor +private func `#ready.set`(_ this: JavaScriptValue, _ arguments: borrowing JavaScriptValuesBuffer, + appContext: AppContext, in runtime: JavaScriptRuntime) throws -> JavaScriptValue { + self.ready = try (~Bool.self).cast(jsValue: arguments[0], appContext: appContext) as! Bool + return .undefined +} + +// A single generated function decorates the JS object core hands it with every binding. +// This is what the runtime calls instead of walking a DSL definition. Core supplies the +// target object — the module's JS object, or a shared object's prototype/constructor; +// never a plain object the macro creates. Mirrors core's `ObjectDefinition.decorate(object:)`. +// The createFunction call lives here once; each binding is passed in as the closure body. +@JavaScriptActor +public func _decorateJavaScriptObject(_ object: borrowing JavaScriptObject, + appContext: AppContext, in runtime: JavaScriptRuntime) throws { + // function → a plain property holding the function + let add = runtime.createFunction("add") { [self] this, args in + try `#add`(this, args, appContext: appContext, in: runtime) + } + object.setProperty("add", value: add.asObject()) + + // property → a get/set descriptor, installed with defineProperty + let descriptor = try runtime.createObject() + descriptor.setProperty("enumerable", value: true) + descriptor.setProperty("get", value: runtime.createFunction("ready") { [self] this, args in + try `#ready.get`(this, args, appContext: appContext, in: runtime) + }) + descriptor.setProperty("set", value: runtime.createFunction("ready") { [self] this, args in // omit for getter-only + try `#ready.set`(this, args, appContext: appContext, in: runtime) + }) + object.defineProperty("ready", descriptor: descriptor) + + // … one entry per @JS function / property / constructor / event … +} +``` + +Notes: +- Uses real `expo-modules-jsi` types — `JavaScriptRuntime`, `JavaScriptValue` + (`arguments[i]`), `JavaScriptFunction`, `JavaScriptObject` — and + `runtime.createFunction(name) { this, arguments in … }`, the same primitive + `SyncFunctionDefinition.build` uses today (`SyncFunctionDefinition.swift:129`). `this` + is the JS owner; the native receiver is the macro's `self`, so the body calls + `self.add(…)` directly. +- Each `` `#…` `` binding **is the host-function body**, not a factory — it has the + `createFunction` closure shape `(this, arguments) throws -> JavaScriptValue`. The single + `runtime.createFunction(...)` call (and `.asObject()`) lives in `_decorateJavaScriptObject`, + which passes the binding as the closure. This keeps the per-member generated code to + just the decode-call-convert work, and avoids each binding capturing `self`/`appContext` + (they're plain method parameters / the implicit receiver). +- **Properties** emit a getter binding (`` `#ready.get` ``) and, if settable, a setter + binding (`` `#ready.set` ``); the builder wraps each in `createFunction` and assembles a + JS **descriptor** (`enumerable` + `get`/`set` functions), installed with + `object.defineProperty(name, descriptor:)` — the same descriptor shape + `PropertyDefinition.buildDescriptor` uses today (`PropertyDefinition.swift:183`). + Getter-only properties (computed `{ get }` / `let`) omit the setter binding. The + getter/setter decode + result conversion uses the same per-type `(~T.self)` casts as + functions. +- **First cut reuses the existing converters.** `(~A.self).cast(jsValue:appContext:)` is + the current per-type dynamic cast — it returns `Any`, so there's still a boxing + force + cast here. The win at this stage is structural: each argument is cast *individually by + its known type*, with no `[Any]` argument array and no `toTuple`. **Eliminating the + `Any` round-trip** (a converter that returns the concrete `A` directly) is the deeper + optimization, layered on later. +- The converter is chosen at expansion by the static type, so `@Record`, `@Union`, + shared object, optional, etc. each get their own `(~T.self)` cast (the full + `AnyArgument` set). +- **Async** (`@JS func … async`) wraps the same body in the JS `Promise` machinery and + runs on `@JavaScriptActor` (synchronous until the first suspension) — see + [`@JS` › Async functions](#async-functions). +- This sketch decorates a module's single JS object. A **shared object** uses a + `static func _decorateJavaScriptClass` instead — same per-member bindings, but applied + to the **constructor** (statics) and **prototype** (instance funcs + properties), once + per class; receivers resolve from JS `this`. See + [Class-level decoration](#class-level-decoration-phase-2). +- **The bindings are never called by name** — only the generated `_decorateJavaScriptObject` + references them (it knows the full set, since the macro emits both in one expansion). + They're named with **backtick raw identifiers `` `#add` ``** (SE-0451), which makes them + visually unmistakable as generated and unspellable in ordinary code, and `private` so they + don't leak. `_decorateJavaScriptObject` keeps the existing leading-underscore convention + since the **runtime calls it**, and takes the target object core supplies (it doesn't + create one) — mirroring core's `ObjectDefinition.decorate(object:)`, including its + **`borrowing`** parameter: it mutates the object through its reference + (`setProperty`/`defineProperty`) without reassigning or taking ownership, so it borrows + rather than `inout` (no rebinding) or `consuming` (caller keeps using it). + Same convention core uses (`ObjectDefinition.swift:97`). +- **Prerequisite: Swift 6.2.** Raw identifiers (`` `#…` ``) require Swift 6.2 **where the + generated code compiles — i.e. the consumer module**, not just the macro plugin (the + plugin is already on swift-tools 6.2). Core / consumer modules are currently + `swift_version 6.0`, so this binding style is gated on raising that floor. Until then the + fallback is a plain leading-underscore name (`_expoBind_add`), which has no version + requirement. Tracked in [Core dependencies](#core-dependencies). + +### Proof of concept: `@OptimizedFunction` + +`@OptimizedFunction` (`ExpoModulesOptimizedMacro.swift`, +`OptimizedFunctionHelpers.swift`) is an early **proof of concept** of this idea — it +demonstrates that a typed function can be bound with per-argument types instead of the +dynamic path. It is **not the target shape**: + +- It currently bridges through **ObjC** — a `@convention(block)` wrapper plus a + hand-built ObjC type-encoding string (e.g. `"d@?dd"`) and an + `OptimizedFunctionDescriptor`. Phase 2 should be **pure Swift** (no ObjC encoding / + `@convention(block)` round-trip), reading arguments directly through the JSI Swift + API. +- Its converter table covers only primitives (`Double`/`Int`/`String`/`Bool`/`Void`); + phase 2 covers the full type set above. +- It's opt-in per function. Phase 2 makes the optimized strategy the **default for + every synthesized function** (sync + async, properties, constructors). + +**End state: `@OptimizedFunction` is removed.** Once every synthesized function is +optimized by definition, a separate opt-in attribute is redundant — the +proof-of-concept macro and its ObjC bridge get deleted. + +### Scope & dependencies + +- **Author API is unchanged** from phase 1 — same `@JS`/`@ExpoModule` declarations; + only what the macro emits (and the core entry points it targets) changes. The DSL + itself can also remain as a hand-written fallback. +- **First cut needs little new core.** `runtime.createFunction` and the per-type + `(~T.self).cast(jsValue:appContext:)` / `castToJS` converters already exist in + `expo-modules-jsi` + core; the macro just emits the binding that uses them directly + (skipping `[Any]`/`toTuple`). What's needed is a clear **registration entry point** to + attach the generated functions/properties to the module's / shared object's JS object. +- **Deeper optimization is a later layer:** an `Any`-free converter (returns the concrete + `A` instead of `Any`, removing the boxing + force-cast in the sketch above). This is + the real perf dependency, pinned with core when we get there. +- Open: how non-`AnyArgument` / unconvertible types are diagnosed at expansion time + (compile error vs. fallback to the dynamic path). + +--- + +# Phase 3 — TypeScript type generation + +Generate the module's `.d.ts` (function signatures, record shapes, **view props + +event callbacks**) directly from the annotated Swift source, so JS types are always +in sync with native and never hand-maintained. The macro attributes from phases 1–2 +(`@ExpoModule`, `@JS`, `@SharedObject`, `@Record`, `@ViewProps`, `@ExpoView`) form a +declarative, machine-readable description of the module's native + JS surface — the +basis for generation. The `@ViewProps`-vs-`@Record` distinction +([above](#viewprops-vs-record)) exists partly to make this unambiguous: a `@ViewProps` +type maps to a React component-props type with event callbacks; a `@Record` maps to a +plain data object. + +**This cannot live in the macro.** Swift compiler macros run in an OS-level sandbox +with **no filesystem or network access** (WWDC23, *Expand on Swift Macros*: "Compiler +plug-ins run in a sandbox that stops macro implementations from reading files on disk +or accessing the network") and must be deterministic for incremental builds. So a +macro can never write a `.ts` file — it can only transform Swift in-place. Type +generation is a **separate source-parsing tool** — and that tool already exists +(`expo-type-information`, below). + +The generator reads the **declarations from source** (the decided signal), not a +runtime conformance — it doesn't need the code to compile or link. Open design points: +how view-props events map to TS callback signatures (payload `Record` → TS object), how +`AnyArgument` payload types resolve to TS, and optional/required field mapping. + +### Reuse `expo-type-information` + +**The pipeline already exists** — the `expo-type-information` package +(`packages/expo-type-information`) parses Swift Expo modules and emits TypeScript today. +Phase 3 is **adapting it to the new macro surface**, not building a generator from +scratch. + +What it already does: +- **Parses Swift modules for type info via `sourcekitten`** (SourceKit-backed, so it has + *resolved* types, not just syntax; macOS-only, needs the `sourcekitten` tool). +- **Emits TypeScript** — types, wrapper functions, and mocks + (`typescriptGeneration.ts`, `mockgen.ts`). +- Ships CLI commands that already line up with our surface: + `inlineModulesInterfaceCommand`, `moduleInterfaceCommand`, `generateModuleTypesCommand`, + `generateViewTypesCommand`, and `generateJSXIntrinsicsCommand` (view JSX intrinsics ≈ + our `@ViewProps`). + +So phase 3 work is mostly **teaching it the new attributes** — `@JS` (incl. `async` → +`Promise`), `@Record`/field-by-default + requiredness inference, `@ViewProps` (value +props vs. function-typed events), `@ExpoView`, `@Union`, `@Event` — and mapping each to +the right TS shape (e.g. `@ViewProps` → component-props type with event callbacks; +`@Union` → `A | B | C`; optional/default → optional TS field). + +Note: it uses **SourceKitten**, not SwiftSyntax (which the phase-1/2 macros and the +sandbox discussion assume). That's fine for an external tool — SourceKitten gives +resolved types, which can be *more* than enough — but it's a different front end than the +macros use. Open: whether to keep SourceKitten or move the parser to SwiftSyntax for +consistency with the macro toolchain. + +--- + +# Cross-cutting concerns + +## Events model + +**Requirement: an event's payload must be typed.** The runtime is untyped end to +end — `Module.sendEvent(_:_: [String: Any?])`; `EventDispatcher`'s handler is +`([String: Any]) -> Void`; the Fabric path ends at `dispatchEvent(name, payload: id)` +→ JSI. So "typed" means **compile-time typing in Swift**, funneled into the existing +untyped runtime; no generic runtime type is required. + +**Payload type = `AnyArgument`** (or no payload). "Everything we can convert to JS" +is exactly the `AnyArgument` protocol: primitives, `Record`, `Convertible`, shared +objects (`AnySharedObject: AnyArgument`), `UIView`, `JavaScriptObject`, +arrays/typed-arrays, `Either`, `Promise`. `Conversions.anyToJavaScriptValue` already +casts any `AnyArgument` to JS via `getDynamicType().castToJS()`. Precedent: +`SharedObject.emit(event:payload:)` +(`SharedObjects/SharedObject.swift:102`) is already exactly a typed-payload event +send — modules/views just don't have an equivalent yet. + +**`EventDispatcher` is dropped from the macro's output.** It's a heap class found +reflectively (`Mirror`) that only forwards to a `([String: Any]) -> Void` handler; +the real emitters (`sendEvent`, `dispatchEvent`) take a dict directly. + +- **Views:** events fold into `@ViewProps` function-typed properties (above). Core + dispatches by event name; the synthesized event property calls into a core-held + dispatcher rather than core mutating a stored closure on the props instance (the + props are a `struct` — value semantics, so the old "assign `.handler` via `Mirror`" + approach in `SwiftUIViewProps.setUpEvents` doesn't apply). Still a *reducing* change + vs. the `EventDispatcher` indirection. See [Core dependencies](#core-dependencies). +- **Modules & shared objects:** a `@Event` function-typed property; the macro (not core) + synthesizes a computed property whose closure dispatches by name into + `emit(event:payload:)` — the same typed call on both + (`SharedObject` has it; `Module` assumed to match). Names are collected by + `@ExpoModule`/`@SharedObject` into `Events(…)`. + +## Verified core DSL signatures + +The contract generated code must match. All paths under +`…/expo/main/packages/expo-modules-core/ios`. + +**Base classes** +- `Module = AnyModule & BaseModule` (`Core/Modules/Module.swift:29`). Module init is + `required init(appContext:)`; overriding `init()` is unavailable — use lifecycle. +- View base: `public typealias ExpoView = ExpoFabricView` (`Core/ExpoView.swift:3`); + `required public init(appContext: AppContext? = nil)` (`Fabric/ExpoFabricView.swift`). + +**Module-level factories** +- `Name(_ name: String) -> AnyDefinition` (`ModuleFactories.swift:4`). +- `Events(_ names: String...) -> EventsDefinition` and `Events(_ names: [String])` + (`ObjectFactories.swift:33`). +- `OnStartObserving(_ event: String? = nil, _ closure:)` / `OnStopObserving(...)` + (`ObjectFactories.swift:47`). +- `OnCreate(...)`, `OnDestroy(...)`, `OnAppContextDestroys(...)` + (`EventListenersFactories.swift:4`). +- **No `Exceptions(...)` factory exists** → out of scope. +- `Function`/`AsyncFunction`: name + function-reference / name + closure overloads + confirmed; existing macro output stays valid. +- `Constant(_ name, get:) -> ConstantDefinition` + (`ConstantFactories.swift:11`); `Constants(...)` dict form is deprecated. (Not + emitted — see the `@Constant` note above.) + +**Property + setters** +- `Property(_ name, get:)` and a no-owner getter overload + (`PropertyFactories.swift:11,18`). +- Chained `.set` setter (`PropertyDefinition.swift:105`) — see + [Property getter + setter](#property-getter--setter). + +**Views** +- `View(_ viewType:, @ViewDefinitionBuilder _ elements) + -> ViewDefinition` (`Factories/ViewFactories.swift:10`). Concrete return + type, not `AnyViewDefinition`. (SwiftUI overloads exist; UIKit-only for v1.) +- `Prop(_ name, _ setter: @MainActor (ViewType, PropType) -> Void)` + + `defaultValue` overload (`ViewFactories.swift:38,52`). **Superseded by the props + object** — listed for reference. +- View events (core today): names from `Events("onX")`; dispatcher is a property on + the view/props (`var onTap = EventDispatcher()`), discovered via `Mirror` in + `setUpEvents`. **Our design drops `EventDispatcher`** (see events model). +- View lifecycle: only `OnViewDidUpdateProps(...)` + (`ViewFactories.swift:70`). No `OnViewDestroys`. + +**Existing macro declarations (the pattern to copy)** — `ios/Core/ExpoModulesMacros.swift`: +- `@attached(peer) public macro JS(_ jsName: String? = nil) = …` +- `@attached(member, names: named(_synthesizedDefinition), named(appContext), named(init)) public macro ExpoModule(_ name: String? = nil, classes: [Any.Type] = []) = …` +- `@attached(member, names: named(_synthesizedClassDefinition)) public macro SharedObject(_ name: String? = nil) = …` +- All point at `module: "ExpoModulesMacros"`. + +## Core dependencies + +**1. The `_synthesized…` rename must land in core.** Core on `expo/main` still uses +the old names: `_exposedDefinition` / `_exposedClassDefinition` in +`ExpoModulesMacros.swift` `names:` lists and the `AnyModule._exposedDefinition()` +requirement. Until core's declarations and protocol are renamed to match, a module +built with the new plugin won't satisfy the declared `names` and won't conform. + +**2. Unified `@ViewProps` runtime (UIKit).** The props object is a **`struct`** for +v1 (value semantics, for performance — see [`@ViewProps`](#viewprops)), so the contract +must be value-friendly. The macro depends on core adding: +1. A way to register a typed props `Record` **struct** for a UIKit `ExpoView` (a + `Props(MyProps.self)` definition element, or a `View` overload). +2. A typed **`var props: Props`** on the base view, always holding the current props. +3. In `updateProps(_:)`: decode the incoming dict into a `MyProps` value, capture + `oldProps = view.props`, **set `view.props = newProps` first**, then call + `view.onViewPropsChanged(oldProps:)`. So inside the callback `self.props` is already + the new value and `oldProps` is the previous one. +4. An overridable `open func onViewPropsChanged(oldProps: Props?)` on the base view + (generic or associated props type). `oldProps` is **`nil` on the first application** + (initial mount — no previous value); `view.props` is non-optional and always current. +4. **Event dispatch by name**, not by mutating a stored closure: since props are a + value type, core can't assign a handler onto a function-typed field the way + `setUpEvents` does for `EventDispatcher` today. The synthesized event property + instead calls a core-held dispatcher keyed by event name; core exposes that entry + point. + +**3. Props protocol split (for SwiftUI; deferred).** To let `@ViewProps` be a `struct` +*or* `class` and have a `struct` props be rejected only on the SwiftUI path, the single +`ExpoSwiftUI.ViewProps` (today a class) must split into a **value** props protocol and a +**class-bound observable** props protocol (`: AnyObject, ObservableObject`), with +SwiftUI's `View` bound on the latter. A `struct` props then fails to conform at a +SwiftUI view → clean compiler error; no macro type-check needed. UIKit binds on the +value protocol. (See [`@ViewProps`](#struct-or-class--the-conformance-carries-the-constraint).) + +**4. Typed `Module.emit`.** Module events synthesize a call to +`emit(event:payload:)`, which `SharedObject` already has +(`SharedObject.swift:102`) but `Module`/`BaseModule` does not (it has only the +dict-based `sendEvent(_:_: [String: Any?])`). Add the same typed `emit` to `BaseModule` +so module and shared-object events share one mechanism and accept any `AnyArgument` +payload. (See [Module events](#module-events).) + +**5. Swift 6.2 floor (for phase-2 binding names).** The phase-2 bindings use backtick +raw identifiers (`` `#add` ``, SE-0451), which require **Swift 6.2 in the consumer module** +where the generated code compiles. Core / consumer modules are `swift_version 6.0` today, +so this is gated on raising that floor (the macro plugin is already on 6.2). Fallback +until then: leading-underscore binding names (`_expoBind_…`), no version requirement. + +**6. `StaticProperty` for shared objects.** `@JS static func` maps to the existing +`StaticFunction` (decorates the constructor), but there's **no `StaticProperty`** in core +(class properties decorate an instance, `ClassDefinition.swift:91`). A `@JS static var` +needs a core `StaticProperty` that decorates the constructor object. (See +[Static vs. instance members](#static-vs-instance-members).) + +Until these land, generated `@ViewProps`/`@ExpoView`/`@Event` code won't run; the macro +tests verify expansion shape only, not runtime. + +## Implementation plan + +Phase-1 staging (DSL coverage). Phase 2 (direct JSI binding) and phase 3 (TS +generation) are sequenced after, each gated on its own core/tooling contract — see +those sections. Each step = PR + tests + `node build.js` (rebuild the plugin binary +before committing); each new impl struct → add to `providingMacros` in `Plugin.swift`. + +1. **Helper refactor.** Extract the duplicated param-rewrite/closure builders from + `SharedObjectMacro.swift` into `MacroHelpers.swift`; factor `@Record`'s + field-descriptor synthesis out of `RecordMacro.swift` for `@ViewProps` reuse. No + behavior change. +2. **Module property setters.** Establishes the property-modifier pattern. (Module + lifecycle is core-only override work; confirm the rename lands in core.) +3. **`@ViewProps` macro.** New `ViewPropsMacro.swift` reusing the record field + synthesis, plus view-props conformance and function-typed-field event + recognition. **Gated on the core props/events contract.** +4. **`@ExpoView` macro.** New `ExpoViewMacro.swift`, `_synthesizedViewDefinition()`, + `: ExpoView` check, props metatype arg, `Props(...)`/`View` wrapper, + event-name gathering, `@ExpoModule(views:)` wiring. **Gated on the core contract.** +5. **`@SharedObject` parity** — class-level `Events` + property setters. +6. **Module + view events** — typed function-typed members/fields and their send + synthesis, once the events contract is pinned. + +## Testing + +- `assertExpansion(input, expandedSource:)` pairs in the existing style; new suites + `ViewPropsMacroTests.swift`, `ExpoViewMacroTests.swift`. Whitespace must match + (output is string-spliced). +- Error paths: `@ExpoView` on non-class / missing `: ExpoView`; `@ViewProps` on an + unsupported decl kind; etc. +- `swift test --package-path apple` green after each stage. +- **Caveat:** these tests verify *expansion shape*, not real compilation against + core. Integration is only proven by building a real module against the paired core + PR — state this in every PR. + +## Open questions + +1. **Core view contract** — `Props(_:)` element vs `View` overload; the + props-object conformance/marker; the `onViewPropsChanged` base-class shape + (generic vs associated type); the function-typed-field event wiring in + `setUpEvents`. The macro output can't be pinned until this is decided; owned by + the core side. +2. **`GroupView` / `ViewName`** — in scope or follow-up? +3. **Record field opt-out** — with all stored properties fields by default, how to + exclude one (an `@Ignore` attribute vs. a convention), and how a non-convertible + stored property is handled (diagnostic vs. silent skip). +4. **Discriminated unions** — `@Union` matches structurally in declaration order for v1; + a tag-based mode (`@Union(discriminator: "type")`) for overlapping payload shapes is a + deferred follow-up. Also: confirm `Union` is the right attribute name (vs. `JSUnion`). +5. **`@Event` name override** — JS event name comes from the property name; a per-event + override (`@Event("customName")`) if needed is a small follow-up. +6. **Phase 3 front end** — `expo-type-information` already parses Swift modules (via + SourceKitten) and emits TS; phase 3 adapts it to the new attributes. Open: keep + SourceKitten or move to SwiftSyntax for consistency with the macro toolchain. + +Resolved: events → typed payloads (`AnyArgument`), no `EventDispatcher`; view events +fold into `@ViewProps` function-typed fields; **module & shared-object events are a +`@Event` function-typed property → macro synthesizes a computed closure dispatching into +the typed `emit` (same on both; needs `Module.emit` added to core)**; +module + view lifecycle → overridable instance methods (core-called); view props → +single `@ViewProps` object (no `@Prop`), exposed as non-optional `self.props` (always +current); `onViewPropsChanged(oldProps:)` gets only the previous props, typed `Props?` +(`nil` on first application); `@ViewProps` distinct from `@Record`; no +`@Constant` macro; `@ExpoView` props via metatype arg; `@Record`/`@ViewProps` fields +are all stored properties by default — `@Field` only for options; field requiredness is +**inferred** (default value → optional; optional type → nullable+optional; non-optional +no-default → required); **`@ViewProps` may be +a `struct` (UIKit) or `class` (SwiftUI) — `@ViewProps` emits a value vs. class-bound +observable props conformance, and a `struct` used by a SwiftUI view is a compile error +via that conformance (no macro type-check). Needs a core props-protocol split; SwiftUI +deferred past v1**. Async: **drop Promise-based async; `async` Swift keyword → +JS `Promise`; `async` functions stamped `@JavaScriptActor`; body runs synchronously on +the JS thread until the first suspension (JS-like)**. Unions: **`@Union` on an enum = +typed N-case union (tagged, no `Any?`, → TS `A | B | C`); `Either` kept for inline +2-type; structural match in declaration order, discriminated mode deferred**. Phase 2: +**per-member bindings named with backtick raw identifiers (`` `#add` ``, never called by +users; needs Swift 6.2 in the consumer), installed by one generated +`_decorateJavaScriptObject(_ object:…)` that decorates the JS object core supplies (module +object / SO prototype / constructor) — mirrors `ObjectDefinition.decorate(object:)`; each +binding *is* the host-function body `(this, arguments) -> JavaScriptValue`, the decorate +function does the `createFunction` call (parameter is `borrowing`, matching +`ObjectDefinition.decorate`)**. Shared objects: **`static` Swift modifier marks JS-static +members (→ constructor, `StaticFunction`) vs instance (→ prototype). All shared-object +bindings are `static func`s (class-level decoration); an instance-member binding recovers +its receiver from JS `this`, a static-member binding calls the Swift `static` member. A JS +instance + static member sharing a name disambiguate by a `` `#static.name` `` qualifier +(same scheme as property `` `#ready.get` ``). `static var` needs a core `StaticProperty`. +Decoration is **class-level, once per class** — `static func _decorateJavaScriptClass` +sets up constructor (statics) + prototype (instance funcs *and* properties, receivers from +`this`); no per-instance pass. A module decorates its single object via +`_decorateJavaScriptObject` with instance-method bindings on real `self`**. + +## Further ideas + +Beyond the three phases, but enabled by the same source-level description: + +- **JS/JSI binding generation** — beyond `.d.ts` (phase 3), the same source + description could drive generated JS glue, reducing hand-written + `requireNativeModule` boilerplate. +- **Android / Kotlin parity** — an equivalent annotation-processing path (KSP) so a + single conceptual module surface generates both platforms' definitions and shared + TS types. +- **Diagnostics from the description** — lint native modules against the generated + surface (e.g. flag a `@JS` member whose payload type isn't JS-convertible) at build + time rather than runtime. +- **Dropping the inheritance clause entirely** — the `@ExpoModule`/`@ExpoView` + metatype-arg design (props not tied to the inheritance line) is a step toward + protocol-izing the base classes so `: Module` / `: ExpoView` become optional, with + the macro supplying all conformances. + +## Risks + +- **Out-of-repo coupling (high).** Generated code only compiles against matching + core symbols/declarations; the `_synthesized…` rename is a live example where this + repo and core have already diverged. Every stage needs a paired core PR, and green + tests here do **not** prove integration. +- **The view contract is a hard dependency, not just coupling (high).** The unified + `@ViewProps` model can't run on UIKit until core ships the props-object decode + + `onViewPropsChanged` runtime + function-field event wiring. View stages should not + merge ahead of that core work. +- **Whitespace-sensitive tests** — the helper refactor limits duplication that would + otherwise multiply formatting-drift breakage. +- **Async execution semantics depend on the executor contract.** The "synchronous up + to the first suspension" behavior (see [`@JS` › Async functions](#async-functions)) + holds only if `@JavaScriptActor`'s custom `SerialExecutor` correctly reports being on + the JS thread (SE-0471/SE-0424). If that's wrong, same-thread async calls would hop + and lose the JS-like prefix. Also: stamping `@JavaScriptActor` on `async` functions + reverses current macro behavior — verify it doesn't over-isolate functions that + intentionally hop off the JS thread. From b02c45b5845515f024998f5cc217e1e025fd2475 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Thu, 11 Jun 2026 19:57:31 +0200 Subject: [PATCH 02/15] Update Expo Modules v2 doc: direct-binding details + AI-agent rationale Syncs the design doc with the current state: - @JS var binds directly via defineProperty inside _decorateModule (the phase-2 direct-JSI form), not the DSL Property(...) entry; notes the settability and access-modifier semantics (reflects PR #14). - Adds a "Why macros over the DSL (incl. AI-agent ergonomics)" section: macros are easier for AI agents to author (ordinary Swift, signature-as-contract, fewer Expo-specific concepts) and cheaper over a session (fewer error/retry loops; Phase 3 gives typed JS for free). --- docs/expo-modules-v2.md | 416 +++++++++++++++++++++------------------- 1 file changed, 222 insertions(+), 194 deletions(-) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index 307291c..d689302 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -2,7 +2,7 @@ - **Status:** Draft for review (signatures verified against `expo/main` core) - **Author:** Tomasz Sapeta -- **Created:** 2026-05-31 · **Updated:** 2026-06-02 +- **Created:** 2026-05-31 · **Updated:** 2026-06-07 - **Scope:** Apple/Swift macros in this repo (`apple/Sources/ExpoModulesMacros`) ## Goal @@ -12,6 +12,37 @@ type-safe end to end — an author writes ordinary Swift (`@JS` members, a typed object, typed events) and the toolchain takes care of the rest. The work is staged in three phases: +## Why macros over the DSL (incl. AI-agent ergonomics) + +Beyond performance and type-safety, the macro surface is a markedly better authoring +API **for AI coding agents** — and, increasingly, that's a first-class design criterion. + +- **It's ordinary Swift.** `@ExpoModule class { @JS func add(a: Double, b: Double) -> + Double }` is "an annotated class," a shape agents model well from training data + (`@Published`, `@objc`, decorators, Java annotations). The DSL — `Function("add") { + (a: Double, b: Double) in … }`, `.get/.set` chaining, `Events("…")` — is a bespoke + result-builder grammar an agent must recall *Expo-specifically*, with more room to + invent a signature that doesn't exist. +- **Signature is the contract; the type checker enforces it.** With `@JS func add(…) -> + Double` there's one obvious place for types and Swift verifies it. The DSL splits the + contract (closure param types must be spelled out *and* `AnyArgument`-convertible) and + surfaces mistakes as hard-to-diagnose result-builder/closure-inference errors. +- **Fewer Expo-specific concepts to get right.** Macros fold `Function`/`AsyncFunction` + into the `async` keyword, `Property().get.set` into a normal `var`, the `Events("…")` + string list + `sendEvent("…")` into one typed `@Event` property — each collapsed + concept is one less thing to misremember or desync. +- **Lower token cost over a session.** Per module the macro form is a bit more compact, + but the dominant lever is **fewer error→re-read→fix round-trips**: precise, + type-checked failures cost far less than debugging result-builder errors. Phase 3 + compounds this — generated `.d.ts` means the *JS-side* code an agent writes is typed + too. +- **Caveat (transient):** today the DSL is the better-known surface simply because it's + documented and in training data; the v2 macros aren't shipped yet. That edge flips + once the macros ship and are documented. + +The same properties that help agents (ordinary Swift, one obvious place for types, +fewer special concepts) help humans too — this isn't an agent-only optimization. + ## Phases 1. **[DSL coverage](#phase-1--dsl-coverage)** — macros that cover the existing API @@ -97,8 +128,18 @@ Arguments: #### Property getter + setter -Extend the property-entry builder to emit `.set` for a settable stored/computed -var; getter-only (`{ get }` or `let`) stays as today. +> **Implemented (PR #14): `@JS var` binds directly via `defineProperty`, not the DSL.** The +> originally-planned approach below — emit a DSL `Property("ready") { … }.set { … }` entry — was +> skipped. `@ExpoModule` instead binds each `@JS var` straight into the module's JS object inside +> `_decorateModule` (the phase-2 direct-JSI form), so properties never go through `Property(...)`. A +> get/set accessor is installed with `object.defineProperty(name, descriptor:)`; see +> [the phase-2 sketch](#example-a-sync-function). Settability is syntactic — a stored `var`, or a +> computed `var` with an explicit `set` (or `willSet`/`didSet`), gets a setter; getter-only computed +> vars and `let` stay read-only. Swift access modifiers (`private`, `private(set)`) don't gate JS +> exposure: the synthesized accessor lives inside the type, so it reads/writes those members fine. + +The original DSL plan, for reference (not built): extend the property-entry builder to emit `.set` +for a settable stored/computed var; getter-only (`{ get }` or `let`) stays as today. ```swift Property("ready") { self.ready }.set { (newValue: Bool) in self.ready = newValue } @@ -194,7 +235,7 @@ JS; expands to nothing on its own. `@ExpoModule` and `@SharedObject` discover ```swift @JS func greet(name: String) -> String { … } // Function("greet", greet) @JS("doWork") func performWork() async throws {} // AsyncFunction("doWork", performWork) -@JS var status: String { "ok" } // Property("status") { self.status } +@JS var status: String { "ok" } // defineProperty get accessor (direct binding) ``` The macro stamps `@JavaScriptActor` on `@JS` members (skipping `nonisolated` or members @@ -271,16 +312,17 @@ DSL mapping: an instance function emits `Function("get", …)`; a static one emi already provides). In phase 2 (direct JSI binding), statics decorate the constructor and instance members the prototype — the same split, just done by the generated builder. -**Binding names don't need a new sigil.** For a shared object, **all** `` `#…` `` -bindings are `static func`s on the type (the decoration is class-level — see -[Class-level decoration](#class-level-decoration-phase-2)) — they differ only in what the -body does: an instance-member binding recovers the native receiver from JS `this` -(`(~Cache.self).cast(jsValue: this, …)`), a static-member binding ignores `this` and calls -the Swift `static` member (`Cache.open(…)`). So a JS instance member and a JS static member -that share a name (`cache.get` vs `Cache.get`) are two distinct `static func \`#get\`` -*overloads-by-context* — but to keep their Swift names unambiguous, the static-member -binding can carry a qualifier (e.g. `` `#static.get` ``), the same scheme used for property -get/set (`` `#ready.get` ``). The single `#` sigil stays the only marker. +**No named bindings — each member's body is inlined into its `setProperty` closure inside the +decorate entry point** (the module's `_decorateModule`, see +[the module sketch](#example-a-sync-function); a shared object uses a `static func +_decorateSharedObject` class-level form). Instance vs. static is just *which JS object the +closure decorates* and *how it reaches the receiver*: an **instance** member's closure decorates +the **prototype** and recovers the native receiver from JS `this` +(`Cache.getDynamicType().cast(jsValue: this, …)`); a **static** member's closure decorates the +**constructor** and calls the Swift `static` member (`Cache.open(…)`) ignoring `this`. So a JS +instance member and a JS static member that share a name (`cache.get` vs `Cache.get`) never +clash — they're closures on different JS objects. The only generated *name* is the entry point +itself. > **Static *properties* need core support.** Core has `StaticFunction` but **no > `StaticProperty`** today (class properties decorate an instance, not the prototype/ @@ -304,35 +346,36 @@ instance. This mirrors core's `ClassDefinition.decorate` (`ClassDefinition.swift `ClassDefinition.swift:97`. A stale comment two lines above says properties decorate an instance — the code below it puts them on the prototype.) -So `@SharedObject` emits a **`static func _decorateJavaScriptClass(_ klass:…)`** that sets -up the prototype + constructor once. The only genuinely per-instance work is core's +So `@SharedObject` emits a **`static func _decorateSharedObject(…)`** (the class-level form, taking +the constructor/prototype core supplies) that sets up the prototype + constructor once. The only genuinely per-instance work is core's internal native↔JS pairing (`sharedObjectId`, `SharedObjectRegistry.swift:114`), which the macro doesn't generate. **How an instance member reaches its receiver without `self`.** This is the key -difference from modules. A shared-object binding is `static` — it has no `self` — so it -recovers the native instance from JS **`this`**: every instance's JS object is paired with -its native object in the `SharedObjectRegistry` at construction (`ClassDefinition.swift:68`), -and `(~Cache.self).cast(jsValue: this, …)` resolves it back (core's `takesOwner` path → -`DynamicSharedObjectType` → `sharedObjectRegistry.get(...).native`, -`DynamicSharedObjectType.swift:41`). The receiver is a *resolved parameter*, not the -binding's `self`: +difference from modules. A shared object's `_decorateSharedObject` is a `static func` — it has no instance +`self` — so each instance-member closure recovers the native instance from JS **`this`**: every +instance's JS object is paired with its native object in the `SharedObjectRegistry` at +construction (`ClassDefinition.swift:68`), and `Cache.getDynamicType().cast(jsValue: this, …)` +resolves it back (core's `takesOwner` path → `DynamicSharedObjectType` → +`sharedObjectRegistry.get(...).native`, `DynamicSharedObjectType.swift:41`). The receiver is a +*resolved local*, not an instance `self`: ```swift -// shared-object instance function `get` — static, receiver resolved from `this` -static func `#get`(_ this: JavaScriptValue, _ arguments: borrowing JavaScriptValuesBuffer, - appContext: AppContext, in runtime: JavaScriptRuntime) throws -> JavaScriptValue { - let owner = try (~Cache.self).cast(jsValue: this, appContext: appContext) as! Cache - let key = try (~String.self).cast(jsValue: arguments[0], appContext: appContext) as! String - return try (~(String?).self).castToJS(owner.get(key), appContext: appContext, in: runtime) +// shared-object instance function `get` — closure decorates the prototype, receiver from `this` +prototype.setProperty("get") { [weak appContext, self] this, arguments in + guard let appContext else { + throw Exceptions.AppContextLost() + } + let owner = try Cache.getDynamicType().cast(jsValue: this, appContext: appContext) as! Cache + let key = try arguments[0].asString() + return try (String?).getDynamicType().castToJS(owner.get(key), appContext: appContext, in: runtime) } ``` -**Modules differ exactly here:** a module *is* a singleton instance, so its binding is an -**instance method** using the real `self` (`self.add(…)`, the -[sketch above](#example-a-sync-function)) and ignores `this`. So: module binding = instance -method on real `self`; shared-object binding = `static`, receiver recovered from `this`. -The decode/convert of the *other* arguments is identical in both. +**Modules differ exactly here:** a module *is* a singleton instance, so its closures call the +real `self` (`self.add(…)`, the [sketch above](#example-a-sync-function)) and ignore `this`. So: +module closure = call on real `self`; shared-object instance closure = receiver recovered from +`this`. The decode/encode of the *other* arguments is identical in both. **Events** use the same `@Event` function-typed property as modules ([Module events](#module-events)); the synthesized closure dispatches into @@ -347,9 +390,9 @@ For full parity, `@SharedObject` should also gain **property setters** (as modul `@Record` on a `Record` type synthesizes `_recordFields(of:)` — compile-time field/key pairs that replace the runtime `Mirror` walk in `fieldsOf(_:)`. -**All stored properties are fields by default** — `@Field` is no longer required to -make a property a field. The macro can see every stored property at expansion time, -so the common case needs no annotation: +**Every stored property is a field. There is no `@Field` attribute.** The macro can +see every stored property at expansion time, so no annotation is needed (and `@Field` +is rejected with a diagnostic — it has no meaning under `@Record`): ```swift @Record @@ -360,8 +403,8 @@ struct Options: Record { } ``` -**Requiredness is inferred from the declaration** (default value / optional type), so -`@Field(.required)` is rarely needed: +**Requiredness is inferred from the declaration** (default value / optional type) — +there's nothing to configure: | Property | JS requiredness | |---|---| @@ -369,25 +412,18 @@ struct Options: Record { | optional type (`var x: T?`) | **nullable** *and* optional — may be omitted or `null` | | non-optional, no default (`var x: T`) | **required** — must be provided | -`@Field` still exists to **set options** — force `.required` on a defaulted/optional -field, or remap the key: - ```swift @Record struct Options: Record { - var name: String // required (no default, non-optional) - var count: Int = 0 // optional (has default) - var note: String? // nullable + optional - @Field(.required) var token: String = "" // force-required despite the default - @Field(.keyed("custom_key")) var flag: Bool = false // custom JS key + var name: String // required (no default, non-optional) + var count: Int = 0 // optional (has default) + var note: String? // nullable + optional } ``` -Because the default flipped, **exclusion needs an explicit opt-out** (omitting -`@Field` no longer excludes a property). Open: the opt-out spelling — an `@Ignore` -attribute vs. a convention. Also: every stored property is now expected to be -`AnyArgument`/convertible; a non-convertible stored property must be opted out or it's -a diagnostic. +Open: every stored property is expected to be `AnyArgument`/convertible; a +non-convertible stored property is a diagnostic. (No opt-out: every stored property is +a field. If a future need to exclude one arises, revisit — but `@Field` is not it.) `@Record`'s field synthesis is reused by `@ViewProps` for its value props — see [`@ViewProps` vs `@Record`](#viewprops-vs-record). @@ -515,9 +551,9 @@ exposes only `@ViewProps` on a props **`struct`**. **Value stored properties are props; function-typed properties are events** (see [Events model](#events-model)). Like -`@Record`, fields need no `@Field` annotation by default; `@Field` is only for -options. One record ⇄ one TS type, callbacks included — exact native/TS symmetry. No -`EventDispatcher`, no `@ViewEvent`. +`@Record`, every stored property is a field — no `@Field` annotation. One record ⇄ one +TS type, callbacks included — exact native/TS symmetry. No `EventDispatcher`, no +`@ViewEvent`. ```swift @ViewProps @@ -530,8 +566,7 @@ struct MyViewProps { } ``` -**Requiredness is inferred from the declaration** (so `@Field(.required)` is rarely -needed — see [`@Record`](#record)): +**Requiredness is inferred from the declaration** (see [`@Record`](#record)): | Property | JS requiredness | |---|---| @@ -541,8 +576,7 @@ needed — see [`@Record`](#record)): "Optional" (key may be omitted) and "nullable" (value may be `null`) are independent: a property can be one, both, or neither. The rule maps to core's `isRequired` — a field is -required only in the last row. `@Field(.required)` can still force-require a defaulted or -optional property when an author wants that. +required only in the last row. **Struct, for performance.** Props are decoded on every React update; a value type avoids per-update heap allocation + ARC churn and makes the `oldProps` vs `self.props` @@ -697,75 +731,66 @@ Property("ready") { self.ready }.set { (newValue: Bool) in self.ready = newValue // + toTuple is the cost. ``` -**Phase 2** — the macro emits code that **builds the JS host function itself** with -`runtime.createFunction` (from `expo-modules-jsi`) and assigns it to the module's / -shared object's JS object, decoding each argument by its static type and calling the -Swift function directly — no `[Any]`/`toTuple` assembled by a generic call path, no -per-call definition walk. Illustrative expansion (the exact registration entry point is -part of the [core contract](#core-dependencies)): +**Phase 2** — the macro emits a single `_decorateModule` that **builds each JS host function itself** +via the closure-taking `JavaScriptObject.setProperty(_:)` (which calls `runtime.createFunction` +under the hood), with the decode-call-encode body **inlined into the closure**, decoding each +argument by its static type and calling the Swift function directly — no `[Any]`/`toTuple` +assembled by a generic call path, no per-call definition walk. Illustrative expansion (the exact +registration entry point is part of the [core contract](#core-dependencies)): ```swift -// One binding per @JS member. The binding IS the host-function body — it has the -// createFunction closure signature (this, arguments) -> JavaScriptValue — rather than -// wrapping it; `_decorateJavaScriptObject` does the createFunction call. Named with a backtick -// raw identifier `#add` so it's unmistakably generated and never spelled by users (see -// note). `self` is the native module instance; `this` is the JS owner; `arguments` is a -// JavaScriptValuesBuffer. appContext/runtime are threaded in (no longer captured). -@JavaScriptActor -private func `#add`(_ this: JavaScriptValue, _ arguments: borrowing JavaScriptValuesBuffer, - appContext: AppContext, in runtime: JavaScriptRuntime) throws -> JavaScriptValue { - // 1. static arity check — count known at expansion time - guard arguments.count == 2 else { throw InvalidArgCount(expected: 2, got: arguments.count) } - - // 2. per-argument decode, one cast per arg by its static type — no [Any] collection, - // no toTuple. (For now via the existing dynamic-type converters; see note.) - let a = try (~Double.self).cast(jsValue: arguments[0], appContext: appContext) as! Double - let b = try (~Double.self).cast(jsValue: arguments[1], appContext: appContext) as! Double - - // 3. call the Swift function directly, convert the typed result back to JS - let result: Double = self.add(a: a, b: b) - return try (~Double.self).castToJS(result, appContext: appContext, in: runtime) -} - -// A settable property → a getter binding + a setter binding. A getter-only property -// omits the setter. Same host-function-body shape as above. +// A single generated function decorates the JS object core hands it. This is what the runtime +// calls instead of walking a DSL definition. Core supplies the target object — the module's JS +// object, or a shared object's prototype/constructor; never a plain object the macro creates. +// Mirrors core's `ObjectDefinition.decorate(object:)`. Named `_decorateModule` (the `_`-prefix +// convention for synthesized members the runtime calls; the `ExpoModule` suffix names the macro +// it came from). `self` is the native module instance; `this` is the JS owner; `arguments` is a +// JavaScriptValuesBuffer. @JavaScriptActor -private func `#ready.get`(_ this: JavaScriptValue, _ arguments: borrowing JavaScriptValuesBuffer, - appContext: AppContext, in runtime: JavaScriptRuntime) throws -> JavaScriptValue { - return try (~Bool.self).castToJS(self.ready, appContext: appContext, in: runtime) -} -@JavaScriptActor -private func `#ready.set`(_ this: JavaScriptValue, _ arguments: borrowing JavaScriptValuesBuffer, - appContext: AppContext, in runtime: JavaScriptRuntime) throws -> JavaScriptValue { - self.ready = try (~Bool.self).cast(jsValue: arguments[0], appContext: appContext) as! Bool - return .undefined -} - -// A single generated function decorates the JS object core hands it with every binding. -// This is what the runtime calls instead of walking a DSL definition. Core supplies the -// target object — the module's JS object, or a shared object's prototype/constructor; -// never a plain object the macro creates. Mirrors core's `ObjectDefinition.decorate(object:)`. -// The createFunction call lives here once; each binding is passed in as the closure body. -@JavaScriptActor -public func _decorateJavaScriptObject(_ object: borrowing JavaScriptObject, - appContext: AppContext, in runtime: JavaScriptRuntime) throws { - // function → a plain property holding the function - let add = runtime.createFunction("add") { [self] this, args in - try `#add`(this, args, appContext: appContext, in: runtime) +public func _decorateModule(object: borrowing JavaScriptObject, + in runtime: JavaScriptRuntime, appContext: AppContext) throws { + // function → the decode-call-encode body is inlined straight into the setProperty closure + // (the closure-taking overload creates the host function under the hood). No separate named + // binding — inlining tested as no slower, and it drops a whole naming/collision scheme. + // Capture mirrors core's `SyncFunctionDefinition.build`: `self` (the module) STRONG — the + // closure is what keeps the native callable alive while JS can invoke it, reclaimed by the JS + // VM's GC; `appContext` WEAK + guarded, so it isn't a real retain cycle. + object.setProperty("add") { [weak appContext, self] this, arguments in + guard let appContext else { + throw Exceptions.AppContextLost() + } + // 1. static arity check — count known at expansion time + guard arguments.count == 2 else { + throw Exception(name: "InvalidArgumentCount", description: "Function 'add' expects 2 argument(s), but got \(arguments.count)") + } + // 2. per-argument decode by static type — no [Any], no toTuple. Primitives use a direct + // typed accessor (`asDouble()`, validating + throwing); other types fall back to + // `T.getDynamicType().cast(...) as! T` (the public converter; `~` is internal to core). + let arg0 = try arguments[0].asDouble() + let arg1 = try arguments[1].asDouble() + // 3. call the Swift function directly, encode the typed result back to JS. Primitives use + // `toJavaScriptValue(in:)`; other types use `T.getDynamicType().castToJS(...)`. + let result = self.add(a: arg0, b: arg1) + return result.toJavaScriptValue(in: runtime) } - object.setProperty("add", value: add.asObject()) - - // property → a get/set descriptor, installed with defineProperty - let descriptor = try runtime.createObject() - descriptor.setProperty("enumerable", value: true) - descriptor.setProperty("get", value: runtime.createFunction("ready") { [self] this, args in - try `#ready.get`(this, args, appContext: appContext, in: runtime) - }) - descriptor.setProperty("set", value: runtime.createFunction("ready") { [self] this, args in // omit for getter-only - try `#ready.set`(this, args, appContext: appContext, in: runtime) - }) - object.defineProperty("ready", descriptor: descriptor) + // property → a get/set accessor installed with defineProperty (implemented, PR #14). The + // getter/setter bodies inline the same way a function's do, reading/writing `self.ready`. A + // descriptor object holds `enumerable` + `get` (+ `set` when settable), each a closure-taking + // `setProperty(_:)` host function — the same overload functions use — then + // `object.defineProperty("ready", descriptor:)` installs it. A getter-only property omits `set`. + let readyDescriptor = runtime.createObject() + readyDescriptor.setProperty("enumerable", value: true) + readyDescriptor.setProperty("get") { [weak appContext, self] this, arguments in + guard let appContext else { throw Exceptions.AppContextLost() } + return self.ready.toJavaScriptValue(in: runtime) + } + readyDescriptor.setProperty("set") { [weak appContext, self] this, arguments in + guard let appContext else { throw Exceptions.AppContextLost() } + self.ready = try arguments.unownedValue(at: 0).asBool() + return .undefined + } + object.defineProperty("ready", descriptor: readyDescriptor) // … one entry per @JS function / property / constructor / event … } ``` @@ -777,54 +802,52 @@ Notes: `SyncFunctionDefinition.build` uses today (`SyncFunctionDefinition.swift:129`). `this` is the JS owner; the native receiver is the macro's `self`, so the body calls `self.add(…)` directly. -- Each `` `#…` `` binding **is the host-function body**, not a factory — it has the - `createFunction` closure shape `(this, arguments) throws -> JavaScriptValue`. The single - `runtime.createFunction(...)` call (and `.asObject()`) lives in `_decorateJavaScriptObject`, - which passes the binding as the closure. This keeps the per-member generated code to - just the decode-call-convert work, and avoids each binding capturing `self`/`appContext` - (they're plain method parameters / the implicit receiver). -- **Properties** emit a getter binding (`` `#ready.get` ``) and, if settable, a setter - binding (`` `#ready.set` ``); the builder wraps each in `createFunction` and assembles a - JS **descriptor** (`enumerable` + `get`/`set` functions), installed with - `object.defineProperty(name, descriptor:)` — the same descriptor shape - `PropertyDefinition.buildDescriptor` uses today (`PropertyDefinition.swift:183`). - Getter-only properties (computed `{ get }` / `let`) omit the setter binding. The - getter/setter decode + result conversion uses the same per-type `(~T.self)` casts as - functions. -- **First cut reuses the existing converters.** `(~A.self).cast(jsValue:appContext:)` is - the current per-type dynamic cast — it returns `Any`, so there's still a boxing + force - cast here. The win at this stage is structural: each argument is cast *individually by - its known type*, with no `[Any]` argument array and no `toTuple`. **Eliminating the - `Any` round-trip** (a converter that returns the concrete `A` directly) is the deeper - optimization, layered on later. -- The converter is chosen at expansion by the static type, so `@Record`, `@Union`, - shared object, optional, etc. each get their own `(~T.self)` cast (the full - `AnyArgument` set). -- **Async** (`@JS func … async`) wraps the same body in the JS `Promise` machinery and - runs on `@JavaScriptActor` (synchronous until the first suspension) — see - [`@JS` › Async functions](#async-functions). +- **The body is inlined into the `setProperty` closure**, not a separate named function. + `_decorateModule` installs each `@JS func` via the closure-taking `JavaScriptObject.setProperty(_:)` + overload (which creates the host function under the hood), with the full decode-call-encode + body inlined into the closure. (An earlier design emitted a named `` `#name` `` host-function + body per member and forwarded to it; inlining tested as no slower and dropped a whole + naming/collision scheme, so the named bindings were removed.) The closure captures **`self` + strong** (the closure is what keeps the native callable alive while JS can invoke it, reclaimed + by the JS VM's GC) and **`appContext` weak + guarded** (`guard let appContext else { throw + Exceptions.AppContextLost() }`) — mirroring core's `SyncFunctionDefinition.build` + (`SyncFunctionDefinition.swift:129`). +- **Primitive arguments/returns use a typed fast path; everything else falls back to the dynamic + converter.** For `Bool`/`Int`/`Double`/`String`, decode is a direct **`try arguments[i].asBool()` + / `asInt()` / `asDouble()` / `asString()`** (a validating `JavaScriptValue` accessor that throws + `TypeError` on mismatch) and encode is **`result.toJavaScriptValue(in: runtime)`** (the typed + `JavaScriptRepresentable` conversion). No `getDynamicType()` allocation, no `Any` boxing, no + force-cast. **Other types** (arrays, records, optionals, shared objects, other numeric widths) + fall back to **`T.getDynamicType().cast(jsValue:appContext:) as! T`** / `.castToJS(...)`. The + `~` prefix sugar is **`internal`** to core (`DynamicType.swift:34`) so generated consumer code + can't spell it; the public equivalent is **`T.getDynamicType()`** — a `public nonisolated static` + requirement on `AnyArgument` (`Arguments/AnyArgument.swift:9`) that `~` just wraps + (`AnyDynamicType.castToJS` is public, `AnyDynamicType.swift:74`). The fallback path still boxes + to `Any`; extending the typed fast path to more types is the remaining optimization. +- **Measured (bare-expo BenchmarkingExpoModule, 100k calls):** `@JS` runs ~2.2× faster than the + DSL `Function` and lands within **1.04–1.23×** of `@OptimizedFunction` — e.g. `addStrings` + 66.5 ms (`@JS`) vs 64.1 ms (`@OptimizedFunction`) vs 143 ms (`Function`); `nothing()` 26.6 vs + 21.7 vs 58.4 ms. The realistic arg-marshaling cases (`addStrings`) are essentially tied with the + optimized path; the small fixed gap on `nothing()` is the per-call `guard let appContext` + weak-load that `@OptimizedFunction` skips. This validates the end state: once every synthesized + function is fast by default, `@OptimizedFunction` becomes redundant. +- **Async** (`@JS func … async`) inlines an `async` body that `await`s `self.fn(...)`; the closure + being `async` is what selects the **async `setProperty(_:)` overload**, so JS receives a promise. + The decode-call-encode shape is otherwise identical to the sync case. Runs on `@JavaScriptActor` + (synchronous until the first suspension) — see [`@JS` › Async functions](#async-functions). - This sketch decorates a module's single JS object. A **shared object** uses a - `static func _decorateJavaScriptClass` instead — same per-member bindings, but applied - to the **constructor** (statics) and **prototype** (instance funcs + properties), once + `static func _decorateSharedObject` (a class-level form) instead — same inlined per-member closures, but + applied to the **constructor** (statics) and **prototype** (instance funcs + properties), once per class; receivers resolve from JS `this`. See [Class-level decoration](#class-level-decoration-phase-2). -- **The bindings are never called by name** — only the generated `_decorateJavaScriptObject` - references them (it knows the full set, since the macro emits both in one expansion). - They're named with **backtick raw identifiers `` `#add` ``** (SE-0451), which makes them - visually unmistakable as generated and unspellable in ordinary code, and `private` so they - don't leak. `_decorateJavaScriptObject` keeps the existing leading-underscore convention - since the **runtime calls it**, and takes the target object core supplies (it doesn't - create one) — mirroring core's `ObjectDefinition.decorate(object:)`, including its - **`borrowing`** parameter: it mutates the object through its reference - (`setProperty`/`defineProperty`) without reassigning or taking ownership, so it borrows - rather than `inout` (no rebinding) or `consuming` (caller keeps using it). - Same convention core uses (`ObjectDefinition.swift:97`). -- **Prerequisite: Swift 6.2.** Raw identifiers (`` `#…` ``) require Swift 6.2 **where the - generated code compiles — i.e. the consumer module**, not just the macro plugin (the - plugin is already on swift-tools 6.2). Core / consumer modules are currently - `swift_version 6.0`, so this binding style is gated on raising that floor. Until then the - fallback is a plain leading-underscore name (`_expoBind_add`), which has no version - requirement. Tracked in [Core dependencies](#core-dependencies). +- **The decorate function is the only generated entry point** (`_decorateModule` for a module, + `_decorateSharedObject` for a shared object), `public` because the **runtime calls it** (the + `_`-prefix is the convention for runtime-called synthesized members; the macro-name suffix makes + its origin clear and reads naturally at core's call site). It takes the target object core supplies (it doesn't create one) — mirroring + core's `ObjectDefinition.decorate(object:)`, including its **`borrowing`** parameter: it mutates + the object through its reference (`setProperty`/`defineProperty`) without reassigning or taking + ownership, so it borrows rather than `inout` (no rebinding) or `consuming` (caller keeps using + it). Same convention core uses (`ObjectDefinition.swift:97`). ### Proof of concept: `@OptimizedFunction` @@ -1049,11 +1072,13 @@ dict-based `sendEvent(_:_: [String: Any?])`). Add the same typed `emit` to `Base so module and shared-object events share one mechanism and accept any `AnyArgument` payload. (See [Module events](#module-events).) -**5. Swift 6.2 floor (for phase-2 binding names).** The phase-2 bindings use backtick -raw identifiers (`` `#add` ``, SE-0451), which require **Swift 6.2 in the consumer module** -where the generated code compiles. Core / consumer modules are `swift_version 6.0` today, -so this is gated on raising that floor (the macro plugin is already on 6.2). Fallback -until then: leading-underscore binding names (`_expoBind_…`), no version requirement. +**5. No special toolchain/language-mode requirement.** The generated code uses only plain +identifiers (`_decorateModule`, `argN`) — no raw identifiers — so there's no +`swift_version`/floor concern. (An earlier design used `` `#name` `` raw-identifier bindings, which would have needed +the Swift 6.2 compiler; inlining the bodies into the `setProperty` closures removed them.) Build +note: the macro plugin binary must be built with a swift-syntax major that the host compiler's +plugin protocol accepts, and **Xcode must be restarted after swapping the plugin binary** (it +caches `-load-plugin-executable` in-process; a clean build / DerivedData wipe does not reload it). **6. `StaticProperty` for shared objects.** `@JS static func` maps to the existing `StaticFunction` (decorates the constructor), but there's **no `StaticProperty`** in core @@ -1107,9 +1132,9 @@ before committing); each new impl struct → add to `providingMacros` in `Plugin `setUpEvents`. The macro output can't be pinned until this is decided; owned by the core side. 2. **`GroupView` / `ViewName`** — in scope or follow-up? -3. **Record field opt-out** — with all stored properties fields by default, how to - exclude one (an `@Ignore` attribute vs. a convention), and how a non-convertible - stored property is handled (diagnostic vs. silent skip). +3. **Non-convertible record property** — every stored property is a field (no opt-out, + no `@Field`); how is a non-`AnyArgument` stored property handled (diagnostic vs. + silent skip)? 4. **Discriminated unions** — `@Union` matches structurally in declaration order for v1; a tag-based mode (`@Union(discriminator: "type")`) for overlapping payload shapes is a deferred follow-up. Also: confirm `Union` is the right attribute name (vs. `JSUnion`). @@ -1127,8 +1152,8 @@ module + view lifecycle → overridable instance methods (core-called); view pro single `@ViewProps` object (no `@Prop`), exposed as non-optional `self.props` (always current); `onViewPropsChanged(oldProps:)` gets only the previous props, typed `Props?` (`nil` on first application); `@ViewProps` distinct from `@Record`; no -`@Constant` macro; `@ExpoView` props via metatype arg; `@Record`/`@ViewProps` fields -are all stored properties by default — `@Field` only for options; field requiredness is +`@Constant` macro; `@ExpoView` props via metatype arg; **`@Record`/`@ViewProps` fields +are every stored property — no `@Field` attribute**; field requiredness is **inferred** (default value → optional; optional type → nullable+optional; non-optional no-default → required); **`@ViewProps` may be a `struct` (UIKit) or `class` (SwiftUI) — `@ViewProps` emits a value vs. class-bound @@ -1139,22 +1164,25 @@ JS `Promise`; `async` functions stamped `@JavaScriptActor`; body runs synchronou the JS thread until the first suspension (JS-like)**. Unions: **`@Union` on an enum = typed N-case union (tagged, no `Any?`, → TS `A | B | C`); `Either` kept for inline 2-type; structural match in declaration order, discriminated mode deferred**. Phase 2: -**per-member bindings named with backtick raw identifiers (`` `#add` ``, never called by -users; needs Swift 6.2 in the consumer), installed by one generated -`_decorateJavaScriptObject(_ object:…)` that decorates the JS object core supplies (module -object / SO prototype / constructor) — mirrors `ObjectDefinition.decorate(object:)`; each -binding *is* the host-function body `(this, arguments) -> JavaScriptValue`, the decorate -function does the `createFunction` call (parameter is `borrowing`, matching -`ObjectDefinition.decorate`)**. Shared objects: **`static` Swift modifier marks JS-static -members (→ constructor, `StaticFunction`) vs instance (→ prototype). All shared-object -bindings are `static func`s (class-level decoration); an instance-member binding recovers -its receiver from JS `this`, a static-member binding calls the Swift `static` member. A JS -instance + static member sharing a name disambiguate by a `` `#static.name` `` qualifier -(same scheme as property `` `#ready.get` ``). `static var` needs a core `StaticProperty`. -Decoration is **class-level, once per class** — `static func _decorateJavaScriptClass` -sets up constructor (statics) + prototype (instance funcs *and* properties, receivers from -`this`); no per-instance pass. A module decorates its single object via -`_decorateJavaScriptObject` with instance-method bindings on real `self`**. +**one generated decorate function (`_decorateModule` for a module, `_decorateSharedObject` for a +shared object) decorates the JS object core supplies (module object / SO prototype / constructor) +via the closure-taking `JavaScriptObject.setProperty(_:)` — +the decode-call-encode body is **inlined into each closure** (no separate named binding; tested as +no slower than a named func). Mirrors `ObjectDefinition.decorate(object:)` (`borrowing` object). +The closure captures `self` strong + `appContext` weak-guarded (matching `SyncFunctionDefinition`). +Primitives (`Bool/Int/Double/String`) decode via `arguments[i].asDouble()`-style validating +accessors and encode via `toJavaScriptValue(in:)` (no `Any`, no dynamic-type alloc); other types +fall back to `T.getDynamicType().cast/castToJS`. Measured ~2.2× faster than the DSL and within +1.04–1.23× of `@OptimizedFunction`. The entry point uses the `_`-prefix (runtime-called) with a +macro-name suffix; a `@JS func decorate` can't collide since there are no named bindings.** +Shared objects: **`static` Swift modifier marks JS-static members (→ constructor, `StaticFunction`) +vs instance (→ prototype). An instance member's closure decorates the prototype and recovers its +receiver from JS `this`; a static member's closure decorates the constructor and calls the Swift +`static` member. A JS instance + static member sharing a name don't clash — they're closures on +different JS objects. `static var` needs a core `StaticProperty`. Decoration is **class-level, once +per class** — `static func _decorateSharedObject` sets up constructor (statics) + prototype +(instance funcs *and* properties, receivers from `this`); no per-instance pass. A module decorates +its single object via an instance-method `_decorateModule` on real `self`**. ## Further ideas From e2ac96a61a5202260805bf0fc5c8a12767527d38 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Thu, 11 Jun 2026 20:12:37 +0200 Subject: [PATCH 03/15] Add JavaScriptKit / BridgeJS prior-art note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftWasm's JavaScriptKit (BridgeJS) ships macro-based Swift↔JS interop and independently lands on the same core decisions (value vs reference semantics, fields-by-default, static markers, generated .d.ts) — validation of this direction. Notes the deliberate divergence: it uses one @JS for all kinds, while we split @JS (members) from @Record/@Union/@ViewProps (types) to track existing expo-modules-core concepts. Adds the related open question on whether @JS struct/@JS enum should error-and-redirect or alias to @Record/@Union. --- docs/expo-modules-v2.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index d689302..8030a1a 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -43,6 +43,38 @@ API **for AI coding agents** — and, increasingly, that's a first-class design The same properties that help agents (ordinary Swift, one obvious place for types, fewer special concepts) help humans too — this isn't an agent-only optimization. +## Prior art: JavaScriptKit / BridgeJS + +SwiftWasm's [JavaScriptKit](https://github.com/swiftwasm/JavaScriptKit) (its **BridgeJS** +interop) is shipping macro-based Swift↔JS bridging, and independently lands on most of the +same decisions — strong validation of this direction: + +- **`@JS` macro-based export** of Swift types/functions to JS, replacing hand-written glue + — the same thesis as this proposal. +- **`@JS struct` = value/copy semantics** (reconstructed as a plain JS object, no shared + state); **`@JS class` = reference semantics** (shared across the boundary). Matches our + `@Record` (value) vs `@SharedObject` (reference) split. +- **Instance fields auto-exported, no per-field annotation**; **static** members need an + explicit marker. Matches our fields-by-default ([`@Record`](#record)) and `static` + differentiator ([static vs. instance](#static-vs-instance-members)). +- **Optionals → `T | null`**, nested structs nest, `JSObject` fields allowed; **no + generics / conformances / property observers**. Mirrors our requiredness/nullability + rules. +- **Generates TypeScript `.d.ts`** from the annotated source — exactly + [Phase 3](#phase-3--typescript-type-generation). +- Struct `init` → a **static `init` method**; class `init` → a JS **`new` constructor** — + the same instance-vs-static / prototype-vs-constructor distinction we draw. + +**Where it differs (deliberately):** JavaScriptKit uses **one `@JS` attribute for +everything** (struct, class, enum, func, property). We split it — `@JS` for *members*, +`@Record`/`@Union`/`@ViewProps`/`@ExpoView` for *types* — because those map onto +**existing `expo-modules-core` concepts** (`Module`, `Record`, `SharedObject`, views) with +distinct runtime registration and lifetimes. JavaScriptKit targets a flatter, pure-Swift- +Wasm world (Swift *is* the whole program), where a single `@JS` fits; we layer onto a host +runtime with a pre-existing type taxonomy. (This is also the backdrop for the open +question of whether `@JS struct`/`@JS enum` should be an error redirecting to +`@Record`/`@Union`, or accepted as a JavaScriptKit-familiar alias — see open questions.) + ## Phases 1. **[DSL coverage](#phase-1--dsl-coverage)** — macros that cover the existing API @@ -1143,6 +1175,12 @@ before committing); each new impl struct → add to `providingMacros` in `Plugin 6. **Phase 3 front end** — `expo-type-information` already parses Swift modules (via SourceKitten) and emits TS; phase 3 adapts it to the new attributes. Open: keep SourceKitten or move to SwiftSyntax for consistency with the macro toolchain. +7. **`@JS struct` / `@JS enum`** — `@JS` is member-only today (funcs/properties/inits); a + nested `struct`/`enum` is a silent no-op. Either **error and redirect** to + `@Record`/`@Union`, or **accept as an alias** for them (the JavaScriptKit-familiar + spelling — see [Prior art](#prior-art-javascriptkit--bridgejs)). Leaning toward the + redirect, since our type attributes track distinct core concepts; revisit if the + JavaScriptKit muscle-memory argument wins. Resolved: events → typed payloads (`AnyArgument`), no `EventDispatcher`; view events fold into `@ViewProps` function-typed fields; **module & shared-object events are a From 4ab8f12a9b9a0b5717bf7dd62eeed8d7d03a5791 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Fri, 12 Jun 2026 09:23:01 +0200 Subject: [PATCH 04/15] Add Android companion doc; sync v2 doc from plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `docs/expo-modules-v2-android.md` — the Android/Kotlin companion design. - Update `docs/expo-modules-v2.md` to the latest from the working draft. --- docs/expo-modules-v2-android.md | 433 ++++++++++++++++++++++++++++++++ docs/expo-modules-v2.md | 199 +++++++++++---- 2 files changed, 589 insertions(+), 43 deletions(-) create mode 100644 docs/expo-modules-v2-android.md diff --git a/docs/expo-modules-v2-android.md b/docs/expo-modules-v2-android.md new file mode 100644 index 0000000..3695938 --- /dev/null +++ b/docs/expo-modules-v2-android.md @@ -0,0 +1,433 @@ +# Expo Modules v2 — Android / Kotlin parity + +- **Status:** Draft companion to `expo-modules-v2.md` (Apple/Swift). Grounded against + `expo/main` Kotlin core, not yet prototyped. +- **Author:** Tomasz Sapeta +- **Created:** 2026-06-11 +- **Scope:** Android/Kotlin annotation-processing path for the same author-facing model + the Swift macros deliver (`apple/Sources/ExpoModulesMacros`). Read alongside + `expo-modules-v2.md` — that doc owns the *design*; this one owns the *Android mapping*. + +## TL;DR — can we expose the same API? + +**Yes.** The author-facing API (what someone types) can be the same on both platforms, and +that's the whole point — same annotations, same mental model, same generated TypeScript: +`@ExpoModule` classes, `@JS` on functions/properties, `@Record` with fields-by-default and +inferred requiredness, `@SharedObject` (instance + static), typed events, `@Union`, the +typed view-props object, and `async` → JS `Promise`. Field requiredness lines up because +Swift optional/default and Kotlin nullable/default map to the same rule. + +Three asterisks — none of them break the *API*; they're behavioral or syntactic: + +1. **Async behaves differently underneath.** Same spelling, same `Promise` to the JS + caller, but iOS runs synchronously to the first suspension (actor isolation) while + Kotlin dispatches via coroutines. Identical API, different timing semantics. +2. **Unions are spelled per-language.** Swift `enum` with associated values vs Kotlin + `sealed interface` — same `@Union` annotation and same `A | B | C` TS, just idiomatic + native declaration kinds. +3. **One Android visibility wrinkle.** A `@JS` member may need to be `internal`+ (not + `private`), because the KSP-generated companion sits outside the class. Settle in the + prototype; doesn't change the annotation surface. + +What is **not** shared is the *implementation*: a parallel KSP processor, not a port of the +Swift macros, because KSP generates files instead of rewriting declarations. That's an +implementation cost, not an API limitation. The rest of this doc works through the mapping. + +## Why this companion exists + +`expo-modules-v2.md` lists "Android / Kotlin parity — an equivalent annotation-processing +path (KSP) so a single conceptual module surface generates both platforms' definitions +and shared TS types" as a *Further idea*. This doc works out whether that's real and what +it costs, grounded in the actual Kotlin core (paths under +`…/expo/main/packages/expo-modules-core/android/src/main/java/expo/modules/kotlin`). + +**The headline finding:** the author-facing model ports cleanly, and the compile-time +strategy is **already shipping on Android** in a narrow form. Expo's Kotlin type system +is already wired through **Pika** (`io.github.lukmccall.pika`), a **KSP-based +compile-time introspection** library used today for `@OptimizedRecord`/ +`@OptimizedComposeProps` — it generates `PIntrospectionData` so the runtime can skip +`kotlin.reflect`. So "a KSP processor that reads annotations and emits compile-time +binding data instead of reflecting at runtime" is not a leap on Android; it's an +*extension of a pattern that already exists in core* (`types/typeDescriptorOf.kt:32` +already prefers the Pika `ctTypeDescriptorOf()` path and falls back to `typeOf()`). + +## The fundamental tooling difference (read this first) + +Swift compiler macros and KSP are **not the same kind of tool**, and every section below +is shaped by this: + +| | Swift macros (iOS) | KSP (Android) | +|---|---|---| +| Mechanism | Rewrite/attach **in place** on the annotated decl (member/peer/accessor) | **Generate new files only** — cannot touch existing source | +| Sees | SwiftSyntax (unresolved syntax) | Resolved symbols (`KSClassDeclaration`, resolved `KSType`) — *more* type info | +| Output | New members on the same type, conformances, peers | Separate `.kt` files (new top-level/extension/companion decls) | +| Runs | Per-decl during type-check, sandboxed (no FS/network) | Gradle build step, **has** FS access (can emit TS as a side artifact) | + +Two consequences dominate: + +1. **KSP can't rewrite the user's declaration.** Every iOS trick that *stamps something + onto the author's own member* must be redesigned as *generate a companion*. Concretely + affected: the `@JS` peer conformance-assertion (iOS attaches it to the member); + `@JavaScriptActor` stamping (the actor-isolation that makes async run sync-to-first- + suspension — **no Kotlin equivalent at all**); `@Event` as an accessor macro rewriting + a stored `var` into a computed property (KSP can't, so the event shape must change). +2. **KSP sees resolved types, which is actually easier for the binding work.** A Swift + macro only sees `a: Double` as syntax and must trust it; KSP resolves `KSType` so it + *knows* the type, nullability, defaults, and annotations without guessing. The + per-argument typed converter selection that iOS phase 2 does at expansion time is + *more* reliable on Android. + +So: **the model ports; the mechanism does not.** This is a parallel implementation that +lands on the same author API, not a translation of the macro code. + +## Prior art already in core (the Android equivalent of "the macro knows the type") + +Before designing anything, note what core already does so we don't reinvent it: + +- **Pika / KSP is live.** `expo-modules-core`'s gradle plugin pins a KSP version + (`ExpoModulesCorePlugin.gradle:20-39`) and the type system imports Pika throughout + (`types/JSTypeConverterHelper.kt`, `types/ReturnType.kt`, + `types/descriptors/TypeDescriptor.kt`, `records/RecordTypeConverter.kt`). Modules opt + into KSP today (`expo-image`, `expo-updates`, `expo-app-metrics`). +- **`@OptimizedRecord` is the proof of concept** — the Android analog of the iOS + `@OptimizedFunction` POC. It's a `@Target(CLASS)` annotation + (`types/OptimizedRecord.kt`) whose Pika-generated introspection lets + `RecordTypeConverter` skip the reflective `KClass.memberProperties` / + `KProperty.javaField` walk. v2 generalizes the *same idea* from records to the whole + `@JS` surface. +- **The runtime already has a clean direct-binding seam.** `JSDecoratorsBridgingObject` + (`jni/decorators/JSDecoratorsBridgingObject.kt`) exposes `registerSyncFunction`, + `registerAsyncFunction`, `registerProperty`, `registerConstant` — each takes a + `JNIFunctionBody` closure + an `Array` of C++ type codes. This is the + Android counterpart of iOS's `JavaScriptObject.setProperty`/`defineProperty`. A + generated binding has a real, existing entry point to target. + +This is why Android is in some ways *better positioned* than iOS for phase 2: the +"describe-then-interpret" vs "bind-directly" seam (`registerSyncFunction` taking a +closure) already exists, and a compile-time-data pattern (Pika) is already accepted. + +--- + +# Phase mapping + +The three iOS phases map onto Android, but the **risk profile is different**: Phase 1 and +Phase 3 are the safe, high-value targets; Phase 2's *story* changes because the cost model +is JNI/JVM, not Swift/JSI. + +## Phase 1 — DSL coverage (KSP generates `ModuleDefinition { … }`) + +**iOS:** the macro emits the `*Definition` element tree onto the type. +**Android:** a KSP processor reads the annotations and **generates a `.kt` file** that +builds the existing `ModuleDefinitionData` via the current DSL +(`modules/ModuleDefinitionBuilder.kt`, `objects/ObjectDefinitionBuilder.kt`). + +The existing Kotlin DSL is a near-perfect target — same grammar family as iOS: + +- `Name(name)` (`ModuleDefinitionBuilder.kt:73`) +- `Function(name) { … }` / `AsyncFunction(name) { … }` (reified-generic builders in + `objects/ObjectDefinitionBuilder.kt`, up to 8 params via `FunctionBuilder.kt:34`) +- `Property(name) { get { … } set { … } }` → `PropertyComponent` (getter+setter as two + `SyncFunctionComponent`s, `objects/PropertyComponent.kt`, + `objects/PropertyComponentBuilder.kt`) +- `Events("a", "b")` (`ObjectDefinitionBuilder.kt:438`) +- `View(MyView::class) { Prop(name) { v, p -> } … }` (`views/ViewDefinitionBuilder.kt:92`) +- `Class(name) { … }` for shared objects (`ModuleDefinitionBuilder.kt:181`) + +**How the generated code attaches to the user's class.** Because KSP can't add a method +*to* `class MyModule`, it generates an **extension** or a **separate definition provider**. +Two viable shapes (pick during prototype): + +```kotlin +// authored +@ExpoModule class MyModule { + @JS fun greet(name: String): String = "hi $name" + @JS var ready: Boolean = false +} + +// KSP-generated MyModule_ExpoDefinition.kt +internal fun MyModule.synthesizedDefinition(): ModuleDefinitionData = + ModuleDefinition { + Name("MyModule") + Function("greet") { name: String -> greet(name) } + Property("ready").get { ready }.set { v: Boolean -> ready = v } + } +``` + +The author's base `Module.definition()` (`modules/Module.kt:68`) either calls the +generated extension, or core gains a tiny convention: if a generated +`_ExpoDefinition` exists, `ModuleHolder` uses it. (This is the Android version +of the iOS `_synthesizedDefinition()` core-rename dependency — same coupling, different +lookup mechanism.) + +**Per-attribute mapping for Phase 1:** + +### `@ExpoModule` +KSP `SymbolProcessor` visits `@ExpoModule` classes, collects `@JS`/`@Event` members +(resolved symbols — names, param `KSType`s, nullability, `suspend`, `static`/companion, +defaults all available), and emits the `ModuleDefinition { … }` builder. The +`views = [...]`/`classes = [...]` args become generated `View(...)`/`Class(...)` entries. +Unlike iOS, **no inheritance synthesis is needed** — the author still `: Module` +(or core adds an interface later); KSP just generates the definition. + +### `@JS` (functions & properties) +- `@JS fun f(...)` → `Function("f") { … }`; `@JS suspend fun f(...)` → + `AsyncFunction`/suspend component (see async below). `@JS("name")` overrides. +- `@JS val/var p` → `Property("p").get { p }` plus `.set { p = it }` **iff settable** — + Kotlin makes this *cleaner than Swift*: `var` → settable, `val` → read-only, decided + syntactically with no accessor analysis. Visibility (`private`) doesn't gate JS exposure + for the same reason as iOS: the generated accessor must be able to read it — **caveat**, + unlike a Swift macro expanding *inside* the type, a KSP-generated extension is *outside* + the class, so it **can't see `private` members**. So either: (a) require `@JS` members to + be at least `internal`, or (b) generate into the same module/package and rely on + `internal` visibility. This is a real divergence from iOS to settle in the prototype. +- **The conformance assertion** iOS emits as a peer becomes either a KSP **diagnostic** + (`KSPLogger.error` at processing time — strictly better UX, fails the build with a + precise message on the user's decl) or generated `require`-style checks. KSP diagnostics + are the natural and superior form here. + +### Async — the biggest semantic divergence +iOS: `async` Swift func, stamped `@JavaScriptActor`, runs **synchronously on the JS thread +until the first real suspension** (SE-0306 actor-reentrancy guarantee). **Kotlin has no +analog.** Android async is one of: +- `SuspendFunctionComponent` (`functions/SuspendFunctionComponent.kt`) — author writes + `@JS suspend fun`, KSP emits a suspend component that `launch`es on a `CoroutineScope` + (queue-selected: `MAIN`/`DEFAULT`/custom) and resolves a `Promise`. **This already + exists and is the right target.** +- The older `AsyncFunctionWithPromiseComponent` (explicit `Promise` arg) — drop it, same + as iOS drops the Promise-param form. + +**Decision:** `@JS suspend fun` → JS `Promise`, via `SuspendFunctionComponent`. **Do not +promise iOS's "synchronous to first suspension" semantics** — Kotlin coroutines dispatch +per the chosen dispatcher; document the difference rather than fake it. This is acceptable +(JS callers `await` either way) but it is a genuine behavioral difference between platforms +that the shared TS surface hides. + +### `@Record` +iOS `@Record` synthesizes compile-time field/key pairs replacing the `Mirror` walk. +Android already has the runtime (`records/Record` marker, `@Field`, `@Required`, +`RecordTypeConverter`) **and** the compile-time path (`@OptimizedRecord` + Pika). So the +v2 `@Record` annotation on Android = **the existing `@OptimizedRecord` story, made the +default and given inference**: +- Every property is a field — but today Android requires explicit `@Field` + (`RecordTypeConverter.kt` only picks up `findAnnotation()`). v2 = **field-by- + default** (drop the per-field `@Field` requirement; KSP sees every property). +- **Requiredness inference** matches iOS: non-null + no default → required; has default → + optional; nullable `T?` → nullable+optional. Today Android uses an explicit `@Required` + annotation; v2 infers it from the Kotlin declaration (KSP resolves nullability and sees + default values). `@Field("jsKey")` stays as the name-override escape hatch. +- The reflective `KClass.memberProperties`/`javaField.set` path + (`RecordTypeConverter.kt:46-150`) is replaced by generated field descriptors — exactly + what `@OptimizedRecord` + Pika already do; v2 makes it automatic. + +### `@Union` +iOS: `@Union` on an enum with associated-value cases; structural decode in declaration +order; `Either` kept for inline 2-type. Android has `Either` (`apifeatures`, the +deprecated `@EitherType`) and a `DynamicEitherType`-equivalent trial decode. Kotlin's +analog of "enum with typed payloads" is a **sealed class/interface** (each subtype carries +its typed payload), not an `enum class` (Kotlin enum cases can't carry distinct +per-case types). So: +- `@Union` on a **`sealed interface`/`sealed class`**; KSP reads the subtypes and emits an + ordered, typed decode into the matching subtype — the Android counterpart of the iOS + enum-case decode, and it kills the `Either` `try?`-each-candidate trial loop the same way. +- `Either` stays for inline 2-type. + +### Views — same hard core dependency as iOS, different runtime +iOS phase 1 views are **gated on a core props-object contract that doesn't exist yet** +(`Props(_:)` element, `var props`, `onViewPropsChanged(oldProps:)`). Android is in the +*same position but further*: today views are per-prop closures +(`Prop(name) { view, prop -> }`, `views/ConcreteViewProp.kt`) and there is **no unified +props object** at all on Android. So `@ViewProps`/`@ExpoView` on Android need the **same +core runtime work iOS needs**, ported to Kotlin: +- A typed props type registered for an `ExpoView` (a `Props(MyProps::class)` element or a + `View` overload). +- `var props: Props` always-current + `onViewPropsChanged(oldProps: Props?)` override, + with core setting `props` before the callback. +- **Event dispatch by name**, not by mutating a stored closure. Android's current view + events use the `ViewEventDelegate`/`EventDispatcher` property-delegate + (`viewevent/ViewEventDelegate.kt`, `View.EventDispatcher()`), discovered per-view. + A `data class`/value props object can't carry a mutated delegate, so — exactly like iOS — + the generated event must *call into* a core dispatcher keyed by name. + +**Struct-vs-class on Android:** iOS's `@ViewProps struct` (value) vs `class` (SwiftUI +observable) distinction maps to Kotlin `data class` (value props, the common case) vs a +class implementing an observable contract (Jetpack Compose path — note core already has +`@OptimizedComposeProps`, so a Compose props story exists to align with). The "wrong kind +is a compile error via the conformance" trick is **weaker on Android** — KSP can see +`class` vs `data class` and emit a *diagnostic* directly (better than iOS's conformance +trick), rather than relying on a protocol bound to fail. + +### `@SharedObject` +iOS synthesizes `_synthesizedClassDefinition()` / `_decorateSharedObject`. Android already +has `Class(name) { … }` (`ModuleDefinitionBuilder.kt:181`), `SharedObject` +(`sharedobjects/SharedObject.kt`), the `SharedObjectRegistry` native↔JS id pairing +(`sharedobjects/SharedObjectRegistry.kt`, `SharedObjectTypeConverter`). So `@SharedObject` +on Android = KSP generates the `Class(name) { Constructor(...); Function(...); +Property(...) }` block from `@JS`/`@JS static`(companion)/`init` members. Instance vs +static maps to **instance members vs `companion object` members** (Kotlin's static analog). +Events use the existing `SharedObject.emit(event, payload)` (`SharedObject.kt`), which is +already typed-payload-friendly — closer to done than iOS was before core's `EventEmitter` +work. + +## Phase 2 — Performance: the story changes (this is where the analogy breaks) + +iOS phase 2's win = generated Swift binds directly into the JS object via JSI, decoding +each arg by its **static Swift type** with no `[Any]`/`toTuple` boxing, measured ~2.2× +over the DSL. + +On Android the win is **real but differently shaped**, for three reasons: + +1. **There is no `[Any]`/`toTuple` to delete.** The Kotlin path already crosses JNI as an + `Array` and converts per-arg via `AnyType.convert` → + `TypeConverterProvider.obtainTypeConverter` (`functions/AnyFunction.kt:67`, + `types/AnyType.kt`). The expensive part isn't a tuple reinterpret; it's **runtime + converter resolution + `kotlin.reflect`** (for records: `memberProperties`, + `KProperty.returnType`, `javaField.set`). +2. **The "skip reflection" win is the one that ports — and core already does it.** The + Android phase-2 win is: KSP knows each arg's resolved `KSType`, so it emits the + concrete `TypeConverter` selection (or a direct primitive read) at **compile time**, + eliminating the lazy `obtainTypeConverter` lookup and the reflective record walk. This + is *exactly* what Pika + `@OptimizedRecord` already prove for records; phase 2 + generalizes it to functions/properties/unions. The existing `getCppRequiredTypes()` + (`ExpectedType` arrays) are already emitted per-component, so the JNI registration + surface needs no change — just statically-resolved converters behind it. +3. **No `@JavaScriptActor` no-hop guarantee exists** to lean on for async, so there is no + "synchronous to first suspension" perf/semantics win to claim. Async stays + coroutine-dispatched. + +So phase 2 on Android = **make every generated component use compile-time converters +(Pika-style) by default**, the way iOS makes every function direct-JSI by default. The +runtime seam (`JSDecoratorsBridgingObject.registerSyncFunction(... JNIFunctionBody ...)`) +already binds a closure directly — the generated `JNIFunctionBody` just contains a +statically-resolved decode-call-encode body instead of one that calls +`convertArgs`/`obtainTypeConverter` dynamically. + +**`@OptimizedFunction`/`@OptimizedRecord` are the Android POC, same end state.** +`@OptimizedRecord` (records) is the shipped proof that compile-time introspection beats +reflection on Android; v2 makes it the default for the whole `@JS` surface and the +explicit opt-in annotation becomes redundant — mirroring the iOS plan retiring +`@OptimizedFunction`. + +**What phase 2 does NOT get on Android that iOS does:** the elimination of an `Any` box. +JNI hands Kotlin `Array`; you can avoid *reflective converter resolution* but the +boxed-`Any?` argument array is the JNI ABI. A deeper win (specialized JNI entry points per +arity/type) is a much larger core/C++ change and is **out of scope** — note it explicitly +so the perf story isn't oversold as "same as iOS." + +## Phase 3 — TypeScript generation (the strongest case for doing this at all) + +iOS phase 3 **cannot live in the macro** (sandbox) and is a separate source-parser +(`expo-type-information`, SourceKitten-based) — today **Swift-only** (confirmed: the +package has `src/swift/sourcekittenTypeInformation.ts` and no Kotlin parser). + +Android has **two routes**, and the second is the prize: + +1. **KSP-emitted type info.** Because KSP *does* have filesystem access (unlike the Swift + macro sandbox), the same processor that generates the binding can **also emit a TS/JSON + type artifact** as a build output. This is *easier* than iOS, where a wholly separate + SourceKitten tool is required. +2. **Unified cross-platform generation (the goal).** The ideal end state from the iOS doc's + Further-ideas bullet: **one TS surface generated from both platforms**. Two + sub-options: + - Both KSP (Android) and `expo-type-information` (iOS) emit a common intermediate + type-info JSON; a small merge step produces the shared `.d.ts` and **diffs the two** + (flagging when a module's Swift and Kotlin surfaces disagree — a real, valuable + cross-platform lint that neither platform can do alone). + - Or teach `expo-type-information` a Kotlin front end. The agent confirmed it has none + today, so this is net-new; the KSP-emits-its-own-info route is lower-friction since + KSP already has resolved types in hand. + +Phase 3 is where "single conceptual module surface, shared TS types" actually pays off, and +Android is *better* suited to it than iOS (KSP can write files; the macro can't). + +--- + +# Cross-cutting: what ports, what doesn't + +**Ports cleanly (the model):** +- Author API: annotated class, `@JS` members, fields-by-default records with inferred + requiredness, typed events, `@Union`, typed view props object. This is the whole point — + symmetric authoring + shared TS. +- Field-requiredness inference (Kotlin nullability + default params give the same 3-way + table as Swift). +- The compile-time-over-reflection performance thesis (Pika/`@OptimizedRecord` already + prove it on Android). +- The direct-binding seam (`JSDecoratorsBridgingObject` ≈ `JavaScriptObject.setProperty`). +- Shared-object id pairing, events (`SharedObject.emit` is already typed-payload-shaped). + +**Does NOT port (the mechanism), needs redesign:** +- **In-place declaration rewriting.** KSP generates companions; it can't stamp the user's + member. Affects: `@JS` peer assertion (→ KSP diagnostic, *better*), `@Event` accessor + rewrite (→ generated wiring or a delegate, *different shape*), and `private`-member + access (a generated extension can't see `private` — iOS expands *inside* the type and + can; **settle the visibility rule in the prototype**). +- **`@JavaScriptActor` / actor-isolation async semantics.** No Kotlin equivalent. Async = + `suspend fun` → coroutine → Promise; **no "sync to first suspension" guarantee**. +- **The `Any`-box-elimination half of phase 2.** JNI ABI is `Array`; you remove + reflective resolution, not the box. +- **`enum`-with-payload unions** → Kotlin `sealed interface`, not `enum class`. + +**Core dependencies (Android) — same coupling risk the iOS doc flags repeatedly:** +- Generated definition lookup (`_ExpoDefinition` convention or + `ModuleHolder`/`Module.definition()` hook) — the Android analog of the iOS + `_synthesized…` rename. Every stage needs a paired core PR; green processor tests do not + prove integration. +- **Unified view-props runtime on Android does not exist** (today: per-prop closures, no + props object) — `@ViewProps`/`@ExpoView` are gated on the same core work iOS needs, + ported to Kotlin (props-object decode, `onViewPropsChanged`, name-keyed event dispatch). +- `@JS static var` ↔ a companion-object/static property binding (iOS's `StaticProperty` + gap has an Android sibling). +- Field-by-default records: core's `RecordTypeConverter` must stop *requiring* `@Field` + (or the generated descriptor must supply all fields). + +# Recommended sequencing + +Mirror the iOS staging, lead with the lowest-risk highest-value pieces: + +1. **`@Record` field-by-default + inferred requiredness** via KSP, reusing the existing + Pika/`@OptimizedRecord` machinery. Lowest risk — the runtime and compile-time paths both + exist; this is mostly "make it the default and infer." +2. **`@ExpoModule` + `@JS` (sync functions, properties)** → generated `ModuleDefinition`. + Establishes the processor, the generated-definition lookup convention (paired core PR), + and the visibility rule. +3. **`@JS suspend fun`** → `SuspendFunctionComponent`/Promise. Document the async-semantics + divergence from iOS. +4. **`@SharedObject`** (instance + companion statics, events via `SharedObject.emit`). +5. **`@Union`** on sealed types. +6. **Phase 2 default** — statically-resolved converters for every generated component + (generalize Pika beyond records); retire `@OptimizedRecord`/`@OptimizedFunction`-style + opt-ins. +7. **Views** (`@ViewProps`/`@ExpoView`) — **gated on the core props-object runtime**, the + same hard dependency as iOS. Don't land ahead of that core work. +8. **Phase 3** — KSP emits type-info; build the cross-platform merge/diff against + `expo-type-information`'s output. The highest-leverage cross-platform payoff. + +# Risks specific to Android + +- **Visibility (high, and iOS doesn't have it).** A KSP-generated extension is outside the + class and can't read `private` members; a Swift macro expands inside and can. Either + constrain `@JS` to `internal`+ or generate into the same package and rely on `internal`. + Decide early — it shapes every generated accessor. +- **Async semantics differ from iOS (medium).** No actor no-hop; the shared TS surface + hides a genuine behavioral difference. Document, don't paper over. +- **View core contract is a hard dependency, and Android is *further behind* iOS (high).** + No props object exists today; this is net-new core runtime, not just a rename. +- **Out-of-repo coupling (high).** Same as iOS: generated code only compiles against + matching core; every stage needs a paired core PR. Plus a build-wiring concern unique to + Android: KSP is applied **per-module** today (`apply plugin: 'com.google.devtools.ksp'`), + not centrally — v2 likely wants the `expo-module-gradle-plugin` to apply the v2 processor + automatically so authors don't hand-wire it. +- **Pika dependency (medium).** Leaning on Pika generalizes a third-party + (`io.github.lukmccall.pika`) KSP library beyond its current record-only use; either + commit to it as the introspection substrate or write a first-party `SymbolProcessor` + (none exists in the repo today). + +# Bottom line + +Yes — the same author-facing model can be implemented on Android, and the design intent is +already in the iOS doc. **Phase 1 and Phase 3 are the safe, high-value targets** (Phase 3 +is *better* on Android than iOS and is the real cross-platform payoff), **Phase 2's perf +story must be re-derived for the JVM/JNI reality** (compile-time converter resolution, not +`Any`-box elimination — and core already proves the pattern via Pika/`@OptimizedRecord`), +and **the mechanism is a parallel KSP implementation, not a port** — every "rewrite the +author's declaration" trick from the macros becomes "generate a companion," with async +actor-isolation having no Kotlin equivalent at all. diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index 8030a1a..95b710b 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -2,7 +2,7 @@ - **Status:** Draft for review (signatures verified against `expo/main` core) - **Author:** Tomasz Sapeta -- **Created:** 2026-05-31 · **Updated:** 2026-06-07 +- **Created:** 2026-05-31 · **Updated:** 2026-06-11 - **Scope:** Apple/Swift macros in this repo (`apple/Sources/ExpoModulesMacros`) ## Goal @@ -202,8 +202,8 @@ object for core to populate. ```swift @ExpoModule class MyModule: Module { - @Event var onProgress: (ProgressEvent) -> Void // ProgressEvent: @Record - @Event var onReady: () -> Void // no payload + @Event var onProgress: (ProgressEvent) -> Void // ProgressEvent: @Record; JS: addListener("progress") + @Event var onReady: () -> Void // no payload; JS: addListener("ready") func work() { self.onProgress(ProgressEvent(percent: 50)) // type-checked; dispatches to JS @@ -213,27 +213,96 @@ class MyModule: Module { `@Event` is an **accessor macro**: a function-typed `var` can't be a stored property without an initializer, so it expands to a **computed property returning a closure** that -captures `self` and dispatches by name into the typed `emit`: +captures `self` **weakly** and dispatches by name into the typed `emit`: ```swift var onProgress: (ProgressEvent) -> Void { - { payload in self.emit(event: "onProgress", payload: payload) } + get { { [weak self] payload in self?.emit(event: "progress", payload: payload) } } +} +var onReady: () -> Void { + get { { [weak self] in self?.emit(event: "ready") } } // no-payload overload — not payload: .undefined } ``` -This is the **same on modules and shared objects**: both expose -`emit(event:payload:)` (`SharedObject.swift:102`; assumed added to -`Module` to match — see [Core dependencies](#core-dependencies)), which converts the -payload via `(~P.self).castToJS`. So a `@Event` payload can be **any `AnyArgument`** — -primitive, `@Record`, shared object, `@Union`, etc. — with no dict requirement, and the -no-payload case (`() -> Void`) dispatches `.undefined`. - -`@ExpoModule`/`@SharedObject` separately collect `@Event` member names and emit -`Events("onProgress", …)` into the synthesized definition, the same way they collect -`@JS` members — so JS knows the event exists and `OnStartObserving` works. +The weak capture matters when an author stores the closure (hands it to a delegate, +keeps it in a callback table): a strong capture would extend the module/shared-object +lifetime. After the emitter deallocates, the closure silently no-ops — the same behavior +`emit` already has once the runtime is gone. + +**Naming: the Swift property uses the `on` prefix; the JS wire name strips it.** The two +sides idiomatically want different names. In Swift the property reads as invoking a +handler (`self.onStatusChange(…)`) and the prefix keeps it from colliding with a state +property (`status`). In JS, module/shared-object events are listened to by **bare +names** — `addListener("statusChange")`, the Node/DOM idiom the newest team modules +follow (expo-video: `statusChange`, `playingChange`, `timeUpdate`, `playToEnd`). So the +default JS name is the Swift name with a leading `on` + capital stripped and the +remainder decapitalized, acronym-aware (`onStatusChange` → `statusChange`, +`onURLChange` → `urlChange`); names without the prefix (`online`, `statusChange`) pass +through verbatim. `@Event("name")` overrides verbatim with no transformation — that's +also the migration escape hatch for legacy `onX` *wire* names (`@Event("onContactsChange")`). +View events are different on purpose: a `@ViewProps` function-typed field *is* a React +handler prop, so it keeps its `onX` name untouched — a view event names a handler prop, +an `@Event` names an emitted event. The macro doesn't enforce the `on` prefix on the +Swift property (it's a convention; stripping simply doesn't fire without it). + +This is the **same on modules and shared objects**: core ships an **`EventEmitter` +protocol** (`Core/Events/EventEmitter.swift`) and conforms **both** base types to it — +`extension BaseModule: EventEmitter` (`Module.swift:28`) and `extension SharedObject: +EventEmitter` (`SharedObject.swift:81`) — so `self.emit(...)` resolves on each. The +protocol's extension supplies three `emit` overloads: `emit(event:)` (no payload), +`emit(event:payload: JavaScriptValue)` (pre-converted), and the typed +`emit(event:payload: sending P)`, which converts the payload via +`(~P.self).castToJS`. So a `@Event` payload can be **any `AnyArgument`** — primitive, +`@Record`, shared object, `@Union`, etc. — with no dict requirement; the no-payload case +(`() -> Void`) calls the dedicated `emit(event:)` overload, and the payload case passes +the closure's `payload` straight through (a fresh local, consumed once — satisfies +`sending`). **All of this now exists in core; module events are no longer gated.** + +**No `Events(...)` DSL.** `@Event` does not register event names into the synthesized +definition — `@ExpoModule`/`@SharedObject` ignore `@Event` members entirely; core +discovers events through another path. `@Event` is a **self-contained accessor macro** +with **zero touch-points** in the module/shared-object macros. + +**Not stamped `@JavaScriptActor` — an async event member (the default) stays +non-isolated.** `emit` is +itself non-isolated and `runtime.schedule`s onto the JS thread internally (it captures +the emitter `nonisolated(unsafe) weak`, since the emitter need not be `Sendable`). So an +author can fire `self.onProgress(...)` from **any** thread/isolation with no `await` at +the call site, and `emit` does the hop. Stamping `@JavaScriptActor` would *defeat* this — +it would force an actor hop at the call site for the common background-work caller. The +base classes (`open class BaseModule` / `open class SharedObject`) carry no actor +isolation, so an un-stamped `@Event` var is non-isolated by default; no `nonisolated` +keyword is needed in the expansion. This is the **one JS-related member that is +deliberately not `@JavaScriptActor`** — the opposite of `@JS func`/`@JS var`. The synthesized closure allocates per access — negligible for events (not a hot loop). -`@Event` members are stamped `@JavaScriptActor` like other JS members. + +**Diagnostics and compile-time assertions** (implemented in `EventMacro.swift`, PR #17): +- As a **peer**, `@Event` emits the same never-called conformance assertion as `@JS`, + checking both that the **payload type is `AnyArgument`** (skipped for primitives) and + that the **enclosing type conforms to `EventEmitter`** — so attaching `@Event` to a + type that can't emit fails with a clear conformance error on the user's declaration + instead of an opaque "no member 'emit'". +- `@Event let` errors with a **fix-it** replacing `let` with `var` (the property must be + computed; it's getter-only anyway, so nothing is lost). +- `@Event` + `@JS` on one property is rejected — an event is exposed to JS on its own. +- Shape validation: instance-only (no `static`), function type required (optional + function types rejected — an event is always present), `Void` return, at most one + payload parameter, no initializer, no author-written accessors. `@Sendable` and + parenthesized function types are unwrapped. + +> **Synchronous events: `@Event(sync: true)` — macro side implemented, gated on core +> `emitSync`.** The default `emit` always *schedules* (deferred dispatch). `sync: true` +> opts into dispatching **inline on the JS thread**: the synthesized closure calls +> **`emitSync`** instead of `emit`, and `@ExpoModule`/`@SharedObject` stamp that member +> `@JavaScriptActor` — making "must be on the JS thread" a compile-time guarantee rather +> than a runtime crash. That's the only case where a `@Event` member gets the stamp (the +> inverse of the async default). The macro work is done (`@Event(sync:)` parsing, the +> `emitSync` call, the stamping in both member macros); **core must still add the +> `@JavaScriptActor emitSync(event:)`/`emitSync(event:payload:)` overloads** (skip +> `runtime.schedule`, convert + `dispatchEvent` inline, swallow + warn on conversion +> failure to keep the closure type non-throwing). Until then the parameter is harmless — +> generated code references `emitSync` only when an author opts in. #### Module lifecycle @@ -410,10 +479,13 @@ module closure = call on real `self`; shared-object instance closure = receiver `this`. The decode/encode of the *other* arguments is identical in both. **Events** use the same `@Event` function-typed property as modules -([Module events](#module-events)); the synthesized closure dispatches into -`emit(event:payload:)` (`SharedObject.swift:102`). `@SharedObject` -collects `@Event` names into the class definition's `Events(…)`. (Modules and shared -objects share one events model — same `@Event`, same typed `emit`.) +([Module events](#module-events)); the synthesized closure dispatches into the +`EventEmitter` `emit` overloads (`SharedObject: EventEmitter`, `SharedObject.swift:81`). +On a shared object the `@Event` accessor's `self` *is* the concrete instance (the getter +runs on a real `Cache`, not inside `_decorateSharedObject`), so `self.emit(...)` works +directly. `@SharedObject` emits **no `Events(…)`** for `@Event` members and does not need +the stamp — the events model is identical to modules (same `@Event`, same non-isolated +`emit`, no `Events(...)` registration). For full parity, `@SharedObject` should also gain **property setters** (as modules do). @@ -990,10 +1062,10 @@ untyped runtime; no generic runtime type is required. is exactly the `AnyArgument` protocol: primitives, `Record`, `Convertible`, shared objects (`AnySharedObject: AnyArgument`), `UIView`, `JavaScriptObject`, arrays/typed-arrays, `Either`, `Promise`. `Conversions.anyToJavaScriptValue` already -casts any `AnyArgument` to JS via `getDynamicType().castToJS()`. Precedent: -`SharedObject.emit(event:payload:)` -(`SharedObjects/SharedObject.swift:102`) is already exactly a typed-payload event -send — modules/views just don't have an equivalent yet. +casts any `AnyArgument` to JS via `getDynamicType().castToJS()`. This is exactly the +`EventEmitter.emit(event:payload: sending P)` overload +(`Core/Events/EventEmitter.swift`), which core now provides on both `BaseModule` and +`SharedObject` via the `EventEmitter` conformance. **`EventDispatcher` is dropped from the macro's output.** It's a heap class found reflectively (`Mirror`) that only forwards to a `([String: Any]) -> Void` handler; @@ -1006,10 +1078,12 @@ the real emitters (`sendEvent`, `dispatchEvent`) take a dict directly. approach in `SwiftUIViewProps.setUpEvents` doesn't apply). Still a *reducing* change vs. the `EventDispatcher` indirection. See [Core dependencies](#core-dependencies). - **Modules & shared objects:** a `@Event` function-typed property; the macro (not core) - synthesizes a computed property whose closure dispatches by name into - `emit(event:payload:)` — the same typed call on both - (`SharedObject` has it; `Module` assumed to match). Names are collected by - `@ExpoModule`/`@SharedObject` into `Events(…)`. + synthesizes a computed property whose getter returns a closure that dispatches by name + into the `EventEmitter` `emit` overloads — the same typed call on both, now that core + conforms `BaseModule` and `SharedObject` to `EventEmitter`. The member is **not** + stamped `@JavaScriptActor` (it stays non-isolated; `emit` schedules onto the JS thread + itself), and the macro emits **no `Events(…)`** — `@Event` is self-contained, with no + touch-point in `@ExpoModule`/`@SharedObject`. ## Verified core DSL signatures @@ -1097,12 +1171,16 @@ SwiftUI's `View` bound on the latter. A `struct` props then fails to conf SwiftUI view → clean compiler error; no macro type-check needed. UIKit binds on the value protocol. (See [`@ViewProps`](#struct-or-class--the-conformance-carries-the-constraint).) -**4. Typed `Module.emit`.** Module events synthesize a call to -`emit(event:payload:)`, which `SharedObject` already has -(`SharedObject.swift:102`) but `Module`/`BaseModule` does not (it has only the -dict-based `sendEvent(_:_: [String: Any?])`). Add the same typed `emit` to `BaseModule` -so module and shared-object events share one mechanism and accept any `AnyArgument` -payload. (See [Module events](#module-events).) +**4. Typed `emit` on modules — ✅ done in core.** Module events synthesize a call to +`emit(event:payload:)`. Core has since added an **`EventEmitter` +protocol** (`Core/Events/EventEmitter.swift`) whose extension supplies the typed `emit` +(plus `emit(event:)` no-payload and `emit(event:payload: JavaScriptValue)`) and conformed +**both** base types — `extension BaseModule: EventEmitter` (`Module.swift:28`) and +`extension SharedObject: EventEmitter` (`SharedObject.swift:81`). So module and +shared-object events share one mechanism and `self.emit(...)` resolves on each; +**`@Event` is no longer gated on core.** Note the payload param is `sending P` and the +no-payload form is a dedicated overload (not `payload: .undefined`). (See +[Module events](#module-events).) **5. No special toolchain/language-mode requirement.** The generated code uses only plain identifiers (`_decorateModule`, `argN`) — no raw identifiers — so there's no @@ -1118,8 +1196,10 @@ caches `-load-plugin-executable` in-process; a clean build / DerivedData wipe do needs a core `StaticProperty` that decorates the constructor object. (See [Static vs. instance members](#static-vs-instance-members).) -Until these land, generated `@ViewProps`/`@ExpoView`/`@Event` code won't run; the macro -tests verify expansion shape only, not runtime. +Until these land, generated `@ViewProps`/`@ExpoView` code won't run; the macro +tests verify expansion shape only, not runtime. (`@Event` is the exception — its core +side, `EventEmitter` + the `BaseModule`/`SharedObject` conformances, has shipped, so +module/shared-object events can run against today's core.) ## Implementation plan @@ -1140,9 +1220,23 @@ before committing); each new impl struct → add to `providingMacros` in `Plugin 4. **`@ExpoView` macro.** New `ExpoViewMacro.swift`, `_synthesizedViewDefinition()`, `: ExpoView` check, props metatype arg, `Props(...)`/`View` wrapper, event-name gathering, `@ExpoModule(views:)` wiring. **Gated on the core contract.** -5. **`@SharedObject` parity** — class-level `Events` + property setters. -6. **Module + view events** — typed function-typed members/fields and their send - synthesis, once the events contract is pinned. +5. **`@SharedObject` parity** — property setters. (No `Events` collection — `@Event` + emits no `Events(…)`.) +6. **`@Event` (modules + shared objects) — ✅ implemented (PR #17)** (`EventMacro.swift`): an + `AccessorMacro` whose getter returns the weak-capturing dispatching closure + (`emit(event:payload:)` with a payload, `emit(event:)` without), plus a `PeerMacro` + conformance assertion (payload `AnyArgument` + enclosing type `EventEmitter`, one + merged helper naming the user's type via the lexical context), default-name + derivation stripping the `on` prefix (`onStatusChange` → JS `"statusChange"`), the + `@Event("name")` verbatim override, a `let`→`var` fix-it, `@JS`-exclusivity, full + shape validation, and `@Event(sync: true)` (selects `emitSync` + the + `@JavaScriptActor` stamp from both member macros). **Async events are ungated** — + core's `EventEmitter` conformances have shipped; `sync: true` is gated on core adding + `emitSync`. Remaining: the `@attached(accessor) @attached(peer, names: arbitrary) + macro Event(_ name: String? = nil, sync: Bool = false)` declaration in core's + `ExpoModulesMacros.swift`, the core `emitSync` overloads, and runtime verification in + a real module. **View events** + (`@ViewProps` function-typed fields) remain gated on the core view contract. ## Testing @@ -1170,8 +1264,13 @@ before committing); each new impl struct → add to `providingMacros` in `Plugin 4. **Discriminated unions** — `@Union` matches structurally in declaration order for v1; a tag-based mode (`@Union(discriminator: "type")`) for overlapping payload shapes is a deferred follow-up. Also: confirm `Union` is the right attribute name (vs. `JSUnion`). -5. **`@Event` name override** — JS event name comes from the property name; a per-event - override (`@Event("customName")`) if needed is a small follow-up. +5. **`@Event(sync:)` core contract** — the macro side is implemented (`sync: true` + selects `emitSync` and gets the `@JavaScriptActor` stamp from + `@ExpoModule`/`@SharedObject`), but the **core `emitSync` overloads don't exist yet**; + the parameter must stay unused until they land. Open core-side details: exact + signatures, and whether conversion failures throw or swallow + warn (the macro assumes + non-throwing). The `@Event("customName")` name override is in scope for v1 (verbatim, + no `on`-stripping). 6. **Phase 3 front end** — `expo-type-information` already parses Swift modules (via SourceKitten) and emits TS; phase 3 adapts it to the new attributes. Open: keep SourceKitten or move to SwiftSyntax for consistency with the macro toolchain. @@ -1184,8 +1283,22 @@ before committing); each new impl struct → add to `providingMacros` in `Plugin Resolved: events → typed payloads (`AnyArgument`), no `EventDispatcher`; view events fold into `@ViewProps` function-typed fields; **module & shared-object events are a -`@Event` function-typed property → macro synthesizes a computed closure dispatching into -the typed `emit` (same on both; needs `Module.emit` added to core)**; +`@Event` function-typed property → an accessor macro whose getter returns a +weak-capturing closure (`[weak self]`, so a stored closure can't extend the emitter's +lifetime) dispatching into the `EventEmitter` `emit` overloads (typed `emit

(event: +payload: sending P)` for a payload, `emit(event:)` for none). The JS wire name strips +the Swift property's `on` prefix (`onStatusChange` → `"statusChange"`, acronym-aware; +un-prefixed names verbatim; `@Event("name")` overrides verbatim — the escape hatch for +legacy `onX` wire names), matching the bare-name listener idiom of the newest modules +(expo-video) while the Swift side keeps the handler-style prefix; view events keep `onX` +untouched since a `@ViewProps` function-typed field is a React handler prop. Core already ships +`EventEmitter` and conforms both `BaseModule` and `SharedObject` to it, so this is +ungated. An async event is deliberately **not** stamped `@JavaScriptActor` — it stays +non-isolated so it's callable from any thread, and `emit` schedules onto the JS thread +itself; the macro emits **no `Events(…)`**. The synchronous opt-in is implemented too: +`@Event(sync: true)` → the closure calls `emitSync` (inline dispatch) and +`@ExpoModule`/`@SharedObject` stamp that member `@JavaScriptActor` (their only `@Event` +touch-point) — gated on core adding the `emitSync` overloads; unused until then**; module + view lifecycle → overridable instance methods (core-called); view props → single `@ViewProps` object (no `@Prop`), exposed as non-optional `self.props` (always current); `onViewPropsChanged(oldProps:)` gets only the previous props, typed `Props?` From ce9c2fa31cd28be145de8997d071e89b0f997fa4 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Sat, 13 Jun 2026 15:56:58 +0200 Subject: [PATCH 05/15] Doc: retire the Name DSL component from synthesized output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name is the last DSL element the macro emits (alone in _synthesizedDefinition now that members moved to _decorateModule). Document retiring it: - default case emits nothing — core's existing type-name fallback covers it (ModuleDefinition / ModuleHolder), no core change. - custom name (@ExpoModule("Bar")) → a synthesized _synthesizedModuleName static that core reads, feeding both native registration and JS __expo_module_name__ so they can't diverge (vs a JS-only write). - same for @SharedObject class names. Adds core dependency #7 and records the decision. --- docs/expo-modules-v2.md | 55 +++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index 95b710b..6bef181 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -131,12 +131,13 @@ discovering the JS surface from `@JS`-marked members. ### `@ExpoModule` -`@ExpoModule` / `@ExpoModule("CustomName")` on a module class. Synthesizes -`_synthesizedDefinition() -> [AnyDefinition]` from the module name plus the `@JS` -members in the body, and (when the class doesn't inherit `Module`/`BaseModule`) -the `appContext` storage, `init(appContext:)`, and the `AnyModule` conformance — -so inheriting from `Module` is optional. It also stamps `@ModuleDefinitionBuilder` -on a user-written `definition()`. +`@ExpoModule` / `@ExpoModule("CustomName")` on a module class. Binds the `@JS` members +directly into the module's JS object via `_decorateModule` (the phase-2 form, already +shipped), and (when the class doesn't inherit `Module`/`BaseModule`) synthesizes the +`appContext` storage, `init(appContext:)`, and the `AnyModule` conformance — so inheriting +from `Module` is optional. It also stamps `@ModuleDefinitionBuilder` on a user-written +`definition()`. The synthesized `_synthesizedDefinition()` is now (near-)empty — members +moved to `_decorateModule`, and `Name` is being retired (see below). ```swift @ExpoModule(views: [MyView.self]) @@ -158,6 +159,34 @@ Arguments: `MyView._synthesizedViewDefinition()` (see [Views](#views)). Read with the existing `classListArgument(of:label:"views")` `.self` parser. +#### Module name — retire the `Name` DSL component + +`Name(...)` is the last DSL element the macro still emits (functions/properties have moved +to `_decorateModule`; it sits alone in `_synthesizedDefinition()`). It's **redundant in the +common case**: core already resolves an absent name to the Swift type name — twice over, +in `ModuleDefinition` (name from `ModuleNameDefinition` else `String(describing: type)`, +`ModuleDefinition.swift:74–76`) and `ModuleHolder.name` (`ModuleHolder.swift:36`). + +**Decision: the macro never emits `Name`.** +- **No custom name** (`@ExpoModule`) — emit nothing; core's type-name fallback produces the + identical name. `_synthesizedDefinition()` becomes empty (or goes away entirely once + nothing else populates it). +- **Custom name** (`@ExpoModule("Bar")`) — convey it through a **non-DSL channel** that + feeds *both* native registration and the JS `__expo_module_name__`, so the two can't + diverge. The sound channel is a synthesized **`_synthesizedModuleName` static** (added as + an `AnyModule` requirement next to `_synthesizedDefinition()`), read by `ModuleDefinition` + in the same place it reads `ModuleNameDefinition` today. + - Rejected: setting `__expo_module_name__` only in `_decorateModule` — that names the JS + object but not native registration (`registry[holder.name]` keys on the + definition/type name), so the module would register natively under the type name while + reporting the custom name to JS. One source of truth, resolved at definition-build time, + avoids that. + +**Core dependency:** core reads `_synthesizedModuleName` (when present) for the module +name, retiring its reliance on `ModuleNameDefinition`. The same applies to `@SharedObject` +class names (`ClassDefinition` has the parallel `Name`/type-name fallback). See +[Core dependencies](#core-dependencies). + #### Property getter + setter > **Implemented (PR #14): `@JS var` binds directly via `defineProperty`, not the DSL.** The @@ -1196,6 +1225,13 @@ caches `-load-plugin-executable` in-process; a clean build / DerivedData wipe do needs a core `StaticProperty` that decorates the constructor object. (See [Static vs. instance members](#static-vs-instance-members).) +**7. Read the module name without `Name` (retire the DSL component).** To stop emitting +`Name(...)`, core should read a synthesized **`_synthesizedModuleName`** static (when the +author gave `@ExpoModule("Bar")`) in the same place `ModuleDefinition`/`ModuleHolder` +currently read `ModuleNameDefinition`, and otherwise keep the existing type-name fallback. +The default (no custom name) needs **no core change** — the fallback already applies. +Same for `@SharedObject` class names. (See [Module name](#module-name--retire-the-name-dsl-component).) + Until these land, generated `@ViewProps`/`@ExpoView` code won't run; the macro tests verify expansion shape only, not runtime. (`@Event` is the exception — its core side, `EventEmitter` + the `BaseModule`/`SharedObject` conformances, has shipped, so @@ -1333,7 +1369,12 @@ receiver from JS `this`; a static member's closure decorates the constructor and different JS objects. `static var` needs a core `StaticProperty`. Decoration is **class-level, once per class** — `static func _decorateSharedObject` sets up constructor (statics) + prototype (instance funcs *and* properties, receivers from `this`); no per-instance pass. A module decorates -its single object via an instance-method `_decorateModule` on real `self`**. +its single object via an instance-method `_decorateModule` on real `self`**. Module name: +**retire the `Name` DSL component — the macro never emits it. No custom name → core's existing +type-name fallback (`ModuleDefinition`/`ModuleHolder`) covers it, zero core change; custom name +(`@ExpoModule("Bar")`) → a synthesized `_synthesizedModuleName` static core reads (feeding both +native registration and JS `__expo_module_name__`, so they can't diverge), not a JS-only +`__expo_module_name__` write. Same for `@SharedObject` class names**. ## Further ideas From a69cbc149d9c850ed483caff8cce5191eed84d25 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Sat, 13 Jun 2026 16:03:13 +0200 Subject: [PATCH 06/15] Doc: module name via a static let, not a special channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify that the custom-name carrier is a plain static stored property — `static let _synthesizedModuleName = "Bar"` (compile-time literal) — satisfying a `static var … : String? { get }` AnyModule requirement that defaults to nil. No computed property or method needed. --- docs/expo-modules-v2.md | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index 6bef181..fa161ee 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -171,15 +171,21 @@ in `ModuleDefinition` (name from `ModuleNameDefinition` else `String(describing: - **No custom name** (`@ExpoModule`) — emit nothing; core's type-name fallback produces the identical name. `_synthesizedDefinition()` becomes empty (or goes away entirely once nothing else populates it). -- **Custom name** (`@ExpoModule("Bar")`) — convey it through a **non-DSL channel** that - feeds *both* native registration and the JS `__expo_module_name__`, so the two can't - diverge. The sound channel is a synthesized **`_synthesizedModuleName` static** (added as - an `AnyModule` requirement next to `_synthesizedDefinition()`), read by `ModuleDefinition` - in the same place it reads `ModuleNameDefinition` today. +- **Custom name** (`@ExpoModule("Bar")`) — convey it through a **simple static stored + property**: the macro synthesizes `public static let _synthesizedModuleName = "Bar"` (the + name is a compile-time string literal, so a stored `let` is all it needs — no computed + property or method). Core reads it where it reads `ModuleNameDefinition` today, feeding + *both* native registration and the JS `__expo_module_name__` so the two can't diverge. + - The `AnyModule` requirement is phrased as `static var _synthesizedModuleName: String? + { get }` with a **`nil` default** in the protocol extension — matching how + `_synthesizedDefinition()`/`_decorateModule(...)` carry no-op defaults, so non-macro + modules and the no-custom-name case fall straight through to the type-name fallback. + (Protocols express the requirement as a getter; the synthesized member is a plain + stored `let`.) - Rejected: setting `__expo_module_name__` only in `_decorateModule` — that names the JS object but not native registration (`registry[holder.name]` keys on the definition/type name), so the module would register natively under the type name while - reporting the custom name to JS. One source of truth, resolved at definition-build time, + reporting the custom name to JS. One source of truth, resolved before registration, avoids that. **Core dependency:** core reads `_synthesizedModuleName` (when present) for the module @@ -1226,11 +1232,12 @@ needs a core `StaticProperty` that decorates the constructor object. (See [Static vs. instance members](#static-vs-instance-members).) **7. Read the module name without `Name` (retire the DSL component).** To stop emitting -`Name(...)`, core should read a synthesized **`_synthesizedModuleName`** static (when the -author gave `@ExpoModule("Bar")`) in the same place `ModuleDefinition`/`ModuleHolder` -currently read `ModuleNameDefinition`, and otherwise keep the existing type-name fallback. -The default (no custom name) needs **no core change** — the fallback already applies. -Same for `@SharedObject` class names. (See [Module name](#module-name--retire-the-name-dsl-component).) +`Name(...)`, add a `static var _synthesizedModuleName: String? { get }` requirement to +`AnyModule` (default `nil`); the macro synthesizes a `static let _synthesizedModuleName = +"Bar"` only for `@ExpoModule("Bar")`. Core reads it where `ModuleDefinition`/`ModuleHolder` +read `ModuleNameDefinition` today, keeping the type-name fallback when it's `nil`. The +default (no custom name) needs **no core change** — the fallback already applies. Same for +`@SharedObject` class names. (See [Module name](#module-name--retire-the-name-dsl-component).) Until these land, generated `@ViewProps`/`@ExpoView` code won't run; the macro tests verify expansion shape only, not runtime. (`@Event` is the exception — its core @@ -1372,9 +1379,9 @@ per class** — `static func _decorateSharedObject` sets up constructor (statics its single object via an instance-method `_decorateModule` on real `self`**. Module name: **retire the `Name` DSL component — the macro never emits it. No custom name → core's existing type-name fallback (`ModuleDefinition`/`ModuleHolder`) covers it, zero core change; custom name -(`@ExpoModule("Bar")`) → a synthesized `_synthesizedModuleName` static core reads (feeding both -native registration and JS `__expo_module_name__`, so they can't diverge), not a JS-only -`__expo_module_name__` write. Same for `@SharedObject` class names**. +(`@ExpoModule("Bar")`) → a synthesized `static let _synthesizedModuleName = "Bar"` core reads +(feeding both native registration and JS `__expo_module_name__`, so they can't diverge), not a +JS-only `__expo_module_name__` write. Same for `@SharedObject` class names**. ## Further ideas From bb983bd976fac672c5ab90853af19a6088747c7f Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Sat, 13 Jun 2026 16:05:46 +0200 Subject: [PATCH 07/15] Doc: module name is always macro-resolved and non-optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macro knows the final name in every case (class name, or the @ExpoModule("Bar") arg), so it synthesizes a non-optional, fully-resolved `static let _synthesizedModuleName` for every module — core never runs String(describing:) for a macro module. Precedence: registration _name override → _synthesizedModuleName → type-name fallback (non-macro only). --- docs/expo-modules-v2.md | 85 +++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index fa161ee..e76f542 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -167,31 +167,39 @@ common case**: core already resolves an absent name to the Swift type name — t in `ModuleDefinition` (name from `ModuleNameDefinition` else `String(describing: type)`, `ModuleDefinition.swift:74–76`) and `ModuleHolder.name` (`ModuleHolder.swift:36`). -**Decision: the macro never emits `Name`.** -- **No custom name** (`@ExpoModule`) — emit nothing; core's type-name fallback produces the - identical name. `_synthesizedDefinition()` becomes empty (or goes away entirely once - nothing else populates it). -- **Custom name** (`@ExpoModule("Bar")`) — convey it through a **simple static stored - property**: the macro synthesizes `public static let _synthesizedModuleName = "Bar"` (the - name is a compile-time string literal, so a stored `let` is all it needs — no computed - property or method). Core reads it where it reads `ModuleNameDefinition` today, feeding - *both* native registration and the JS `__expo_module_name__` so the two can't diverge. - - The `AnyModule` requirement is phrased as `static var _synthesizedModuleName: String? - { get }` with a **`nil` default** in the protocol extension — matching how - `_synthesizedDefinition()`/`_decorateModule(...)` carry no-op defaults, so non-macro - modules and the no-custom-name case fall straight through to the type-name fallback. - (Protocols express the requirement as a getter; the synthesized member is a plain - stored `let`.) - - Rejected: setting `__expo_module_name__` only in `_decorateModule` — that names the JS - object but not native registration (`registry[holder.name]` keys on the - definition/type name), so the module would register natively under the type name while - reporting the custom name to JS. One source of truth, resolved before registration, - avoids that. - -**Core dependency:** core reads `_synthesizedModuleName` (when present) for the module -name, retiring its reliance on `ModuleNameDefinition`. The same applies to `@SharedObject` -class names (`ClassDefinition` has the parallel `Name`/type-name fallback). See -[Core dependencies](#core-dependencies). +**Decision: the macro never emits `Name`; it synthesizes the fully-resolved name as a +non-optional `static let`.** The macro already knows the final name in every case — +`jsNameArgument(of: node) ?? classDecl.name.text` (the exact expression that feeds today's +`Name(...)` default) — so it emits, always: + +```swift +public static let _synthesizedModuleName = "MyModule" // or "Bar" for @ExpoModule("Bar") +``` + +A plain stored constant (the value is a compile-time literal — no computed property, +method, or optional). Core reads it directly where it reads `ModuleNameDefinition` today, +feeding *both* native registration and the JS `__expo_module_name__` so the two can't +diverge. Because the name is **already resolved**, core's `String(describing: type)` +fallback never runs for a macro module — it stays only for hand-written DSL modules. + +- The `AnyModule` requirement is `static var _synthesizedModuleName: String? { get }` with + a **`nil` default** in the protocol extension (matching the no-op defaults on + `_synthesizedDefinition()`/`_decorateModule(...)`). `nil` means "not a macro module" → + core falls back to `String(describing:)`. A macro module always overrides it with a + non-`nil` `static let`, so the resolved member the macro emits is non-optional even + though the protocol slot is `String?`. +- **Precedence stays:** a registration-time override (`register(…, name:)` → + `ModuleHolder._name`) still wins over `_synthesizedModuleName`, which wins over the + type-name fallback. So: `_name` → `_synthesizedModuleName` → `String(describing:)`. +- Rejected: setting `__expo_module_name__` only in `_decorateModule` — that names the JS + object but not native registration (`registry[holder.name]` keys on the definition/type + name), so the module would register natively under the type name while reporting the + custom name to JS. One resolved source, read before registration, avoids that. + +**Core dependency:** core reads `_synthesizedModuleName` for the module name (when non-`nil`), +ahead of the type-name fallback and behind a registration `_name`. The same applies to +`@SharedObject` class names (`ClassDefinition` has the parallel `Name`/type-name fallback). +See [Core dependencies](#core-dependencies). #### Property getter + setter @@ -1231,13 +1239,15 @@ caches `-load-plugin-executable` in-process; a clean build / DerivedData wipe do needs a core `StaticProperty` that decorates the constructor object. (See [Static vs. instance members](#static-vs-instance-members).) -**7. Read the module name without `Name` (retire the DSL component).** To stop emitting -`Name(...)`, add a `static var _synthesizedModuleName: String? { get }` requirement to -`AnyModule` (default `nil`); the macro synthesizes a `static let _synthesizedModuleName = -"Bar"` only for `@ExpoModule("Bar")`. Core reads it where `ModuleDefinition`/`ModuleHolder` -read `ModuleNameDefinition` today, keeping the type-name fallback when it's `nil`. The -default (no custom name) needs **no core change** — the fallback already applies. Same for -`@SharedObject` class names. (See [Module name](#module-name--retire-the-name-dsl-component).) +**7. Read the module name without `Name` (retire the DSL component).** Add a +`static var _synthesizedModuleName: String? { get }` requirement to `AnyModule` (default +`nil`); the macro synthesizes a non-optional, **fully-resolved** `static let +_synthesizedModuleName = ""` for *every* `@ExpoModule` (the class name, or the +`@ExpoModule("Bar")` argument). Core reads it where `ModuleDefinition`/`ModuleHolder` read +`ModuleNameDefinition` today, ordered `_name` (registration override) → +`_synthesizedModuleName` → `String(describing:)` (the last only for non-macro modules, when +the property is `nil`). Same for `@SharedObject` class names. (See +[Module name](#module-name--retire-the-name-dsl-component).) Until these land, generated `@ViewProps`/`@ExpoView` code won't run; the macro tests verify expansion shape only, not runtime. (`@Event` is the exception — its core @@ -1377,11 +1387,12 @@ different JS objects. `static var` needs a core `StaticProperty`. Decoration is per class** — `static func _decorateSharedObject` sets up constructor (statics) + prototype (instance funcs *and* properties, receivers from `this`); no per-instance pass. A module decorates its single object via an instance-method `_decorateModule` on real `self`**. Module name: -**retire the `Name` DSL component — the macro never emits it. No custom name → core's existing -type-name fallback (`ModuleDefinition`/`ModuleHolder`) covers it, zero core change; custom name -(`@ExpoModule("Bar")`) → a synthesized `static let _synthesizedModuleName = "Bar"` core reads -(feeding both native registration and JS `__expo_module_name__`, so they can't diverge), not a -JS-only `__expo_module_name__` write. Same for `@SharedObject` class names**. +**retire the `Name` DSL component — the macro never emits it. Instead it synthesizes the +fully-resolved name (class name, or the `@ExpoModule("Bar")` arg) as a non-optional +`static let _synthesizedModuleName`, so core never runs `String(describing:)` for a macro +module. Core reads it (feeding both native registration and JS `__expo_module_name__`, so +they can't diverge), ordered `_name` override → `_synthesizedModuleName` → type-name +fallback. Same for `@SharedObject` class names**. ## Further ideas From 45b14178bae27ed4f7f103c4f82bfdd970e57319 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Sat, 13 Jun 2026 17:52:38 +0200 Subject: [PATCH 08/15] Sync v2 doc: module name synthesized as `_jsName` Update the module-name section to match what shipped: a non-optional `public static let _jsName` rather than the planned optional `_synthesizedModuleName` protocol requirement. Core distinguishes a macro module from a DSL one by presence of `_jsName`, not a `nil` sentinel, and the property is kind-neutral so `@SharedObject` class names can reuse it. --- docs/expo-modules-v2.md | 61 ++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index e76f542..72976a1 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -168,38 +168,37 @@ in `ModuleDefinition` (name from `ModuleNameDefinition` else `String(describing: `ModuleDefinition.swift:74–76`) and `ModuleHolder.name` (`ModuleHolder.swift:36`). **Decision: the macro never emits `Name`; it synthesizes the fully-resolved name as a -non-optional `static let`.** The macro already knows the final name in every case — -`jsNameArgument(of: node) ?? classDecl.name.text` (the exact expression that feeds today's -`Name(...)` default) — so it emits, always: +non-optional `static let`.** The macro already knows the final name in every case: +`jsNameArgument(of: node) ?? classDecl.name.text` (the exact expression that fed the old +`Name(...)` default). So it emits, always: ```swift -public static let _synthesizedModuleName = "MyModule" // or "Bar" for @ExpoModule("Bar") +public static let _jsName = "MyModule" // or "Bar" for @ExpoModule("Bar") ``` -A plain stored constant (the value is a compile-time literal — no computed property, +A plain stored constant (the value is a compile-time literal, not a computed property, method, or optional). Core reads it directly where it reads `ModuleNameDefinition` today, feeding *both* native registration and the JS `__expo_module_name__` so the two can't diverge. Because the name is **already resolved**, core's `String(describing: type)` -fallback never runs for a macro module — it stays only for hand-written DSL modules. - -- The `AnyModule` requirement is `static var _synthesizedModuleName: String? { get }` with - a **`nil` default** in the protocol extension (matching the no-op defaults on - `_synthesizedDefinition()`/`_decorateModule(...)`). `nil` means "not a macro module" → - core falls back to `String(describing:)`. A macro module always overrides it with a - non-`nil` `static let`, so the resolved member the macro emits is non-optional even - though the protocol slot is `String?`. -- **Precedence stays:** a registration-time override (`register(…, name:)` → - `ModuleHolder._name`) still wins over `_synthesizedModuleName`, which wins over the - type-name fallback. So: `_name` → `_synthesizedModuleName` → `String(describing:)`. -- Rejected: setting `__expo_module_name__` only in `_decorateModule` — that names the JS +fallback never runs for a macro module; it stays only for hand-written DSL modules. + +- `_jsName` is a plain `public static let` the macro emits. It carries no protocol + requirement and no optionality: core distinguishes a macro module from a DSL one by + whether the type provides `_jsName`, not by a `nil` sentinel. +- **Precedence stays:** a registration-time override (`register(…, name:)` to + `ModuleHolder._name`) still wins over `_jsName`, which wins over the type-name + fallback. So: `_name`, then `_jsName`, then `String(describing:)`. +- The name `_jsName` is kind-neutral (it describes a JS-facing name independent of what it + names), so `@SharedObject` class names can adopt the same property. +- Rejected: setting `__expo_module_name__` only in `_decorateModule`. That names the JS object but not native registration (`registry[holder.name]` keys on the definition/type name), so the module would register natively under the type name while reporting the custom name to JS. One resolved source, read before registration, avoids that. -**Core dependency:** core reads `_synthesizedModuleName` for the module name (when non-`nil`), -ahead of the type-name fallback and behind a registration `_name`. The same applies to -`@SharedObject` class names (`ClassDefinition` has the parallel `Name`/type-name fallback). -See [Core dependencies](#core-dependencies). +**Core dependency:** core reads `_jsName` for the module name, ahead of the type-name +fallback and behind a registration `_name`. The same applies to `@SharedObject` class names +(`ClassDefinition` has the parallel `Name`/type-name fallback). See +[Core dependencies](#core-dependencies). #### Property getter + setter @@ -1239,14 +1238,12 @@ caches `-load-plugin-executable` in-process; a clean build / DerivedData wipe do needs a core `StaticProperty` that decorates the constructor object. (See [Static vs. instance members](#static-vs-instance-members).) -**7. Read the module name without `Name` (retire the DSL component).** Add a -`static var _synthesizedModuleName: String? { get }` requirement to `AnyModule` (default -`nil`); the macro synthesizes a non-optional, **fully-resolved** `static let -_synthesizedModuleName = ""` for *every* `@ExpoModule` (the class name, or the -`@ExpoModule("Bar")` argument). Core reads it where `ModuleDefinition`/`ModuleHolder` read -`ModuleNameDefinition` today, ordered `_name` (registration override) → -`_synthesizedModuleName` → `String(describing:)` (the last only for non-macro modules, when -the property is `nil`). Same for `@SharedObject` class names. (See +**7. Read the module name without `Name` (retire the DSL component).** The macro synthesizes +a non-optional, **fully-resolved** `public static let _jsName = ""` for *every* +`@ExpoModule` (the class name, or the `@ExpoModule("Bar")` argument). Core reads it where +`ModuleDefinition`/`ModuleHolder` read `ModuleNameDefinition` today, ordered `_name` +(registration override), then `_jsName`, then `String(describing:)` (the last only for +non-macro modules, which don't provide `_jsName`). Same for `@SharedObject` class names. (See [Module name](#module-name--retire-the-name-dsl-component).) Until these land, generated `@ViewProps`/`@ExpoView` code won't run; the macro @@ -1387,11 +1384,11 @@ different JS objects. `static var` needs a core `StaticProperty`. Decoration is per class** — `static func _decorateSharedObject` sets up constructor (statics) + prototype (instance funcs *and* properties, receivers from `this`); no per-instance pass. A module decorates its single object via an instance-method `_decorateModule` on real `self`**. Module name: -**retire the `Name` DSL component — the macro never emits it. Instead it synthesizes the +**retire the `Name` DSL component, the macro never emits it. Instead it synthesizes the fully-resolved name (class name, or the `@ExpoModule("Bar")` arg) as a non-optional -`static let _synthesizedModuleName`, so core never runs `String(describing:)` for a macro +`public static let _jsName`, so core never runs `String(describing:)` for a macro module. Core reads it (feeding both native registration and JS `__expo_module_name__`, so -they can't diverge), ordered `_name` override → `_synthesizedModuleName` → type-name +they can't diverge), ordered `_name` override, then `_jsName`, then type-name fallback. Same for `@SharedObject` class names**. ## Further ideas From 8fc0e68de3779e19ed5b267977ef43e1732a875e Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Sun, 14 Jun 2026 00:29:53 +0200 Subject: [PATCH 09/15] Doc: clarify function argument validation; sync Phase 3 section Add an "Argument validation" subsection to Phase 2 spelling out the semantics the direct-JSI binding must reproduce from the DSL path: - Arity is a required..max range, not an exact count: a trailing run of `Optional` params may be omitted (`requiredArgumentsCount = total - trailingOptionalCount`), thrown as core's `InvalidArgsNumberException`. - `null`/`undefined`/omitted map by the param's static optionality (`nil` for `Optional`, `NullCastException` for a required non-optional). - Type mismatches throw a typed, index-bearing error wrapped like core's `ArgumentCastException`, surfacing as a sync throw or promise rejection. - Converters stay in core (single source of truth); the macro only selects which one to call, statically. Also flags the shipped/target gap: current codegen emits an exact `arguments.count ==` check and an ad-hoc `Exception`, which over-rejects an omitted trailing optional and diverges from the DSL error type. This sync also brings the repo copy up to date with the plans copy's Phase 3 (TypeScript generation) section, which had not been committed yet. --- docs/expo-modules-v2.md | 323 +++++++++++++++++++++++++++++++++++----- 1 file changed, 289 insertions(+), 34 deletions(-) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index 72976a1..3f11985 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -93,7 +93,10 @@ question of whether `@JS struct`/`@JS enum` should be an error redirecting to `@OptimizedFunction` is dropped. 3. **[TypeScript type generation](#phase-3--typescript-type-generation)** — generate the module's `.d.ts` (functions, records, view props + event callbacks) from the - annotated Swift source, so JS types always match native. + annotated Swift source, so JS types always match native. A **SwiftSyntax extractor** + reads the declarations (types are written on the `@JS`/`@Record`/`@ViewProps` + declaration, so no type resolution is needed) and feeds `expo-type-information`'s + existing TS emitter, replacing its SourceKitten front end. Phase 1 produces a correct, complete surface on the existing runtime; phase 2 swaps the *implementation strategy* underneath the same author-facing API for speed; phase 3 @@ -905,9 +908,12 @@ public func _decorateModule(object: borrowing JavaScriptObject, guard let appContext else { throw Exceptions.AppContextLost() } - // 1. static arity check — count known at expansion time + // 1. static arity check. The required and maximum counts are both known at expansion time + // (here both 2 — neither param is optional), so this is a literal range check, no + // definition lookup. With trailing optionals the lower bound drops (see Argument + // validation, below). Mirrors core's `validateArgumentsNumber` / `InvalidArgsNumberException`. guard arguments.count == 2 else { - throw Exception(name: "InvalidArgumentCount", description: "Function 'add' expects 2 argument(s), but got \(arguments.count)") + throw InvalidArgsNumberException((received: arguments.count, expected: 2, required: 2)) } // 2. per-argument decode by static type — no [Any], no toTuple. Primitives use a direct // typed accessor (`asDouble()`, validating + throwing); other types fall back to @@ -995,6 +1001,61 @@ Notes: ownership, so it borrows rather than `inout` (no rebinding) or `consuming` (caller keeps using it). Same convention core uses (`ObjectDefinition.swift:97`). +### Argument validation + +The generated binding must reproduce the **same validation semantics** the DSL path gives +today, just emitted inline instead of derived at call time. Today arity is validated +centrally (`validateArgumentsNumber`, `JavaScriptUtils.swift:12`) and per-argument type +conversion is decentralized across the dynamic-type converters (coordinated by +`MainValueConverter`, wrapping failures in `ArgumentCastException`). The macro keeps that +behavior; it just hoists the arity check into the closure and selects each converter +statically. The four pieces: + +**1. Arity is a range, not an exact count.** A trailing run of `Optional` parameters may +be omitted, so core computes a **required** count (total minus the number of *trailing* +optionals) and a **maximum** (total), then rejects `received < required || received > max` +(`SyncFunctionDefinition.swift:55–76`, `JavaScriptUtils.swift:16`). The macro knows both +numbers at expansion time and emits the same range check. A non-trailing optional does +**not** lower the required count — only a contiguous trailing run does, because arguments +are positional. The example above checks `== 2` only because both params are required +(required == max); for `@JS func f(a: Int, b: String? = nil)` it would emit +`guard (1...2).contains(arguments.count)`. The thrown error is core's +`InvalidArgsNumberException((received:expected:required:))`, not an ad-hoc `Exception`. + +> **Gap (shipped vs. target).** The current codegen (`DecorateModuleBuilder.swift:47`) +> emits an **exact** `arguments.count == ` check and throws an ad-hoc +> `Exception(name: "InvalidArgumentCount", …)`. That over-rejects a legitimately-omitted +> trailing optional and diverges from the DSL'"'"'s error type. Moving to the required/max +> range and `InvalidArgsNumberException` is part of completing phase 2. + +**2. `null` / `undefined` map by the parameter's static optionality.** Both JS `null` and +`undefined` decode to `nil` for an `Optional` parameter (`DynamicOptionalType.swift:26–31`), +and to a thrown `NullCastException` for a required non-optional one +(`Conversions.swift:328`, e.g. `DynamicBoolType.swift:17`). An **omitted** trailing argument +(allowed by the arity range) is treated as `nil` — the same as passing `undefined` — so it +only type-checks when the Swift param is optional. The macro decodes optional params through +the dynamic-optional converter (which already encodes this), so it inherits the behavior +rather than re-implementing the `null`/`undefined`/missing trichotomy. + +**3. Type mismatches throw a typed, index-bearing error.** A primitive fast-path accessor +(`asInt()`/`asDouble()`/`asString()`/`asBool()`) throws a conversion error on a kind mismatch +(`Conversions.swift:251`); the dynamic fallback throws `CastingException` / +`ConversionToNativeFailedException` (`Conversions.swift:275`). To match the DSL'"'"'s diagnostics, +each generated decode is wrapped so the failure carries the **argument index** and expected +type, the way core'"'"'s `ArgumentCastException((index:type:))` does (`JavaScriptUtils.swift:63`). +Because the decode runs inside the host-function closure, the throw propagates out as the +function'"'"'s result: a **sync** function surfaces it as a thrown JS error, an **async** one as a +promise rejection (the closure'"'"'s `async`-ness selects the rejecting overload). The first +failing argument short-circuits the rest. + +**4. What'"'"'s inline vs. borrowed from core.** Arity and the encode/decode calls are emitted +**inline** in the closure (that'"'"'s the point of phase 2: no per-call definition walk). The +*converters themselves* are not re-implemented — primitives use the validating +`JavaScriptValue` accessors and everything else calls the existing public +`T.getDynamicType().cast(...)`, so the per-type validation rules (and their error types) +stay in core and remain the single source of truth. The macro chooses *which* converter to +call statically; core still defines *what* each converter accepts. + ### Proof of concept: `@OptimizedFunction` `@OptimizedFunction` (`ExpoModulesOptimizedMacro.swift`, @@ -1059,34 +1120,224 @@ runtime conformance — it doesn't need the code to compile or link. Open design how view-props events map to TS callback signatures (payload `Record` → TS object), how `AnyArgument` payload types resolve to TS, and optional/required field mapping. -### Reuse `expo-type-information` - -**The pipeline already exists** — the `expo-type-information` package -(`packages/expo-type-information`) parses Swift Expo modules and emits TypeScript today. -Phase 3 is **adapting it to the new macro surface**, not building a generator from -scratch. - -What it already does: -- **Parses Swift modules for type info via `sourcekitten`** (SourceKit-backed, so it has - *resolved* types, not just syntax; macOS-only, needs the `sourcekitten` tool). -- **Emits TypeScript** — types, wrapper functions, and mocks - (`typescriptGeneration.ts`, `mockgen.ts`). -- Ships CLI commands that already line up with our surface: - `inlineModulesInterfaceCommand`, `moduleInterfaceCommand`, `generateModuleTypesCommand`, - `generateViewTypesCommand`, and `generateJSXIntrinsicsCommand` (view JSX intrinsics ≈ - our `@ViewProps`). - -So phase 3 work is mostly **teaching it the new attributes** — `@JS` (incl. `async` → -`Promise`), `@Record`/field-by-default + requiredness inference, `@ViewProps` (value -props vs. function-typed events), `@ExpoView`, `@Union`, `@Event` — and mapping each to -the right TS shape (e.g. `@ViewProps` → component-props type with event callbacks; -`@Union` → `A | B | C`; optional/default → optional TS field). - -Note: it uses **SourceKitten**, not SwiftSyntax (which the phase-1/2 macros and the -sandbox discussion assume). That's fine for an external tool — SourceKitten gives -resolved types, which can be *more* than enough — but it's a different front end than the -macros use. Open: whether to keep SourceKitten or move the parser to SwiftSyntax for -consistency with the macro toolchain. +### Approach: a SwiftSyntax extractor feeding `expo-type-information`'s emitter + +**Decision: replace the front end (parser), keep the back end (TS emitter).** The +`expo-type-information` package (`packages/expo-type-information`) is two cleanly +separated halves joined by one model: + +- **Front end** — `swift/sourcekittenTypeInformation.ts` shells out to the `sourcekitten` + CLI (`sourcekitten structure --file`, plus `source.request.cursorinfo` byte-offset + requests) and folds the result into a typed model in `typeInformation.ts` + (`Type`/`RecordType`/`EnumType`/`ModuleClassDeclaration`/`FunctionDeclaration`/…). +- **Back end** — `typescriptGeneration.ts` consumes that model through a + `GenerationContext { fileInfo, module, view, missingTypes }` (`typescriptGeneration.ts:57`) + and emits `.d.ts` via the TypeScript factory API; `mockgen.ts` emits mocks. It never + touches SourceKitten — it only sees the model. The CLI commands + (`generateModuleTypesCommand`, `generateViewTypesCommand`, `generateJSXIntrinsicsCommand`, + `moduleInterfaceCommand`, …) sit on top. + +Phase 3 swaps the **front end** for a SwiftSyntax extractor and reuses the back end +verbatim. The seam is the `FileTypeInformation` model (`typeInformation.ts:255`): produce +that, and the entire emitter + CLI + mock pipeline works unchanged. + +**Why SwiftSyntax, not SourceKit, for the v2 surface.** The current SourceKitten front +end exists to buy *type resolution* the DSL doesn't write down. `Property("ready") { +self.ready }` has no annotation anywhere — the type only exists after `self.ready` is +resolved — so the tool falls back to a `cursorinfo` request at a byte offset, which its +own comments flag as *"extremely slow and inefficient"* / *"really costly"* +(`sourcekittenTypeInformation.ts:315,352`). It even ships a `PREPROCESS_AND_INFERENCE` +mode (`typeInformation.ts:352`) that **rewrites the source to inject `return`s** so +SourceKit will report a closure's type. All of that is a workaround for *types living +inside result-builder closures*. + +The v2 macro surface puts the type back **on the declaration**, by design ("the signature +is the contract"): + +```swift +@JS func add(a: Double, b: Double) -> Double { a + b } // params + return are written +@JS var ready: Bool = false // type written +@Record struct Options { var name: String; var count: Int = 0 } // fields written +@Union enum Media { case url(URL); case data(Data) } // case payloads written +``` + +Every type the generator needs is now **lexically present**, so the expensive thing +SourceKit provided is no longer required for the common case. That flips the front-end +choice: + +- **One front end for the whole toolchain.** The phase-1/2 macros are already SwiftSyntax + (`ExpoModulesMacros` depends on `swift-syntax` 602.x; `JSMacro.swift`, `RecordMacro.swift`, + `EventMacro.swift`, `MacroHelpers.swift`). A SwiftSyntax extractor reuses the *same* + classification logic the macros use — "is this property an event or a value prop?" is the + same `member.type.is(FunctionTypeSyntax.self)` check `@ViewProps` makes at expansion time — + instead of maintaining a second, divergent parser. +- **No SDK / compile / cursor dependency.** SourceKitten needs `sourcekitd`, an SDK path, and + per-file compiler args (`-sdk`, `-target arm64-apple-ios7` are hardcoded, + `sourcekittenTypeInformation.ts:337`). SwiftSyntax parsing needs *only the source text* — + exactly what the plan already wants ("doesn't need the code to compile or link"). It runs on + Linux/CI, not just macOS. +- **Faster and deterministic.** The slow `cursorinfo`/preprocess path disappears because there's + nothing to resolve. Parsing a package's module files is milliseconds and byte-identical every + run (so the golden-file tests stay stable). + +**Shape of the extractor.** A small **SwiftPM executable** (`ExpoTypeGen`) lives in the +macros package alongside the macro target, depends on `SwiftSyntax` + `SwiftParser`, and +shares the `@JS`/`@ViewProps`/`@Record` classification helpers with the macro target (one +source of truth for "what is an event," "what is a field," requiredness inference). It +walks each file's syntax and emits the **`FileTypeInformationSerialized` JSON** +(`typeInformation.ts:239`, already a defined wire format with +`serialize`/`deserializeTypeInformation`). `expo-type-information` shells out to that +binary instead of `sourcekitten`, `deserializeTypeInformation`s the JSON, and runs the +existing emitter. Same packaging story as the committed macro plugin binary +(`ExpoModulesMacros-tool`): ship a prebuilt `ExpoTypeGen` the JS package invokes. + +Mapping each attribute onto the existing model (no new emitter concepts needed): +`@JS func`/`async` → `FunctionDeclaration`/`asyncFunctions` (async → `Promise<…>`, already +handled by the `asyncModifier` path); `@Record`/`@ViewProps` value fields → `RecordType` + +`Field` with requiredness from the declaration (default value / `?`); `@ViewProps` +function-typed fields → the `props`/event-callback path (`PropDeclaration`); `@Union` → +`SumType` (`A | B | C`); `@Event` → the module `events` list + a callback type from its +payload; `@ExpoView` → the view/JSX-intrinsics path (`generateViewTypesCommand` / +`generateJSXIntrinsicsCommand`). + +### The inference gap, and why it's small + +SwiftSyntax sees only *what's written*. It cannot resolve a type the author left to the +compiler. The cases, and how each is handled: + +- **`func` with no `->` is `Void` — not a gap.** Swift does **not** infer a named + function's return type from its body. `@JS func add(...) { a + b }` returns `Void` (the + `a + b` is a discarded expression — `warning: result of operator '+' is unused`), and + `@JS func add(...) { return a + b }` is a hard **compile error** (`error: unexpected + non-void return value in void function`), so it never reaches the generator. + Single-expression *return* inference is a **closure-only** feature; it never applies to a + `func`. So the absence of `->` is unambiguously `Void` in SwiftSyntax — no resolution + needed. (This is the one place the old tool's preprocess-inject-`return` trick was load-bearing + for the *DSL*, because there the body **was** a closure; it's moot for `@JS func`.) +- **Un-annotated property initializers — the real gap.** `@JS var ready = false`, + `@Record`/`@ViewProps` `var name = makeDefault()`, `var tags = ["a", "b"]` have the type + only in the initializer. **Decision: require an explicit type at the JS boundary** — a + `@JS`/`@Record`/`@ViewProps` stored property without a `typeAnnotation` is a **macro + diagnostic with a fix-it** ("annotate the type: `var ready: Bool = false`"). This is cheap + (the macros already emit diagnostics, e.g. `@Event let`→`var`), keeps the generator + pure-syntax with no SDK/compile dependency, and only constrains the *exported* surface — + where being explicit is reasonable anyway and is consistent with the plan already + legislating requiredness from the declaration shape. A literal-initializer fallback + (`= false`→`Bool`, `= "x"`→`String`, `= 0`→`Int`) can resolve the easy cases *if* we'd + rather not diagnose, but `= someCall()` is unrecoverable, so the diagnostic is the primary + path and literal resolution at most a convenience. +- **Cross-file type identity — resolved by name, not by a type checker.** Knowing a + `@Union` case payload `MediaConfig` is a `@Record` defined in another file is the same + problem the tool already solves: it builds a `typeIdentifierDefinitionMap` + (`typeInformation.ts:276`) and tracks `missingTypes` (`GenerationContext.missingTypes`). + Run the extractor over all files first to collect every `@Record`/`@Union`/`@SharedObject` + name, then resolve references by exact name in a second pass — no resolution needed, + because the names match exactly. + +**SourceKit as an optional last resort, not the default.** If we decide *not* to diagnose +un-annotated properties, the existing SourceKitten `cursorinfo` path can stay as a fallback +*scoped to just the un-annotated remainder* — but it reintroduces the SDK/compile +dependency this phase avoids, so the diagnostic-first path is preferred and SourceKit is +kept (if at all) only behind a flag. + +### Staging + +1. **Extractor MVP.** `ExpoTypeGen` SwiftPM executable in the macros package; walk + functions + records, emit `FileTypeInformationSerialized` JSON for a single file; golden + test against the existing model shape. +2. **Wire into `expo-type-information`.** Add a SwiftSyntax-backed front end that shells out + to `ExpoTypeGen` and `deserializeTypeInformation`s the JSON, selectable alongside the + SourceKitten one; run the *existing* emitter unchanged. Prove the `.d.ts` matches on a + real module. +3. **Full attribute coverage.** `@ViewProps` (value props vs. function-typed events), + `@ExpoView`/JSX intrinsics, `@Union`, `@Event` payload callbacks, `async` → `Promise`, + requiredness inference. Add the un-annotated-property diagnostic to the macros. +4. **Cut over.** Make the SwiftSyntax front end the default; retire + `sourcekittenTypeInformation.ts` and the `cursorinfo`/`PREPROCESS_AND_INFERENCE` path + (or demote SourceKit to an optional fallback per above). + +### When generation runs, and the source of truth + +`expo-type-information` is a **standalone CLI today, invoked by nothing in the build** — +no autolinking step, prebuild hook, or `et` command runs it; it's a manual/experimental +generator. Phase 3 has to decide where it plugs in, because that choice shapes packaging, +CI, and drift handling. Two models: + +- **Committed output (preferred).** `.d.ts` is generated and **checked in** next to the + module, exactly like the macro plugin's build output and the JS `build/` output this repo + already commits (`et check-packages` rebuilds and stages it). Authors and consumers never + need the Swift toolchain to *use* a module; only re-generating needs it. Fits the existing + "stage source + regenerated output together" discipline. +- **Generate-at-build.** A prebuild/autolinking step runs `ExpoTypeGen` and writes `.d.ts` + into a build dir. Always fresh, but couples every consumer build to the Swift toolchain + + `ExpoTypeGen` binary and complicates Metro/TS resolution. Heavier; not preferred for v1. + +**Lean: committed output**, regenerated by an `et` command (e.g. folded into +`et check-packages` for modules that opt in), matching how built JS lands in `build/`. + +### Drift enforcement + +If `.d.ts` is committed it can rot against the Swift source. Guard it the same way the JS +`build/` output is guarded: a **CI check regenerates and diffs** — fail if the working tree +`.d.ts` differs from a fresh `ExpoTypeGen` run (`et check-packages`-style "you forgot to +rebuild"). Determinism is what makes this viable: SwiftSyntax parsing is byte-identical per +run (unlike the old async SourceKitten path, which sorted by `definitionOffset` to *recover* +determinism — `DefinitionOffset`, `typeInformation.ts:153`), so the diff is signal, not +noise. + +### The hand-written-wrapper boundary + +Generated `.d.ts` types the **native surface** — what `requireNativeModule("Foo")` returns. +Real modules then hand-write a TS `index.ts` that wraps that surface for JS ergonomics +(re-exports, defaults, convenience overloads). **Decision: Phase 3 generates the native +interface (the `requireNativeModule` return type + record/union/view-props types) that +authors import and re-export/wrap; it does not replace the hand-written wrapper.** The tool +already distinguishes these (`moduleInterfaceCommand` / `inlineModulesInterfaceCommand` / +`generateModuleTypesCommand`); v1 targets the typed native interface, leaving the wrapper to +the author. Generating or linting the wrapper is a follow-up (see Further ideas). + +### Platform-neutral model (Android/KSP parity) + +The `FileTypeInformation` model is **platform-neutral** — it describes the JS-facing surface +(functions, records, unions, view props, events), not Swift specifics. So the Android KSP +path (`expo-modules-v2-android.md`) can emit the *same* JSON model from Kotlin annotations and feed the +*same* TS emitter, giving one set of `.d.ts` for both platforms. Phase 3 ships Apple-only, +but the extractor's output contract (`FileTypeInformationSerialized`) is the shared +interface — keep it free of Swift-only concepts so KSP can target it unchanged. + +### `AnyArgument` → TypeScript mapping + +The non-obvious cases the extractor must map (primitives are trivial; these are the ones +that bite). Mirrors the requiredness tables above: + +| Swift | TypeScript | Note | +|---|---|---| +| `String` / `Bool` / `Int` / `Double` | `string` / `boolean` / `number` | `Int` and `Double` both → `number` | +| `T?` | `T \| null` | optional *and* nullable (see requiredness tables) | +| `[T]` | `T[]` | | +| `[K: V]` | `Record` | | +| `Data` / `Uint8Array` typed arrays | `Uint8Array` | `ArrayBuffer` for raw `Data` — confirm against core's converter | +| `@Record S` | `S` (generated object type) | fields with inferred requiredness | +| `@Union enum` / `Either` | `A \| B \| C` | from case payloads / type args | +| `@SharedObject C` | `C` (generated class type) | reference type, by name | +| `JavaScriptObject` / `JavaScriptValue` | `object` / `unknown` | escape hatch; confirm | +| `JavaScriptFunction` / `() -> Void` | function type | `@Event`/view-callback payloads → typed callback | +| `Promise` / `async ->` | `Promise` | async return wrapping | +| `URL` | `string` | | +| `Date` | `Date` or `string`? | **open** — confirm against core's `Date` converter | + +Open: `Data` (`Uint8Array` vs `ArrayBuffer`) and `Date` (`Date` vs ISO `string`) must match +what core's converters actually produce at runtime — pin against the converter, not guessed. + +### Verification + +Matching the discipline of phases 1–2 (expansion-shape tests don't prove integration): +**generate `.d.ts` for existing real modules — expo-video, expo-image — and diff against +their hand-written types.** Those hand-maintained types are the ground truth; a generated +file that matches (modulo wrapper ergonomics) proves the extractor + mapping are correct on a +real surface, not just fixtures. State this in the PR. Unit-level: golden-file tests on the +`FileTypeInformationSerialized` JSON per attribute (the existing test style, +`tests/typeInformation.test.ts`). --- @@ -1321,9 +1572,13 @@ before committing); each new impl struct → add to `providingMacros` in `Plugin signatures, and whether conversion failures throw or swallow + warn (the macro assumes non-throwing). The `@Event("customName")` name override is in scope for v1 (verbatim, no `on`-stripping). -6. **Phase 3 front end** — `expo-type-information` already parses Swift modules (via - SourceKitten) and emits TS; phase 3 adapts it to the new attributes. Open: keep - SourceKitten or move to SwiftSyntax for consistency with the macro toolchain. +6. **Phase 3 front end — decided: SwiftSyntax extractor (`ExpoTypeGen`) feeding + `expo-type-information`'s existing TS emitter** (the model at `typeInformation.ts:255` + is the seam; the back end is reused verbatim). SourceKitten is retired (or demoted to an + optional fallback for un-annotated properties). Remaining open: whether to fully drop the + `cursorinfo`/`PREPROCESS_AND_INFERENCE` path or keep it behind a flag, and whether an + un-annotated `@JS`/`@Record`/`@ViewProps` property is a hard diagnostic or gets a + literal-initializer fallback. See [Phase 3](#approach-a-swiftsyntax-extractor-feeding-expo-type-informations-emitter). 7. **`@JS struct` / `@JS enum`** — `@JS` is member-only today (funcs/properties/inits); a nested `struct`/`enum` is a silent no-op. Either **error and redirect** to `@Record`/`@Union`, or **accept as an alias** for them (the JavaScriptKit-familiar From 33f10a29e5ab8659e4450a088f49ca147040fb33 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Sun, 14 Jun 2026 00:36:36 +0200 Subject: [PATCH 10/15] Doc: macro honors arg default values, widening arity validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DSL binds a closure, which can't carry Swift default values, so core's only notion of an omittable arg is `Optional` (omitted ⇒ `nil`). The macro binds the real function, so a trailing param is omittable if it's defaulted OR optional: `required = total − trailing-run-of-(defaulted-or- optional)`. An omitted defaulted slot is left out of the `self.f(...)` call so Swift applies the default; an omitted optional slot passes `nil`. Defaults force per-arity call shapes (branch on `arguments.count`), since a maybe-present arg can't thread through one static call expression; pure `Optional` ranges don't. An explicit in-range `null`/`undefined` still decodes through the converter — only true omission triggers a default. Update the shipped-vs-target gap note: current codegen does exact-count and ignores defaults entirely. --- docs/expo-modules-v2.md | 72 +++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index 3f11985..596c47a 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -1011,31 +1011,63 @@ conversion is decentralized across the dynamic-type converters (coordinated by behavior; it just hoists the arity check into the closure and selects each converter statically. The four pieces: -**1. Arity is a range, not an exact count.** A trailing run of `Optional` parameters may -be omitted, so core computes a **required** count (total minus the number of *trailing* -optionals) and a **maximum** (total), then rejects `received < required || received > max` +**1. Arity is a range, not an exact count — and the macro widens that range past what the +DSL can express.** A trailing run of *omittable* parameters may be left off, so core computes +a **required** count (total minus the number of trailing omittable params) and a **maximum** +(total), then rejects `received < required || received > max` (`SyncFunctionDefinition.swift:55–76`, `JavaScriptUtils.swift:16`). The macro knows both -numbers at expansion time and emits the same range check. A non-trailing optional does -**not** lower the required count — only a contiguous trailing run does, because arguments -are positional. The example above checks `== 2` only because both params are required -(required == max); for `@JS func f(a: Int, b: String? = nil)` it would emit -`guard (1...2).contains(arguments.count)`. The thrown error is core's -`InvalidArgsNumberException((received:expected:required:))`, not an ad-hoc `Exception`. - -> **Gap (shipped vs. target).** The current codegen (`DecorateModuleBuilder.swift:47`) -> emits an **exact** `arguments.count == ` check and throws an ad-hoc -> `Exception(name: "InvalidArgumentCount", …)`. That over-rejects a legitimately-omitted -> trailing optional and diverges from the DSL'"'"'s error type. Moving to the required/max -> range and `InvalidArgsNumberException` is part of completing phase 2. +numbers at expansion time and emits the same range check, returning core's +`InvalidArgsNumberException((received:expected:required:))` rather than an ad-hoc `Exception`. + +What counts as **omittable** is where the macro gains over the DSL. The DSL binds a *closure*, +and a closure parameter can't carry a Swift default value, so core's only notion of "optional" +is `Optional` (an omitted slot becomes `nil`). The macro binds the **real function**, so it +sees the declaration and can honor **both**: + +| trailing parameter | omitted ⇒ | how the call is emitted | +| --- | --- | --- | +| has a default value (`b: Int = 5`) | Swift applies the default | the arg is **left out of the `self.f(...)` call** | +| `Optional`, no default (`b: Int?`) | `nil` | `nil` (or the decoded value) is passed | +| required (`b: Int`) | not omittable | always decoded and passed | + +So `required = total − (length of the maximal trailing run of params that are defaulted **or** +optional)`. A non-omittable param part-way through stops the run: `f(a: Int = 1, b: Int)` has +`required == 2` (the trailing `b` is required, so `a` can't be dropped either — args are +positional). `f(a: Int, b: String? = nil)` emits `guard (1...2).contains(arguments.count)`; +`f(a: Int, b: Int = 5, c: String? = nil)` emits `guard (1...3).contains(...)`, and at +`received == 1` it calls `self.f(a: arg0)` (Swift fills `b`, `c` is its own `nil` default). + +The cost: a defaulted arg can't be threaded through one static call expression — when it may +or may not be present, the binding **branches on `arguments.count`** and emits one +`self.f(...)` shape per valid arity (decoding only the args that are present in that branch). +Pure-`Optional` ranges don't need this (the omitted slot is just `nil`); only **defaults** +force the per-arity call shapes. This is the macro doing at compile time what a closure +fundamentally cannot: respecting author-written default expressions instead of forcing every +optional-with-a-default down to `nil`. + +> **Gap (shipped vs. target).** The current codegen (`DecorateModuleBuilder.swift:47–70`) +> emits an **exact** `arguments.count == ` check, throws an ad-hoc +> `Exception(name: "InvalidArgumentCount", …)`, and unconditionally decodes and passes every +> `arg`. So it over-rejects an omitted trailing optional, ignores author-written default +> values entirely, and diverges from the DSL'"'"'s error type. Moving to the required/max range +> (with the default-aware per-arity call shapes above) and `InvalidArgsNumberException` is part +> of completing phase 2. **2. `null` / `undefined` map by the parameter's static optionality.** Both JS `null` and `undefined` decode to `nil` for an `Optional` parameter (`DynamicOptionalType.swift:26–31`), and to a thrown `NullCastException` for a required non-optional one -(`Conversions.swift:328`, e.g. `DynamicBoolType.swift:17`). An **omitted** trailing argument -(allowed by the arity range) is treated as `nil` — the same as passing `undefined` — so it -only type-checks when the Swift param is optional. The macro decodes optional params through -the dynamic-optional converter (which already encodes this), so it inherits the behavior -rather than re-implementing the `null`/`undefined`/missing trichotomy. +(`Conversions.swift:328`, e.g. `DynamicBoolType.swift:17`). The macro decodes optional params +through the dynamic-optional converter (which already encodes this), so it inherits that +behavior rather than re-implementing the `null`/`undefined`/missing trichotomy. + +**Omission** (a trailing slot the arity range allows to be absent) is resolved by point 1, not +here: a defaulted param's slot is left out of the call so Swift applies the default; an +optional-no-default param's slot becomes `nil`. The two differ only for an *absent* arg — a +slot **present** as JS `null`/`undefined` always decodes through the converter (so `null` → +`nil` for an optional, `null` → throw for a required non-optional, regardless of any default). +We do **not** treat an explicit in-range `undefined` as "use the Swift default"; only true +omission (a shorter `arguments.count`) triggers a default, matching positional-argument +semantics and keeping the per-arity branch the single place defaults are applied. **3. Type mismatches throw a typed, index-bearing error.** A primitive fast-path accessor (`asInt()`/`asDouble()`/`asString()`/`asBool()`) throws a conversion error on a kind mismatch From 69fae220caaab562a6d025972a640079e7bf16c7 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Sun, 14 Jun 2026 00:45:54 +0200 Subject: [PATCH 11/15] Doc: sketch per-arity emission for omittable arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a worked expansion for `resize(width:height:mode:)` mixing a required, a defaulted, and an optional-no-default param. The binding decodes the required prefix once, then `switch`es on `arguments.count`, each case decoding only the present slots: an omitted defaulted param drops its label (Swift applies the default), an omitted optional passes `nil`. Correct an earlier claim: the per-arity branch is needed for the whole omittable trailing run, not just defaults. The arguments buffer is exactly JS-arity-sized and `arguments[i]` traps out of bounds (`JavaScriptValuesBuffer.swift:67`) — there's no missing-slot padding like core's DSL path (`SyncFunctionDefinition.swift:170`), so the binding can't index a slot the caller didn't pass. The switch collapses to a single flat call only when every param is required. --- docs/expo-modules-v2.md | 67 +++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index 596c47a..c097917 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -1037,13 +1037,59 @@ positional). `f(a: Int, b: String? = nil)` emits `guard (1...2).contains(argumen `f(a: Int, b: Int = 5, c: String? = nil)` emits `guard (1...3).contains(...)`, and at `received == 1` it calls `self.f(a: arg0)` (Swift fills `b`, `c` is its own `nil` default). -The cost: a defaulted arg can't be threaded through one static call expression — when it may -or may not be present, the binding **branches on `arguments.count`** and emits one -`self.f(...)` shape per valid arity (decoding only the args that are present in that branch). -Pure-`Optional` ranges don't need this (the omitted slot is just `nil`); only **defaults** -force the per-arity call shapes. This is the macro doing at compile time what a closure -fundamentally cannot: respecting author-written default expressions instead of forcing every -optional-with-a-default down to `nil`. +The cost: an omitted arg can't be threaded through one static call expression, so when a +trailing slot may or may not be present the binding **branches on `arguments.count`** and emits +one `self.f(...)` shape per valid arity, decoding only the slots present in that branch. This +branch is needed for the **whole omittable trailing run**, optional or defaulted — not just +defaults — because the buffer is exactly JS-arity-sized and **`arguments[i]` traps out of +bounds** (`JavaScriptValuesBuffer.swift:67`); there's no missing-slot padding like core's DSL +path does (`SyncFunctionDefinition.swift:170`). Within a branch, a **defaulted** omitted param +drops its label from the call (Swift applies the default) and an **optional** omitted param is +passed `nil`. The decode of each *present* slot is identical across arities (args are +positional), so only the call expression multiplies, not the whole body: decode the **required +prefix** once, then `switch` on the count. For + +```swift +@JS func resize(width: Int, height: Int = 100, mode: String? = nil) -> Bool +// required default optional-no-default +// required = 1, max = 3 +``` + +the body is: + +```swift +object.setProperty("resize") { [weak appContext, self] this, arguments in + guard let appContext else { throw Exceptions.AppContextLost() } + // required/max range; `height` is defaulted and `mode` optional, so the floor is 1 + guard (1...3).contains(arguments.count) else { + throw InvalidArgsNumberException((received: arguments.count, expected: 3, required: 1)) + } + // required prefix: always present, decoded once + let width = try arguments[0].asInt() + // one call per arity. Each branch only touches the slots that branch actually has, so no + // `arguments[i]` reads past `count`. Omitted `height` drops its label (Swift applies =100); + // omitted `mode` is passed `nil`. + let result: Bool + switch arguments.count { + case 1: + result = self.resize(width: width) // height = 100, mode = nil + case 2: + let height = try arguments[1].asInt() + result = self.resize(width: width, height: height, mode: nil) + default: // 3 + let height = try arguments[1].asInt() + let mode = try (String?).getDynamicType().cast(jsValue: arguments[2], appContext: appContext) as! String? + result = self.resize(width: width, height: height, mode: mode) + } + return result.toJavaScriptValue(in: runtime) +} +``` + +The `switch` collapses to a single call only when the trailing run is empty (every param +required): then `required == max`, the range guard becomes the exact-count check, and the body +is the flat decode-all-then-call shown in the earlier `add` example. The moment any trailing +param is omittable — optional **or** defaulted — the per-arity branch is required, since the +binding can't index a slot the caller didn't pass. > **Gap (shipped vs. target).** The current codegen (`DecorateModuleBuilder.swift:47–70`) > emits an **exact** `arguments.count == ` check, throws an ad-hoc @@ -1056,9 +1102,10 @@ optional-with-a-default down to `nil`. **2. `null` / `undefined` map by the parameter's static optionality.** Both JS `null` and `undefined` decode to `nil` for an `Optional` parameter (`DynamicOptionalType.swift:26–31`), and to a thrown `NullCastException` for a required non-optional one -(`Conversions.swift:328`, e.g. `DynamicBoolType.swift:17`). The macro decodes optional params -through the dynamic-optional converter (which already encodes this), so it inherits that -behavior rather than re-implementing the `null`/`undefined`/missing trichotomy. +(`Conversions.swift:328`, e.g. `DynamicBoolType.swift:17`). For a **present** slot the macro +decodes optional params through the dynamic-optional converter (which already encodes the +`null`/`undefined` → `nil` mapping), so it inherits that behavior; an **absent** slot is handled +by point 1's per-arity branch, not the converter. **Omission** (a trailing slot the arity range allows to be absent) is resolved by point 1, not here: a defaulted param's slot is left out of the call so Swift applies the default; an From f2541569b3e04258d98f0467856895f105c8713a Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Sun, 14 Jun 2026 00:49:59 +0200 Subject: [PATCH 12/15] Doc: design function-overload support via a runtime dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS holds one function per name, so N same-named `@JS func`s collapse into one host function that dispatches on the only things JS exposes at runtime: `arguments.count` and each argument's `JavaScriptValue.Kind` (plus the `isArray()`/`isFunction()`/`isTypedArray()` object refinements). Three tiers: - Arity-disjoint: dispatch on count; each branch calls a different function (the per-arity switch already in hand). Always supported. - Same-arity, kind-distinguishable: dispatch on the leftmost differing argument's JS kind (`String`↔`number`, `object`↔`array`, etc.). Supported when those kinds are disjoint. - Runtime-indistinguishable (`Int` vs `Double`, two records, two arrays, return-type-only): no dispatcher possible, so a compile-time diagnostic. The kind check is syntactic on declared types, so it resolves at expansion time with a clear error rather than silent last-writer-wins. First cut is single-position dispatch, not full Swift overload resolution. Flag the interim: reject duplicate JS names until the dispatcher lands. --- docs/expo-modules-v2.md | 75 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index c097917..d9b1283 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -1135,6 +1135,81 @@ failing argument short-circuits the rest. stay in core and remain the single source of truth. The macro chooses *which* converter to call statically; core still defines *what* each converter accepts. +### Overloaded functions + +Swift lets two `@JS func`s share a name; JS does not — one property holds one function. So N +`@JS func`s with the same **JS name** can't each `setProperty(jsName)` (the last would silently +win). Supporting overloads means collapsing them into **one** host function that dispatches at +call time over the only things JS exposes at runtime: `arguments.count` and each argument's +**JS kind** (`JavaScriptValue.Kind` + the `isArray()`/`isFunction()`/`isTypedArray()` object +refinements — `JavaScriptValue.swift:521`, `:121`). The macro groups the funcs by JS name and, +for each group of size > 1, emits a dispatcher instead of one binding per func. There are three +tiers, by how cleanly the candidates separate: + +**1. Arity-disjoint — always supported.** Candidates whose accepted arity *ranges* (point 1, +including defaults/optionals) don't overlap dispatch purely on `arguments.count`. This is the +per-arity `switch` already in hand, except each branch calls a *different Swift function* rather +than a different default-fill of one: + +```swift +@JS func at(_ i: Int) -> Element // arity 1 +@JS func at(_ i: Int, _ j: Int) -> Element // arity 2 +// → +object.setProperty("at") { [self] this, arguments in + switch arguments.count { + case 1: let i = try arguments[0].asInt(); return self.at(i).toJavaScriptValue(in: runtime) + case 2: let i = try arguments[0].asInt(); let j = try arguments[1].asInt() + return self.at(i, j).toJavaScriptValue(in: runtime) + default: + throw InvalidArgsNumberException((received: arguments.count, expected: 2, required: 1)) + } +} +``` + +The macro verifies the ranges are disjoint at expansion time; an overlap falls to tier 2/3. + +**2. Same-arity, kind-distinguishable — supported when the deciding argument's JS kinds are +disjoint.** When two candidates share an arity, the dispatcher must pick on the JS kind of the +first argument position where their types differ. That works *only if* those types map to +**distinct** JS kinds: + +```swift +@JS func write(_ s: String) { … } // arg0 kind: .string +@JS func write(_ n: Double) { … } // arg0 kind: .number +// → if arguments[0].isString() { … } else if arguments[0].isNumber() { … } else { throw … } +``` + +The macro emits the kind tests in declaration order, calls the first match, and throws an +"`no overload of 'write' matches the arguments`" error if none do. `String`↔`number`, +`object`↔`array` (via `isArray()`), `object`↔`function` (`isFunction()`) all separate cleanly. + +Scope: the first cut distinguishes on a **single** argument position (the leftmost where the +candidates' kinds differ) and treats `null`/`undefined` at that position as matching the +optional candidate, if any. Genuinely multi-axis resolution (several positions differing, or +arity *and* kind interacting) is a decision tree the macro could grow into, but it is **not** +full Swift overload resolution — anything the single-position kind test can't separate +unambiguously is pushed to tier 3 rather than guessed. + +**3. Runtime-indistinguishable — compile error.** When same-arity candidates collapse to the +**same** JS kind at every position, no dispatcher can exist and the macro emits a diagnostic +pointing at the colliding declarations. The unsupportable cases: + +- `Int` vs `Double` (both `.number`), `Int` vs `CGFloat`, etc. — numeric widths are one JS kind. +- Two different record types, or two different shared-object classes (both `.object`). +- `[Int]` vs `[String]` (both array), `[String]` vs `Set`. +- **Return-type-only** overloads (`f() -> Int` vs `f() -> String`): identical call sites, nothing + to dispatch on. Always an error. + +The boundary is deliberately the same one core already draws elsewhere: dispatch sees JS kinds, +not Swift types, so the macro can only honor an overload JS could itself tell apart. The check is +purely syntactic on the declared types (a fixed Swift-type → JS-kind table), so it runs entirely +at expansion time with a clear diagnostic, never a silent last-writer-wins. + +> **Status: designed, not built.** Today each `@JS func` emits its own `setProperty(jsName)` +> (`DecorateModuleBuilder.swift:113`), so same-named funcs would currently collide silently. +> Grouping-by-JS-name with the tiered dispatcher (and the tier-3 diagnostic) is phase-2 work; the +> safe interim is to **reject** a duplicate JS name outright until the dispatcher lands. + ### Proof of concept: `@OptimizedFunction` `@OptimizedFunction` (`ExpoModulesOptimizedMacro.swift`, From 1dc6e1494c3064a63b92ae0c3a9077d69038785c Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Sun, 14 Jun 2026 01:08:25 +0200 Subject: [PATCH 13/15] Doc: function signature surface, closure args, error mapping, overload scoping Add a "Function signature surface" section closing gaps the validation and overload work implied but didn't state: - Accepted vs. rejected `@JS func` parameter shapes (variadic, `inout`, generic/opaque rejected with a diagnostic; `T`/`T?`/defaults/closures accepted; extra labels and `rethrows` fine). - Closure/callback arguments as a new capability the DSL lacks (closures aren't `AnyArgument`): the macro wraps an incoming `JavaScriptFunction` in a Swift closure, with the `@JavaScriptActor` thread and strong-retain lifetime rules an `@escaping` callback needs. - Error-to-JS mapping: a thrown `Exception` crosses via `forwardingSwiftErrorsToJS` into a JS `Error` carrying `message` + `code`; sync throws, async rejects; arg index/type ride in the message string, same as the DSL path. Also note overload grouping is per JS object: a static and an instance func of the same name aren't overloads (different objects), but a `@JS var` and `@JS func` resolving to one key on one object collide. --- docs/expo-modules-v2.md | 68 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index d9b1283..69b26fa 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -1205,11 +1205,79 @@ not Swift types, so the macro can only honor an overload JS could itself tell ap purely syntactic on the declared types (a fixed Swift-type → JS-kind table), so it runs entirely at expansion time with a clear diagnostic, never a silent last-writer-wins. +**Grouping is per JS object, not per name globally.** In a shared object a **static** func and +an **instance** func may share a JS name without being overloads of each other — they decorate +different JS objects (constructor vs. prototype), so `Cache.get` and `cache.get` coexist, the same +way the doc already notes for properties ([static vs. instance](#static-vs-instance-members)). The +macro therefore groups overload candidates **within** the static set and **within** the instance +set separately; a static and an instance func never dispatch through one host function. By the +same logic a `@JS var` and a `@JS func` that resolve to the same JS name on the same object **do** +collide (a property descriptor and a function can't share a key) — that's a tier-3-style +diagnostic, not an overload. + > **Status: designed, not built.** Today each `@JS func` emits its own `setProperty(jsName)` > (`DecorateModuleBuilder.swift:113`), so same-named funcs would currently collide silently. > Grouping-by-JS-name with the tiered dispatcher (and the tier-3 diagnostic) is phase-2 work; the > safe interim is to **reject** a duplicate JS name outright until the dispatcher lands. +### Function signature surface + +The validation and overload rules above assume a parameter/return shape the binding can decode. +This is the boundary of what `@JS func` accepts, and what each non-accepted shape does. + +**Accepted vs. rejected parameter shapes.** The macro decodes positional arguments by static +type, so a shape it can't reduce to "one JS value per position, of an `AnyArgument` type" is a +**compile-time diagnostic**, not a silent miscompile: + +| shape | status | rationale | +| --- | --- | --- | +| `T` where `T: AnyArgument` (the asserted boundary types) | accepted | the decode path (primitive accessor or `getDynamicType().cast`) | +| `T?` / `T = default` | accepted | the omittable-arg rule (arity range + per-arity branch) | +| closure param `(@escaping (A...) -> R)` | accepted (new, see below) | synthesized JS-function wrapper | +| `T...` (variadic) | rejected | JS has no variadic ABI; author writes `[T]` and spreads in JS | +| `inout T` | rejected | no JS aliasing of a native slot to write back through | +| generic `` / opaque `some P` param | rejected | no concrete type at expansion time to pick a converter | +| multiple argument labels (`f(x y: Int)`) | accepted | the JS call is positional; the internal label is irrelevant to binding | +| `rethrows` | accepted (treated as `throws`) | the binding always allows a throw | + +The first cut may start narrower (reject closures, land them later); the table is the **target** +surface, and every "rejected" row is an explicit diagnostic naming the offending parameter. + +**Closure / callback arguments.** A JS function passed as an argument is a real need (a +completion callback, a comparator) that the **DSL can't express today** — closures aren't in +`AnyArgument` (`AnyArgument.swift`), so DSL modules fall back to taking a raw `JavaScriptValue` +and calling it by hand. Because the macro binds the real function, it can decode a +function-typed parameter into a Swift closure that wraps the incoming `JavaScriptFunction` +(`JavaScriptFunction.call(arguments:)`, `JavaScriptFunction.swift:37`): each Swift call encodes +its args to JS, invokes the JS function, decodes the result. Semantics that must be spelled out, +because they differ from a plain value: + +- **Thread:** the JS function can only be invoked on `@JavaScriptActor` (the runtime thread). + A closure called **synchronously**, before the host function returns, is already on that + thread. A closure **stored and called later** (hence `@escaping` required) must hop back to + the JS thread; calling it off-thread is a programming error the wrapper should trap, not + silently cross. +- **Lifetime:** the wrapper holds the `JavaScriptFunction` **strong** (it's the only thing + keeping the JS callable reachable), so a stored `@escaping` callback pins it until the Swift + side drops the closure — the same retention reasoning as `@Event` (see [events](#events)). +- **Types:** the closure's own parameters/return must themselves be `AnyArgument`-convertible + (they go through the same encode/decode); an `async` closure return maps to awaiting a JS + promise. A non-`@escaping`, synchronously-invoked callback is the simple case and a good first + cut; `@escaping` storage is the part that needs the lifetime/thread guarantees above. + +**Errors to JS.** A thrown Swift `Exception` crosses the JSI boundary via +`forwardingSwiftErrorsToJS` (`ErrorHandling.swift:28`) into a JS `Error` carrying **`message`** +(the exception's `reason`) and a **`code`** property (`JavaScriptError.swift:21`); the Swift +cause chain is folded into the message text, not a structured `cause`. A **sync** `@JS func` +throws that error **synchronously** at the JS call site; an **async** one **rejects its promise** +(`AsyncFunctionDefinition.swift:123`). The arg-validation errors from the section above ride the +same channel: `InvalidArgsNumberException` and `ArgumentCastException` reach JS as ordinary +`Error`s, with the **argument index and expected type baked into the message string** +(`"The 2nd argument cannot be cast to type String"`, `JavaScriptUtils.swift:66`), not as separate +JS-visible fields. The binding's job is just to *throw the right core exception*; the boundary +forwarding and the JS-Error shape are core's, kept identical to the DSL path so error handling in +JS doesn't change between the two. + ### Proof of concept: `@OptimizedFunction` `@OptimizedFunction` (`ExpoModulesOptimizedMacro.swift`, From 5726268a0bba7c00d15a973f500d5a053198eb3e Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Wed, 8 Jul 2026 10:13:38 +0200 Subject: [PATCH 14/15] Sync with my local doc --- docs/expo-modules-v2.md | 1175 ++++++++++++++++++++++----------------- 1 file changed, 658 insertions(+), 517 deletions(-) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index 69b26fa..56f75e5 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -2,55 +2,82 @@ - **Status:** Draft for review (signatures verified against `expo/main` core) - **Author:** Tomasz Sapeta -- **Created:** 2026-05-31 · **Updated:** 2026-06-11 +- **Created:** 2026-05-31 · **Updated:** 2026-06-25 - **Scope:** Apple/Swift macros in this repo (`apple/Sources/ExpoModulesMacros`) +### Implementation status (2026-06-25) + +This document describes the Apple/Swift macros; the JS-facing surface and TypeScript model +are platform-neutral and an Android/KSP path can target them later (see +[Platform-neutral model](#platform-neutral-model-androidksp-parity)). + +**iOS / Swift**, reconciled against `main` (PRs through #27). **Landed:** direct JSI binding +for `@JS` functions (#13), properties via `defineProperty` (#14), and `@SharedObject` via +`_decorateSharedObject` (#22); range-based arity with default/optional-aware per-arity branches +throwing `Exceptions.ArgumentsRangeMismatch` (#19: this closed the arity "Gap" the validation +section used to flag); `_jsName`, dropping the `Name(…)` DSL entry (#18); the `@Event` accessor +macro (#17); compile-time `@JS`/`@Record` conformance assertions (#15); unowned-`this` sync +closures (#20); IUO-type normalization (#23); the `ExpoModulesScanner` CLI with `scan-modules` +(#21); `@Record` field synthesis without `@Field` (#9); `@ExpoModule` auto-conformance to +`AnyModule` (#5); `@JS` bindings and `@Record` field conversions through `JavaScriptCodable` +(`decode`/`encode`) instead of the dynamic-type path (#26, #27); dropping the unused `appContext` +parameter from the synthesized JSI hooks (this branch). **Not built (unblocked, pure-macro):** +overloaded-`@JS func` grouping/dispatch (duplicates collide silently today), `@Union`, and the +decode index-bearing error wrap. **Not built (blocked on core):** `@ViewProps`/`@ExpoView` (view +contract), `@JS static var` (`StaticProperty`), `@Event(sync:)` runtime (`emitSync`). **Stubbed:** +`scan-exports` (the deep type-export walk for TypeScript generation). Per-section status callouts +below carry the detail. + +**Android / Kotlin (KSP):** **nothing built yet.** No KSP processor, no annotations, no +scanner. The plan here is iOS-only; the Android path is tracked separately +(`expo-modules-v2-android.md`) and will target the same platform-neutral JS/TypeScript model. + ## Goal Use Swift macros to make authoring Expo modules and views simpler, faster, and -type-safe end to end — an author writes ordinary Swift (`@JS` members, a typed props -object, typed events) and the toolchain takes care of the rest. The work is staged -in three phases: +type-safe end to end: an author writes ordinary Swift (`@JS` members, a typed props +object, typed events) and the toolchain takes care of the rest. The work covers three +areas: ## Why macros over the DSL (incl. AI-agent ergonomics) Beyond performance and type-safety, the macro surface is a markedly better authoring -API **for AI coding agents** — and, increasingly, that's a first-class design criterion. +API **for AI coding agents**, and, increasingly, that's a first-class design criterion. - **It's ordinary Swift.** `@ExpoModule class { @JS func add(a: Double, b: Double) -> Double }` is "an annotated class," a shape agents model well from training data - (`@Published`, `@objc`, decorators, Java annotations). The DSL — `Function("add") { - (a: Double, b: Double) in … }`, `.get/.set` chaining, `Events("…")` — is a bespoke + (`@Published`, `@objc`, decorators, Java annotations). The DSL (`Function("add") { + (a: Double, b: Double) in … }`, `.get/.set` chaining, `Events("…")`) is a bespoke result-builder grammar an agent must recall *Expo-specifically*, with more room to invent a signature that doesn't exist. - **Signature is the contract; the type checker enforces it.** With `@JS func add(…) -> Double` there's one obvious place for types and Swift verifies it. The DSL splits the - contract (closure param types must be spelled out *and* `AnyArgument`-convertible) and + contract (closure param types must be spelled out *and* convertible) and surfaces mistakes as hard-to-diagnose result-builder/closure-inference errors. - **Fewer Expo-specific concepts to get right.** Macros fold `Function`/`AsyncFunction` into the `async` keyword, `Property().get.set` into a normal `var`, the `Events("…")` - string list + `sendEvent("…")` into one typed `@Event` property — each collapsed + string list + `sendEvent("…")` into one typed `@Event` property: each collapsed concept is one less thing to misremember or desync. - **Lower token cost over a session.** Per module the macro form is a bit more compact, but the dominant lever is **fewer error→re-read→fix round-trips**: precise, - type-checked failures cost far less than debugging result-builder errors. Phase 3 - compounds this — generated `.d.ts` means the *JS-side* code an agent writes is typed - too. + type-checked failures cost far less than debugging result-builder errors. Generated + `.d.ts` (see [TypeScript generation](#typescript-generation)) compounds this: the + *JS-side* code an agent writes is typed too. - **Caveat (transient):** today the DSL is the better-known surface simply because it's documented and in training data; the v2 macros aren't shipped yet. That edge flips once the macros ship and are documented. The same properties that help agents (ordinary Swift, one obvious place for types, -fewer special concepts) help humans too — this isn't an agent-only optimization. +fewer special concepts) help humans too: this isn't an agent-only optimization. ## Prior art: JavaScriptKit / BridgeJS SwiftWasm's [JavaScriptKit](https://github.com/swiftwasm/JavaScriptKit) (its **BridgeJS** interop) is shipping macro-based Swift↔JS bridging, and independently lands on most of the -same decisions — strong validation of this direction: +same decisions, strong validation of this direction: - **`@JS` macro-based export** of Swift types/functions to JS, replacing hand-written glue - — the same thesis as this proposal. + (the same thesis as this proposal). - **`@JS struct` = value/copy semantics** (reconstructed as a plain JS object, no shared state); **`@JS class` = reference semantics** (shared across the boundary). Matches our `@Record` (value) vs `@SharedObject` (reference) split. @@ -60,56 +87,58 @@ same decisions — strong validation of this direction: - **Optionals → `T | null`**, nested structs nest, `JSObject` fields allowed; **no generics / conformances / property observers**. Mirrors our requiredness/nullability rules. -- **Generates TypeScript `.d.ts`** from the annotated source — exactly - [Phase 3](#phase-3--typescript-type-generation). -- Struct `init` → a **static `init` method**; class `init` → a JS **`new` constructor** — +- **Generates TypeScript `.d.ts`** from the annotated source: exactly + [TypeScript generation](#typescript-generation). +- Struct `init` → a **static `init` method**; class `init` → a JS **`new` constructor**, the same instance-vs-static / prototype-vs-constructor distinction we draw. **Where it differs (deliberately):** JavaScriptKit uses **one `@JS` attribute for -everything** (struct, class, enum, func, property). We split it — `@JS` for *members*, -`@Record`/`@Union`/`@ViewProps`/`@ExpoView` for *types* — because those map onto +everything** (struct, class, enum, func, property). We split it: `@JS` for *members*, +`@Record`/`@Union`/`@ViewProps`/`@ExpoView` for *types*, because those map onto **existing `expo-modules-core` concepts** (`Module`, `Record`, `SharedObject`, views) with distinct runtime registration and lifetimes. JavaScriptKit targets a flatter, pure-Swift- Wasm world (Swift *is* the whole program), where a single `@JS` fits; we layer onto a host runtime with a pre-existing type taxonomy. (This is also the backdrop for the open question of whether `@JS struct`/`@JS enum` should be an error redirecting to -`@Record`/`@Union`, or accepted as a JavaScriptKit-familiar alias — see open questions.) - -## Phases - -1. **[DSL coverage](#phase-1--dsl-coverage)** — macros that cover the existing API - surface and synthesize the definition using **today's DSL** (`Function`, - `Property`, `Class`, `View`, `Events`, …). The macro reads `@JS`/`@ViewProps`/etc. - and emits the same `*Definition` element tree the runtime walks today. This is the - bulk of this document (Modules + Views below). -2. **[Performance — direct JSI binding](#phase-2--performance-direct-jsi-binding)** — - instead of emitting the DSL and letting the runtime build JS objects dynamically, - the macro generates **pure-Swift** code that **binds members directly into the JS object** via - the `expo-modules-jsi` API. This omits the `*FunctionDefinition` indirection and the - dynamic-type/`[Any]`/`toTuple` argument path — the biggest current bottleneck — in - favor of statically-typed, per-argument converters covering **all supported types**. - The existing `@OptimizedFunction` is an early proof of concept; the goal is for - *every* synthesized function to be optimized by default, after which - `@OptimizedFunction` is dropped. -3. **[TypeScript type generation](#phase-3--typescript-type-generation)** — generate - the module's `.d.ts` (functions, records, view props + event callbacks) from the - annotated Swift source, so JS types always match native. A **SwiftSyntax extractor** - reads the declarations (types are written on the `@JS`/`@Record`/`@ViewProps` - declaration, so no type resolution is needed) and feeds `expo-type-information`'s - existing TS emitter, replacing its SourceKitten front end. - -Phase 1 produces a correct, complete surface on the existing runtime; phase 2 swaps -the *implementation strategy* underneath the same author-facing API for speed; phase 3 -adds the JS-side type story on top of the same source annotations. Phases 2 and 3 both -build on the declarative description phase 1 establishes. +`@Record`/`@Union`, or accepted as a JavaScriptKit-familiar alias: see open questions.) + +## Overview + +The work covers three areas, all building on the same author-facing macro surface: + +1. **[Authoring surface](#authoring-surface)**: the macros an author writes + (`@ExpoModule`, `@JS`, `@SharedObject`, `@Record`, `@Union`, `@ViewProps`, + `@ExpoView`, `@Event`) that map onto existing `expo-modules-core` concepts + (`Module`, `Record`, `SharedObject`, views). This is the bulk of this document + (Modules + Views below). +2. **[Direct JSI binding](#direct-jsi-binding)**: instead of emitting the DSL and + letting the runtime build JS objects dynamically, the macro generates **pure-Swift** + code that **binds members directly into the JS object** via the `expo-modules-jsi` + API. This omits the `*FunctionDefinition` indirection and the dynamic-type/`[Any]`/ + `toTuple` argument path (the biggest runtime bottleneck) in favor of + statically-typed, per-argument converters covering **all supported types**. The + existing `@OptimizedFunction` is an early proof of concept; the goal is for *every* + synthesized function to be optimized by default, after which `@OptimizedFunction` is + dropped. +3. **[TypeScript generation](#typescript-generation)**: generate the module's `.d.ts` + (functions, records, view props + event callbacks) from the annotated Swift source, + so JS types always match native. A **SwiftSyntax extractor** reads the declarations + (types are written on the `@JS`/`@Record`/`@ViewProps` declaration, so no type + resolution is needed) and feeds `expo-type-information`'s existing TS emitter, + replacing its SourceKitten front end. + +The author-facing API is the same across all three: the macros describe the module's +native + JS surface declaratively, the direct binding is the *implementation strategy* +underneath that surface, and TypeScript generation reads the same source annotations to +emit the JS-side types. Cross-cutting decisions referenced throughout: - **Events: typed payloads, no `EventDispatcher`.** An event's payload is typed at - the Swift level (payload type is `AnyArgument`, or none) and funnels into the + the Swift level (payload type is `JavaScriptEncodable`, or none) and funnels into the existing untyped runtime. See [Events model](#events-model). - **Lifecycle: handled in core, not the macro.** Overridable instance methods that - core calls directly — the macro synthesizes nothing for lifecycle. See the + core calls directly: the macro synthesizes nothing for lifecycle. See the per-section lifecycle notes. - **Macro declarations** for new attributes live in **`expo-modules-core`** (`ios/Core/ExpoModulesMacros.swift`), alongside `@JS`/`@ExpoModule`. Core symbols @@ -117,14 +146,15 @@ Cross-cutting decisions referenced throughout: --- -# Phase 1 — DSL coverage +# Authoring surface -Cover the existing API by synthesizing the current definition DSL. The macros below -read the author's `@JS`/`@ViewProps`/etc. declarations and emit the same -`*Definition` element tree (`Function`, `AsyncFunction`, `Property`, `Class`, `View`, -`Events`, …) the runtime already consumes — no runtime changes beyond the core -dependencies noted per section. Organized as **Modules** and **Views**, each listing -its macros as subsections. +The macros an author writes. Each reads the author's `@JS`/`@ViewProps`/etc. +declarations and synthesizes the code that exposes the member to JS: for `@JS` +members and `@SharedObject`/`@ExpoModule` types that means binding directly into the +JS object (see [Direct JSI binding](#direct-jsi-binding)), not emitting the runtime's +`*Definition` element tree. The surface is organized as **Modules** and **Views**, each +listing its macros as subsections. Where a macro still needs runtime support that core +doesn't provide yet, the dependency is noted per section. ## Modules @@ -135,11 +165,12 @@ discovering the JS surface from `@JS`-marked members. ### `@ExpoModule` `@ExpoModule` / `@ExpoModule("CustomName")` on a module class. Binds the `@JS` members -directly into the module's JS object via `_decorateModule` (the phase-2 form, already -shipped), and (when the class doesn't inherit `Module`/`BaseModule`) synthesizes the -`appContext` storage, `init(appContext:)`, and the `AnyModule` conformance — so inheriting +directly into the module's JS object via `_decorateModule` (the +[direct-binding form](#direct-jsi-binding), already shipped), and (when the class doesn't +inherit `Module`/`BaseModule`) synthesizes the +`appContext` storage, `init(appContext:)`, and the `AnyModule` conformance, so inheriting from `Module` is optional. It also stamps `@ModuleDefinitionBuilder` on a user-written -`definition()`. The synthesized `_synthesizedDefinition()` is now (near-)empty — members +`definition()`. The synthesized `_synthesizedDefinition()` is now (near-)empty: members moved to `_decorateModule`, and `Name` is being retired (see below). ```swift @@ -149,24 +180,24 @@ class MyModule: Module { @JS var ready: Bool = false // getter+setter (see below) @JS func reset() {} - override func onModuleCreate() {} // core calls directly — NOT macro-synthesized - override func onStartObserving() {} // core calls directly — NOT macro-synthesized + override func onModuleCreate() {} // core calls directly, NOT macro-synthesized + override func onStartObserving() {} // core calls directly, NOT macro-synthesized } ``` Arguments: -- `_ name: String? = nil` — JS module name (defaults to the class name). -- `classes: [Any.Type] = []` — shared-object classes whose +- `_ name: String? = nil`: JS module name (defaults to the class name). +- `classes: [Any.Type] = []`: shared-object classes whose `_synthesizedClassDefinition()` is spliced in. -- `views: [Any.Type] = []` — view classes; each contributes +- `views: [Any.Type] = []`: view classes; each contributes `MyView._synthesizedViewDefinition()` (see [Views](#views)). Read with the existing `classListArgument(of:label:"views")` `.self` parser. -#### Module name — retire the `Name` DSL component +#### Module name: retire the `Name` DSL component `Name(...)` is the last DSL element the macro still emits (functions/properties have moved to `_decorateModule`; it sits alone in `_synthesizedDefinition()`). It's **redundant in the -common case**: core already resolves an absent name to the Swift type name — twice over, +common case**: core already resolves an absent name to the Swift type name, twice over, in `ModuleDefinition` (name from `ModuleNameDefinition` else `String(describing: type)`, `ModuleDefinition.swift:74–76`) and `ModuleHolder.name` (`ModuleHolder.swift:36`). @@ -206,11 +237,11 @@ fallback and behind a registration `_name`. The same applies to `@SharedObject` #### Property getter + setter > **Implemented (PR #14): `@JS var` binds directly via `defineProperty`, not the DSL.** The -> originally-planned approach below — emit a DSL `Property("ready") { … }.set { … }` entry — was +> originally-planned approach below (emit a DSL `Property("ready") { … }.set { … }` entry) was > skipped. `@ExpoModule` instead binds each `@JS var` straight into the module's JS object inside -> `_decorateModule` (the phase-2 direct-JSI form), so properties never go through `Property(...)`. A -> get/set accessor is installed with `object.defineProperty(name, descriptor:)`; see -> [the phase-2 sketch](#example-a-sync-function). Settability is syntactic — a stored `var`, or a +> `_decorateModule` (the [direct-binding form](#direct-jsi-binding)), so properties never go through +> `Property(...)`. A get/set accessor is installed with `object.defineProperty(name, descriptor:)`; +> see [the sync-function sketch](#example-a-sync-function). Settability is syntactic: a stored `var`, or a > computed `var` with an explicit `set` (or `willSet`/`didSet`), gets a setter; getter-only computed > vars and `let` stay read-only. Swift access modifiers (`private`, `private(set)`) don't gate JS > exposure: the synthesized accessor lives inside the type, so it reads/writes those members fine. @@ -234,13 +265,13 @@ Setter is a chained method on `PropertyDefinition` > `getConstants()` bridge** (`getRawValue()` → `ObjectDefinition.legacyConstants`); > on the JSI path a getter-only `Property` is equivalent. A getter-only `@JS var` > therefore already exposes a read-only value. Revisit only if a real consumer -> needs the legacy map — and if so, prefer a `@JS(constant:)` modifier over a +> needs the legacy map, and if so, prefer a `@JS(constant:)` modifier over a > second attribute (avoids two ~95%-overlapping property attributes). #### Module events Typed, no `EventDispatcher` (see [Events model](#events-model)). An event is a -**function-typed property** marked `@Event` — the same "function = event" idea as view +**function-typed property** marked `@Event`, the same "function = event" idea as view props, but here the macro (not core) wires the dispatch, since a module has no props object for core to populate. @@ -265,88 +296,88 @@ var onProgress: (ProgressEvent) -> Void { get { { [weak self] payload in self?.emit(event: "progress", payload: payload) } } } var onReady: () -> Void { - get { { [weak self] in self?.emit(event: "ready") } } // no-payload overload — not payload: .undefined + get { { [weak self] in self?.emit(event: "ready") } } // no-payload overload, not payload: .undefined } ``` The weak capture matters when an author stores the closure (hands it to a delegate, keeps it in a callback table): a strong capture would extend the module/shared-object -lifetime. After the emitter deallocates, the closure silently no-ops — the same behavior +lifetime. After the emitter deallocates, the closure silently no-ops: the same behavior `emit` already has once the runtime is gone. **Naming: the Swift property uses the `on` prefix; the JS wire name strips it.** The two sides idiomatically want different names. In Swift the property reads as invoking a handler (`self.onStatusChange(…)`) and the prefix keeps it from colliding with a state property (`status`). In JS, module/shared-object events are listened to by **bare -names** — `addListener("statusChange")`, the Node/DOM idiom the newest team modules +names**: `addListener("statusChange")`, the Node/DOM idiom the newest team modules follow (expo-video: `statusChange`, `playingChange`, `timeUpdate`, `playToEnd`). So the default JS name is the Swift name with a leading `on` + capital stripped and the remainder decapitalized, acronym-aware (`onStatusChange` → `statusChange`, `onURLChange` → `urlChange`); names without the prefix (`online`, `statusChange`) pass -through verbatim. `@Event("name")` overrides verbatim with no transformation — that's +through verbatim. `@Event("name")` overrides verbatim with no transformation, that's also the migration escape hatch for legacy `onX` *wire* names (`@Event("onContactsChange")`). View events are different on purpose: a `@ViewProps` function-typed field *is* a React -handler prop, so it keeps its `onX` name untouched — a view event names a handler prop, +handler prop, so it keeps its `onX` name untouched: a view event names a handler prop, an `@Event` names an emitted event. The macro doesn't enforce the `on` prefix on the Swift property (it's a convention; stripping simply doesn't fire without it). This is the **same on modules and shared objects**: core ships an **`EventEmitter` -protocol** (`Core/Events/EventEmitter.swift`) and conforms **both** base types to it — +protocol** (`Core/Events/EventEmitter.swift`) and conforms **both** base types to it: `extension BaseModule: EventEmitter` (`Module.swift:28`) and `extension SharedObject: -EventEmitter` (`SharedObject.swift:81`) — so `self.emit(...)` resolves on each. The +EventEmitter` (`SharedObject.swift:81`), so `self.emit(...)` resolves on each. The protocol's extension supplies three `emit` overloads: `emit(event:)` (no payload), -`emit(event:payload: JavaScriptValue)` (pre-converted), and the typed -`emit(event:payload: sending P)`, which converts the payload via -`(~P.self).castToJS`. So a `@Event` payload can be **any `AnyArgument`** — primitive, -`@Record`, shared object, `@Union`, etc. — with no dict requirement; the no-payload case +`emit(event:payload: JavaScriptValue)` (pre-converted), and a typed +payload overload that encodes the payload to JS. So a `@Event` payload can be **any +`JavaScriptEncodable`** type (primitive, `@Record`, shared object, `@Union`, etc.) with no +dict requirement; the no-payload case (`() -> Void`) calls the dedicated `emit(event:)` overload, and the payload case passes -the closure's `payload` straight through (a fresh local, consumed once — satisfies +the closure's `payload` straight through (a fresh local, consumed once, satisfies `sending`). **All of this now exists in core; module events are no longer gated.** **No `Events(...)` DSL.** `@Event` does not register event names into the synthesized -definition — `@ExpoModule`/`@SharedObject` ignore `@Event` members entirely; core +definition: `@ExpoModule`/`@SharedObject` ignore `@Event` members entirely; core discovers events through another path. `@Event` is a **self-contained accessor macro** with **zero touch-points** in the module/shared-object macros. -**Not stamped `@JavaScriptActor` — an async event member (the default) stays +**Not stamped `@JavaScriptActor`: an async event member (the default) stays non-isolated.** `emit` is itself non-isolated and `runtime.schedule`s onto the JS thread internally (it captures the emitter `nonisolated(unsafe) weak`, since the emitter need not be `Sendable`). So an author can fire `self.onProgress(...)` from **any** thread/isolation with no `await` at -the call site, and `emit` does the hop. Stamping `@JavaScriptActor` would *defeat* this — +the call site, and `emit` does the hop. Stamping `@JavaScriptActor` would *defeat* this: it would force an actor hop at the call site for the common background-work caller. The base classes (`open class BaseModule` / `open class SharedObject`) carry no actor isolation, so an un-stamped `@Event` var is non-isolated by default; no `nonisolated` keyword is needed in the expansion. This is the **one JS-related member that is -deliberately not `@JavaScriptActor`** — the opposite of `@JS func`/`@JS var`. +deliberately not `@JavaScriptActor`**, the opposite of `@JS func`/`@JS var`. -The synthesized closure allocates per access — negligible for events (not a hot loop). +The synthesized closure allocates per access, negligible for events (not a hot loop). **Diagnostics and compile-time assertions** (implemented in `EventMacro.swift`, PR #17): - As a **peer**, `@Event` emits the same never-called conformance assertion as `@JS`, - checking both that the **payload type is `AnyArgument`** (skipped for primitives) and - that the **enclosing type conforms to `EventEmitter`** — so attaching `@Event` to a + checking both that the **payload type is `JavaScriptEncodable`** (skipped for primitives) and + that the **enclosing type conforms to `EventEmitter`**, so attaching `@Event` to a type that can't emit fails with a clear conformance error on the user's declaration instead of an opaque "no member 'emit'". - `@Event let` errors with a **fix-it** replacing `let` with `var` (the property must be computed; it's getter-only anyway, so nothing is lost). -- `@Event` + `@JS` on one property is rejected — an event is exposed to JS on its own. +- `@Event` + `@JS` on one property is rejected: an event is exposed to JS on its own. - Shape validation: instance-only (no `static`), function type required (optional - function types rejected — an event is always present), `Void` return, at most one + function types rejected: an event is always present), `Void` return, at most one payload parameter, no initializer, no author-written accessors. `@Sendable` and parenthesized function types are unwrapped. -> **Synchronous events: `@Event(sync: true)` — macro side implemented, gated on core +> **Synchronous events: `@Event(sync: true)`, macro side implemented, gated on core > `emitSync`.** The default `emit` always *schedules* (deferred dispatch). `sync: true` > opts into dispatching **inline on the JS thread**: the synthesized closure calls > **`emitSync`** instead of `emit`, and `@ExpoModule`/`@SharedObject` stamp that member -> `@JavaScriptActor` — making "must be on the JS thread" a compile-time guarantee rather +> `@JavaScriptActor`, making "must be on the JS thread" a compile-time guarantee rather > than a runtime crash. That's the only case where a `@Event` member gets the stamp (the > inverse of the async default). The macro work is done (`@Event(sync:)` parsing, the > `emitSync` call, the stamping in both member macros); **core must still add the > `@JavaScriptActor emitSync(event:)`/`emitSync(event:payload:)` overloads** (skip > `runtime.schedule`, convert + `dispatchEvent` inline, swallow + warn on conversion -> failure to keep the closure type non-throwing). Until then the parameter is harmless — +> failure to keep the closure type non-throwing). Until then the parameter is harmless: > generated code references `emitSync` only when an author opts in. #### Module lifecycle @@ -386,7 +417,7 @@ JS; expands to nothing on its own. `@ExpoModule` and `@SharedObject` discover The macro stamps `@JavaScriptActor` on `@JS` members (skipping `nonisolated` or members already on a global actor), reflecting that JS calls run on the JS thread. **This now -includes `async` members** — see below. +includes `async` members**, see below. #### Async functions @@ -399,14 +430,14 @@ parameter**. - **Promise-based async functions are dropped.** We stop supporting the old form where the author takes a trailing `Promise` argument and calls `promise.resolve(…)` manually (core's `takesPromise` path). Async is the Swift `async` keyword, full stop. -- **Async functions are also stamped `@JavaScriptActor`** — a reversal of the current +- **Async functions are also stamped `@JavaScriptActor`**, a reversal of the current macro, which skips async members. The stamp is what makes the next point work. - **Execution matches JS async semantics: the body runs synchronously on the runtime thread up to the first real suspension point** (everything before the first `await` that actually suspends runs inline), then the continuation is scheduled. This holds because the function is `@JavaScriptActor`-isolated *and* invoked from the JS-actor context (the runtime thread): calling an actor-isolated `async` function from the same - actor runs synchronously until an actual suspension — no executor hop before the body + actor runs synchronously until an actual suspension: no executor hop before the body (SE-0306). Without the `@JavaScriptActor` stamp the call would hop to a generic executor first and lose this property; that's why point 3 and point 4 are linked. @@ -443,7 +474,7 @@ final class Cache: SharedObject { #### Static vs. instance members Shared objects (unlike modules) can expose **static/class members** alongside instance -ones. The differentiator is the Swift **`static` modifier** — no separate attribute: the +ones. The differentiator is the Swift **`static` modifier**, no separate attribute: the macro reads `static func` / `static var` and routes them to the static side. This can't collide with instance members because JS places them differently, exactly as @@ -451,34 +482,40 @@ core's `ClassDefinition` already does at install time (`ClassDefinition.swift:88 - **Instance** members (`@JS func`, `@JS var`) → decorate the **prototype** (`decorateWithFunctions`/`decorateWithProperties` on `object.prototype`). - **Static** members (`@JS static func`) → decorate the **constructor object** - (`decorateWithStaticFunctions`) — i.e. `Cache.open(...)`, callable on the class itself. + (`decorateWithStaticFunctions`), i.e. `Cache.open(...)`, callable on the class itself. + +For reference, the corresponding DSL split: an instance function would emit `Function("get", …)` +and a static one `StaticFunction("open", …)` (`StaticFunctionDefinition`, the static counterpart +core already provides). With direct binding, statics decorate the constructor and instance members +the prototype: the same split, just done by the generated builder. -DSL mapping: an instance function emits `Function("get", …)`; a static one emits -**`StaticFunction("open", …)`** (`StaticFunctionDefinition`, the static counterpart core -already provides). In phase 2 (direct JSI binding), statics decorate the constructor and -instance members the prototype — the same split, just done by the generated builder. +> **Status: instance-only today.** The shipped `_decorateSharedObject` (#22) takes only a +> `prototype:` parameter (`DecorateModuleBuilder.swift:426`) and binds instance funcs + +> properties onto it; there is **no constructor parameter and no static routing yet**, so +> `@JS static func`/`static var` on a `@SharedObject` is not bound to the constructor. Wiring +> the constructor side (and `StaticProperty`, below) is remaining work. -**No named bindings — each member's body is inlined into its `setProperty` closure inside the +**No named bindings: each member's body is inlined into its `setProperty` closure inside the decorate entry point** (the module's `_decorateModule`, see -[the module sketch](#example-a-sync-function); a shared object uses a `static func -_decorateSharedObject` class-level form). Instance vs. static is just *which JS object the -closure decorates* and *how it reaches the receiver*: an **instance** member's closure decorates -the **prototype** and recovers the native receiver from JS `this` -(`Cache.getDynamicType().cast(jsValue: this, …)`); a **static** member's closure decorates the -**constructor** and calls the Swift `static` member (`Cache.open(…)`) ignoring `this`. So a JS -instance member and a JS static member that share a name (`cache.get` vs `Cache.get`) never -clash — they're closures on different JS objects. The only generated *name* is the entry point -itself. +[the module sketch](#example-a-sync-function); a shared object uses an +`override class func _decorateSharedObject` class-level form). Instance vs. static is just *which +JS object the closure decorates* and *how it reaches the receiver*: an **instance** member's +closure decorates the **prototype** and recovers the native receiver from JS `this` +(`SharedObject.native(from: this.asObject(in: runtime), as: Cache.self)`); a **static** member's +closure decorates the **constructor** and calls the Swift `static` member (`Cache.open(…)`) +ignoring `this`. So a JS instance member and a JS static member that share a name (`cache.get` vs +`Cache.get`) never clash: they're closures on different JS objects. The only generated *name* is +the entry point itself. > **Static *properties* need core support.** Core has `StaticFunction` but **no > `StaticProperty`** today (class properties decorate an instance, not the prototype/ -> constructor — `ClassDefinition.swift:91`). So `@JS static var` is gated on a core +> constructor, `ClassDefinition.swift:91`). So `@JS static var` is gated on a core > `StaticProperty` addition; `@JS static func` works against today's core. -#### Class-level decoration (phase 2) +#### Class-level decoration -A shared object's phase-2 decoration is **class-level — once per class, no per-instance -pass** — because every member resolves its native receiver from JS `this`, not a captured +A shared object's decoration is **class-level: once per class, no per-instance +pass**, because every member resolves its native receiver from JS `this`, not a captured instance. This mirrors core's `ClassDefinition.decorate` (`ClassDefinition.swift:87–98`): - **static functions** → decorate the **constructor**. @@ -487,34 +524,34 @@ instance. This mirrors core's `ClassDefinition.decorate` (`ClassDefinition.swift `SyncFunctionDefinition`). - **properties** → also decorate the **prototype**. The getter/setter take the owner (`takesOwner`, the `(_ this: OwnerType)` accessor form) and resolve the native instance - from `this`, so a single prototype accessor serves all instances — no per-instance + from `this`, so a single prototype accessor serves all instances, no per-instance binding needed. (Core does exactly this: `decorateWithProperties(object: prototype)`, `ClassDefinition.swift:97`. A stale comment two lines above says properties decorate an - instance — the code below it puts them on the prototype.) + instance: the code below it puts them on the prototype.) -So `@SharedObject` emits a **`static func _decorateSharedObject(…)`** (the class-level form, taking -the constructor/prototype core supplies) that sets up the prototype + constructor once. The only genuinely per-instance work is core's -internal native↔JS pairing (`sharedObjectId`, `SharedObjectRegistry.swift:114`), which the -macro doesn't generate. +So `@SharedObject` emits an **`override class func _decorateSharedObject(…)`** (the class-level +form, taking the prototype core supplies) that sets up the prototype + constructor once. The only +genuinely per-instance work is core's internal native↔JS pairing (`sharedObjectId`, +`SharedObjectRegistry.swift:114`), which the macro doesn't generate. **How an instance member reaches its receiver without `self`.** This is the key -difference from modules. A shared object's `_decorateSharedObject` is a `static func` — it has no instance -`self` — so each instance-member closure recovers the native instance from JS **`this`**: every -instance's JS object is paired with its native object in the `SharedObjectRegistry` at -construction (`ClassDefinition.swift:68`), and `Cache.getDynamicType().cast(jsValue: this, …)` -resolves it back (core's `takesOwner` path → `DynamicSharedObjectType` → -`sharedObjectRegistry.get(...).native`, `DynamicSharedObjectType.swift:41`). The receiver is a -*resolved local*, not an instance `self`: +difference from modules. A shared object's `_decorateSharedObject` is a class method, it has no +instance `self`, so each instance-member closure recovers the native instance from JS **`this`**: +every instance's JS object is paired with its native object in the `SharedObjectRegistry` at +construction (`ClassDefinition.swift:68`), and `SharedObject.native(from: this.asObject(in: runtime), +as: Cache.self)` resolves it back. The receiver is a *resolved local* (named `_self` to avoid +shadowing), not an instance `self`: ```swift -// shared-object instance function `get` — closure decorates the prototype, receiver from `this` -prototype.setProperty("get") { [weak appContext, self] this, arguments in - guard let appContext else { - throw Exceptions.AppContextLost() +// shared-object instance function `get`, closure decorates the prototype, receiver from `this` +prototype.setProperty("get") { (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + let _self = try SharedObject.native(from: this.asObject(in: runtime), as: Cache.self) + guard arguments.count == 1 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "get", received: arguments.count, required: 1, maximum: 1)) } - let owner = try Cache.getDynamicType().cast(jsValue: this, appContext: appContext) as! Cache - let key = try arguments[0].asString() - return try (String?).getDynamicType().castToJS(owner.get(key), appContext: appContext, in: runtime) + let arg0 = try String.decode(arguments.unownedValue(at: 0), in: runtime) + let result = _self.get(arg0) + return try String?.encode(result, in: runtime) } ``` @@ -529,19 +566,19 @@ module closure = call on real `self`; shared-object instance closure = receiver On a shared object the `@Event` accessor's `self` *is* the concrete instance (the getter runs on a real `Cache`, not inside `_decorateSharedObject`), so `self.emit(...)` works directly. `@SharedObject` emits **no `Events(…)`** for `@Event` members and does not need -the stamp — the events model is identical to modules (same `@Event`, same non-isolated +the stamp: the events model is identical to modules (same `@Event`, same non-isolated `emit`, no `Events(...)` registration). For full parity, `@SharedObject` should also gain **property setters** (as modules do). ### `@Record` -`@Record` on a `Record` type synthesizes `_recordFields(of:)` — compile-time +`@Record` on a `Record` type synthesizes `_recordFields(of:)`: compile-time field/key pairs that replace the runtime `Mirror` walk in `fieldsOf(_:)`. **Every stored property is a field. There is no `@Field` attribute.** The macro can see every stored property at expansion time, so no annotation is needed (and `@Field` -is rejected with a diagnostic — it has no meaning under `@Record`): +is rejected with a diagnostic: it has no meaning under `@Record`): ```swift @Record @@ -552,14 +589,14 @@ struct Options: Record { } ``` -**Requiredness is inferred from the declaration** (default value / optional type) — +**Requiredness is inferred from the declaration** (default value / optional type); there's nothing to configure: | Property | JS requiredness | |---|---| -| has a default value (`var x: T = …`) | **optional** — may be omitted; default applies | -| optional type (`var x: T?`) | **nullable** *and* optional — may be omitted or `null` | -| non-optional, no default (`var x: T`) | **required** — must be provided | +| has a default value (`var x: T = …`) | **optional**, may be omitted; default applies | +| optional type (`var x: T?`) | **nullable** *and* optional, may be omitted or `null` | +| non-optional, no default (`var x: T`) | **required**, must be provided | ```swift @Record @@ -570,16 +607,17 @@ struct Options: Record { } ``` -Open: every stored property is expected to be `AnyArgument`/convertible; a +Open: every stored property is expected to be `JavaScriptCodable` (`JavaScriptDecodable & +JavaScriptEncodable`); a non-convertible stored property is a diagnostic. (No opt-out: every stored property is -a field. If a future need to exclude one arises, revisit — but `@Field` is not it.) +a field. If a future need to exclude one arises, revisit, but `@Field` is not it.) -`@Record`'s field synthesis is reused by `@ViewProps` for its value props — see +`@Record`'s field synthesis is reused by `@ViewProps` for its value props, see [`@ViewProps` vs `@Record`](#viewprops-vs-record). ### `@Union` -A **typed union of types** — the JS value may be any one of several types (`A | B | C`). +A **typed union of types**: the JS value may be any one of several types (`A | B | C`). `@Union` is applied to a Swift `enum` whose cases carry the alternatives as associated values; the macro synthesizes the decode and the JS/TS type. @@ -597,27 +635,27 @@ enum Media { ``` Why an enum (vs. the existing `Either`): -- **Tagged + typed.** A Swift enum with associated values *is* a tagged union — no +- **Tagged + typed.** A Swift enum with associated values *is* a tagged union: no `Any?` box, no `.get()` optionals, exhaustive `switch`. Each payload keeps its static type. - **N cases, named.** `Either` is fixed at two and anonymous (you nest `Either>` for three); `@Union` is any number of named cases and maps cleanly to a TS union (the case payloads → `A | B | C`). -- **Faster decode (phase 2).** The macro knows the cases statically, so it generates an - ordered, typed decode straight into the matching case — no `DynamicEitherType` walk, +- **Faster decode.** The macro knows the cases statically, so it generates an + ordered, typed decode straight into the matching case: no `DynamicEitherType` walk, no `Any?` erasure, no thrown-and-caught exception per failed candidate (`Either`'s - current trial loop). See [Phase 2](#phase-2--performance-direct-jsi-binding). + current trial loop). See [Direct JSI binding](#direct-jsi-binding). **Discrimination is structural, in declaration order** (the same model `Either` uses today): the decoder tries each case's payload converter top-to-bottom and takes the first that succeeds. **Caveat:** when payload shapes overlap (e.g. two `@Record`s with compatible fields, or `Int` vs `Double`), the match is order-dependent. A -*discriminated* mode — picking the case by a tag field, like a TS discriminated union — +*discriminated* mode (picking the case by a tag field, like a TS discriminated union) is a **deferred follow-up** (`@Union(discriminator: "type")`), not v1. **`Either` stays** for the quick inline anonymous 2-type case (`func a(_ x: Either)`); the macro optimizes its decode the same way. -`@Union` is the named, N-case option. (Two ways to express a union — `Either` for inline +`@Union` is the named, N-case option. (Two ways to express a union: `Either` for inline 2-type, `@Union` for named unions.) --- @@ -625,10 +663,10 @@ is a **deferred follow-up** (`@Union(discriminator: "type")`), not v1. ## Views A view is a `: ExpoView` (UIKit) class plus a typed **props object**. Props are -declared once as a `@ViewProps` record. The view exposes **`var props`** — always the -current props instance — and receives **batched, atomic** updates via an +declared once as a `@ViewProps` record. The view exposes **`var props`** (always the +current props instance) and receives **batched, atomic** updates via an `onViewPropsChanged(oldProps:)` lifecycle method (by the time it's called, `self.props` -is already the new value; the parameter is the previous one, for diffing — `nil` on the +is already the new value; the parameter is the previous one, for diffing, `nil` on the first application). There is no per-property `Prop(…)` closure and **no `@Prop` attribute**. @@ -649,7 +687,7 @@ type and the event names gathered from the props object: ```swift @ExpoView(props: MyViewProps.self) class MyView: ExpoView { - // self.props is always current. The callback gets the OLD props for diffing — + // self.props is always current. The callback gets the OLD props for diffing, // nil on the first application (initial mount, no previous value). override func onViewPropsChanged(oldProps: MyViewProps?) { if oldProps?.color != props.color { backgroundColor = props.color } @@ -664,14 +702,14 @@ public static func _synthesizedViewDefinition() -> ViewDefinition { return View(MyView.self) { Props(MyViewProps.self) // ← new core element; replaces per-Prop closures Events("onTap") // names gathered from function-typed fields on the props object - // onViewPropsChanged is an override called by core — not emitted here + // onViewPropsChanged is an override called by core, not emitted here } } ``` > `Props(_:)` is a **new core element** (see [Core dependencies](#core-dependencies)). > If core instead exposes this via a `View` overload (as the -> SwiftUI path does), the synthesized wrapper changes shape — pin when core lands. +> SwiftUI path does), the synthesized wrapper changes shape; pin when core lands. The macro does a `: ExpoView` inheritance check (mirroring `@SharedObject`), reads the props type from the `props: MyViewProps.self` metatype argument (reusing the @@ -686,13 +724,13 @@ superclass. #### View lifecycle -`onViewPropsChanged(oldProps:)` is a core-called **override** — not macro-synthesized. +`onViewPropsChanged(oldProps:)` is a core-called **override**, not macro-synthesized. Contract: core sets `view.props` to the new value **first**, then calls the override with -the **previous** props as `oldProps` (typed `Props?` — **`nil` on the first +the **previous** props as `oldProps` (typed `Props?`, **`nil` on the first application**, since there's no prior value); the user diffs `oldProps` against `self.props` (= the new props). One typed, optional parameter, and a single -authoritative non-optional "current" — `self.props`. -(Depends on the core contract — see [Core dependencies](#core-dependencies).) Core today +authoritative non-optional "current": `self.props`. +(Depends on the core contract, see [Core dependencies](#core-dependencies).) Core today exposes only `OnViewDidUpdateProps`; there is no `OnViewDestroys`. @@ -700,8 +738,8 @@ exposes only `@ViewProps` on a props **`struct`**. **Value stored properties are props; function-typed properties are events** (see [Events model](#events-model)). Like -`@Record`, every stored property is a field — no `@Field` annotation. One record ⇄ one -TS type, callbacks included — exact native/TS symmetry. No `EventDispatcher`, no +`@Record`, every stored property is a field, no `@Field` annotation. One record ⇄ one +TS type, callbacks included: exact native/TS symmetry. No `EventDispatcher`, no `@ViewEvent`. ```swift @@ -710,7 +748,7 @@ struct MyViewProps { var color: UIColor = .red // has default → optional in JS var radius: CGFloat? // optional type → nullable + optional in JS var title: String // no default, non-optional → required in JS - var onTap: (TapEvent) -> Void // event: payload TapEvent (AnyArgument), name "onTap" + var onTap: (TapEvent) -> Void // event: payload TapEvent (JavaScriptEncodable), name "onTap" var onClose: () -> Void // event: no payload } ``` @@ -719,34 +757,34 @@ struct MyViewProps { | Property | JS requiredness | |---|---| -| **has a default value** (`var x: T = …`) | **optional** — may be omitted; the default applies | -| **optional type** (`var x: T?`) | **nullable** *and* optional — may be omitted or `null` | -| **non-optional, no default** (`var x: T`) | **required** — must be provided | +| **has a default value** (`var x: T = …`) | **optional**, may be omitted; the default applies | +| **optional type** (`var x: T?`) | **nullable** *and* optional, may be omitted or `null` | +| **non-optional, no default** (`var x: T`) | **required**, must be provided | "Optional" (key may be omitted) and "nullable" (value may be `null`) are independent: a -property can be one, both, or neither. The rule maps to core's `isRequired` — a field is +property can be one, both, or neither. The rule maps to core's `isRequired`: a field is required only in the last row. **Struct, for performance.** Props are decoded on every React update; a value type avoids per-update heap allocation + ARC churn and makes the `oldProps` vs `self.props` comparison in `onViewPropsChanged(oldProps:)` a clean value diff. v1 is **UIKit-only**, and -the UIKit path (`ExpoFabricView.updateProps`) does **not** observe props — it compares -values and calls setters — so nothing forces a reference type here. +the UIKit path (`ExpoFabricView.updateProps`) does **not** observe props: it compares +values and calls setters, so nothing forces a reference type here. -#### Struct or class — the conformance carries the constraint +#### Struct or class: the conformance carries the constraint SwiftUI can't observe a struct. Its props path shares **one props instance** between the framework and the view: `HostingView` holds `private let props: Props` and on each React update calls `props.updateRawProps(…)` → `objectWillChange.send()`; the view observes that same instance (`@ObservedObject`, props a `class … ObservableObject`). -This "framework owns and pushes mutations" pattern is inherently reference-based — +This "framework owns and pushes mutations" pattern is inherently reference-based: `@State` on a struct can't replace it, and `ObservableObject` / `@Observable` (SE-0395) are class-only. UIKit, by contrast, doesn't observe at all (`ExpoFabricView.updateProps` -compares values and calls setters), so a `struct` is fine — and faster (no per-update +compares values and calls setters), so a `struct` is fine, and faster (no per-update heap alloc/ARC; clean value diff). **Decision: `@ViewProps` may be a `struct` or a `class`; the author picks per view, and -a wrong choice is a compile error — not via a macro type-check (a macro can't see +a wrong choice is a compile error, not via a macro type-check (a macro can't see whether `MyViewProps.self` resolves to a struct or class), but via the conformance the macro emits.** `@ViewProps` is attached directly to the type, so it *can* see the `struct`/`class` keyword and emit the matching props protocol: @@ -754,14 +792,14 @@ macro emits.** `@ViewProps` is attached directly to the type, so it *can* see th - `@ViewProps struct` → a **value** props protocol (value-friendly; `Record` + fields). - `@ViewProps class` → a **class-bound observable** props protocol (`: AnyObject, ObservableObject`). The author's class uses real - `@Observable`/`ObservableObject` (author-written — so no macro-reexpansion problem, + `@Observable`/`ObservableObject` (author-written, so no macro-reexpansion problem, and the author picks the mechanism for their deployment target). `@ExpoView` checks nothing itself. SwiftUI's `View` / `ExpoSwiftUIView` `associatedtype Props` bound requires the **observable** protocol, so a `struct` props simply doesn't conform → **clean compiler error at the SwiftUI view**. UIKit requires only the value protocol (or takes the struct directly). Both frameworks coexist in one -target — a UIKit view uses a struct props, a SwiftUI view uses a class props; they're +target: a UIKit view uses a struct props, a SwiftUI view uses a class props; they're just different types. ```swift @@ -770,7 +808,7 @@ just different types. ``` Trade-off: a props type **shared** by both a UIKit *and* a SwiftUI view must be the -common denominator — a `class`. The common case (props belongs to one view) is +common denominator, a `class`. The common case (props belongs to one view) is unaffected, and the author opts into the class only when they actually need SwiftUI. This replaces the earlier "macro generates a nested observable class" idea, which would @@ -781,23 +819,23 @@ the class and enforcing via the conformance is simpler and avoids that cost. **Core dependency:** the props protocol must be **split** into a value-props and a class-bound observable-props protocol, with SwiftUI's `View` bound on the latter. Today there's one `ExpoSwiftUI.ViewProps` (already a class). This split is core-side -work — see [Core dependencies](#core-dependencies). SwiftUI support itself is deferred +work, see [Core dependencies](#core-dependencies). SwiftUI support itself is deferred past v1 (UIKit-only). -Synthesis — the macro adds: +Synthesis, the macro adds: - **`Record`** + `@Record`'s value-field descriptor synthesis for the prop fields. - The **view-props conformance** core requires (the `ExpoSwiftUI.ViewProps`-equivalent - / props marker — exact protocol TBD against the core work). + / props marker, exact protocol TBD against the core work). - **Function-typed-property event recognition**: their names feed the view definition's `Events(...)`. The macro emits no closure body; core dispatches the event by name. > **Struct ⇒ events wiring can't mutate the props instance.** Core's current > `setUpEvents` finds `EventDispatcher` fields via `Mirror` and *assigns* their -> `.handler` — that needs reference semantics. With a `struct` props and +> `.handler`, that needs reference semantics. With a `struct` props and > function-typed event properties, core can't mutate a stored closure on a value copy; > instead the synthesized event property must *call into* a core-held dispatcher -> (by event name). This sharpens the §0a core contract — see +> (by event name). This sharpens the §0a core contract, see > [Core dependencies](#core-dependencies). #### `@ViewProps` vs `@Record` @@ -815,43 +853,43 @@ core today). Even so, `@ViewProps` is a **distinct attribute**, not a `@Record` data object. A typealias would erase exactly this source-level signal. - **Implementation is shared.** `@ViewProps` reuses `@Record`'s value-field synthesis (factored out of `RecordMacro.swift`); it adds event-property recognition - and the view-props conformance — value-props for a `struct`, class-bound + and the view-props conformance: value-props for a `struct`, class-bound observable-props for a `class` (see above). - The runtime conformance is still emitted (core decodes/observes props through it) - but is **not** the TS distinction — that's the attribute. So the core side is + but is **not** the TS distinction: that's the attribute. So the core side is free to pick whatever conformance/marker it needs without affecting TS generation. --- -# Phase 2 — Performance: direct JSI binding +# Direct JSI binding -Phase 1 keeps the existing execution model: the macro emits `*Definition` elements, -and at call time the runtime walks the definition, builds a `[Any]` argument array, -resolves each argument through a **dynamic type** converter, and assembles a tuple -via `toTuple` before invoking the Swift function. That dynamic path — boxing every -argument to `Any` and the `toTuple` conversion — is the **biggest runtime -bottleneck** today. +The alternative is the runtime's existing execution model: the macro emits `*Definition` +elements, and at call time the runtime walks the definition, builds a `[Any]` argument +array, resolves each argument through a **dynamic type** converter, and assembles a tuple +via `toTuple` before invoking the Swift function. That dynamic path (boxing every +argument to `Any` and the `toTuple` conversion) is the **biggest runtime bottleneck**. -Phase 2 changes the *strategy*, not the author-facing API. Because the macro already -knows each member's **static Swift signature** at compile time, it can generate -**pure-Swift** code that **binds members directly into the JS object** via the `expo-modules-jsi` -API — creating the JS functions/properties itself instead of describing them with -`*FunctionDefinition` for the runtime to interpret. The generated binding can then: +The macro avoids it. Because it already knows each member's **static Swift signature** at +compile time, it generates **pure-Swift** code that **binds members directly into the JS +object** via the `expo-modules-jsi` API: creating the JS functions/properties itself +instead of describing them with `*FunctionDefinition` for the runtime to interpret. The +generated binding: -1. **Validate arity statically** — the argument count is known at expansion time, so +1. **Validates arity statically**: the argument count is known at expansion time, so the generated function checks it directly (no definition lookup). -2. **Use per-argument typed converters** — each argument's concrete type is known, so +2. **Uses per-argument typed converters**: each argument's concrete type is known, so the macro emits a specific converter per argument and reads each from the JS call **individually into its Swift type**, avoiding the `[Any]` boxing and the `toTuple` - step entirely (instead of today's runtime dynamic-type resolution). -3. **Skip the `*FunctionDefinition` indirection** — the JS-visible function is created + step entirely (instead of runtime dynamic-type resolution). +3. **Skips the `*FunctionDefinition` indirection**: the JS-visible function is created straight against JSI, so there's no per-call definition walk. -This must cover **all supported argument/return types** — not just primitives, but -records, shared objects, arrays/typed-arrays, enums, unions (`@Union` / `Either`), -optionals, `Promise`, etc. (the full `AnyArgument` set) — each with a statically-selected -converter. Phase 2 isn't done until the optimized path is a complete replacement, not a -fast lane for a subset. +The author-facing API is unchanged: same `@JS`/`@ExpoModule` declarations; only what the +macro emits changes. This must cover **all supported argument/return types**, not just +primitives, but records, shared objects, arrays/typed-arrays, enums, unions (`@Union` / +`Either`), optionals, `Promise`, etc. (every `JavaScriptCodable` type), each with a +statically-selected converter. The binding isn't a complete replacement until it covers +every type, not just a fast lane for a subset. Unions are a notable win: today `Either` boxes its payload in `Any?` and decodes by *trying each candidate type in a `try?`/throw loop* through `DynamicEitherType`. With the @@ -861,15 +899,15 @@ exception-driven trial. See [`@Union`](#union). ### Example: a sync function -Author writes the same thing in both phases: +Author writes the same `@JS` declaration either way: ```swift @JS func add(a: Double, b: Double) -> Double { a + b } @JS var ready: Bool = false // getter + setter (settable stored var) ``` -**Phase 1** — the macro emits DSL entries; core builds the JS function/property and -resolves arguments dynamically at call time: +**DSL alternative (for contrast):** were the macro to emit DSL entries, core would build +the JS function/property and resolve arguments dynamically at call time: ```swift // in _synthesizedDefinition(): @@ -880,66 +918,62 @@ Property("ready") { self.ready }.set { (newValue: Bool) in self.ready = newValue // + toTuple is the cost. ``` -**Phase 2** — the macro emits a single `_decorateModule` that **builds each JS host function itself** -via the closure-taking `JavaScriptObject.setProperty(_:)` (which calls `runtime.createFunction` -under the hood), with the decode-call-encode body **inlined into the closure**, decoding each -argument by its static type and calling the Swift function directly — no `[Any]`/`toTuple` -assembled by a generic call path, no per-call definition walk. Illustrative expansion (the exact -registration entry point is part of the [core contract](#core-dependencies)): +**Direct binding (what the macro emits):** a single `_decorateModule` that **builds each JS +host function itself** via the closure-taking `JavaScriptObject.setProperty(_:)` (which calls +`runtime.createFunction` under the hood), with the decode-call-encode body **inlined into the +closure**, decoding each argument by its static type and calling the Swift function directly, no +`[Any]`/`toTuple` assembled by a generic call path, no per-call definition walk. The expansion +below is the shipped shape: ```swift // A single generated function decorates the JS object core hands it. This is what the runtime -// calls instead of walking a DSL definition. Core supplies the target object — the module's JS +// calls instead of walking a DSL definition. Core supplies the target object: the module's JS // object, or a shared object's prototype/constructor; never a plain object the macro creates. // Mirrors core's `ObjectDefinition.decorate(object:)`. Named `_decorateModule` (the `_`-prefix // convention for synthesized members the runtime calls; the `ExpoModule` suffix names the macro // it came from). `self` is the native module instance; `this` is the JS owner; `arguments` is a -// JavaScriptValuesBuffer. +// JavaScriptValuesBuffer. The hooks take no `appContext`: the runtime supplies one, recoverable +// from `runtime` where a converter needs it, so it isn't threaded through the signature. @JavaScriptActor -public func _decorateModule(object: borrowing JavaScriptObject, - in runtime: JavaScriptRuntime, appContext: AppContext) throws { +public func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime) throws { // function → the decode-call-encode body is inlined straight into the setProperty closure // (the closure-taking overload creates the host function under the hood). No separate named - // binding — inlining tested as no slower, and it drops a whole naming/collision scheme. - // Capture mirrors core's `SyncFunctionDefinition.build`: `self` (the module) STRONG — the - // closure is what keeps the native callable alive while JS can invoke it, reclaimed by the JS - // VM's GC; `appContext` WEAK + guarded, so it isn't a real retain cycle. - object.setProperty("add") { [weak appContext, self] this, arguments in - guard let appContext else { - throw Exceptions.AppContextLost() - } + // binding, inlining tested as no slower, and it drops a whole naming/collision scheme. + // The closure captures `self` (the module) STRONG: the closure is what keeps the native + // callable alive while JS can invoke it, reclaimed by the JS VM's GC. `this` is borrowed + // (`JavaScriptUnownedValue`); `arguments` is consumed (`JavaScriptValuesBuffer`). + object.setProperty("add") { [self] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in // 1. static arity check. The required and maximum counts are both known at expansion time - // (here both 2 — neither param is optional), so this is a literal range check, no + // (here both 2, neither param is optional), so this is a literal range check, no // definition lookup. With trailing optionals the lower bound drops (see Argument - // validation, below). Mirrors core's `validateArgumentsNumber` / `InvalidArgsNumberException`. + // validation, below). Mirrors core's `validateArgumentsNumber`; throws the macro's + // `Exceptions.ArgumentsRangeMismatch`. guard arguments.count == 2 else { - throw InvalidArgsNumberException((received: arguments.count, expected: 2, required: 2)) + throw Exceptions.ArgumentsRangeMismatch((functionName: "add", received: arguments.count, required: 2, maximum: 2)) } - // 2. per-argument decode by static type — no [Any], no toTuple. Primitives use a direct - // typed accessor (`asDouble()`, validating + throwing); other types fall back to - // `T.getDynamicType().cast(...) as! T` (the public converter; `~` is internal to core). - let arg0 = try arguments[0].asDouble() - let arg1 = try arguments[1].asDouble() - // 3. call the Swift function directly, encode the typed result back to JS. Primitives use - // `toJavaScriptValue(in:)`; other types use `T.getDynamicType().castToJS(...)`. + // 2. per-argument decode by static type: no [Any], no toTuple. Each argument is decoded + // through `JavaScriptCodable`: `T.decode(jsValue, in: runtime)`, the same converter the + // `@Record` factories use. + let arg0 = try Double.decode(arguments.unownedValue(at: 0), in: runtime) + let arg1 = try Double.decode(arguments.unownedValue(at: 1), in: runtime) + // 3. call the Swift function directly, encode the typed result back to JS via + // `T.encode(result, in: runtime)`. let result = self.add(a: arg0, b: arg1) - return result.toJavaScriptValue(in: runtime) + return try Double.encode(result, in: runtime) } // property → a get/set accessor installed with defineProperty (implemented, PR #14). The // getter/setter bodies inline the same way a function's do, reading/writing `self.ready`. A // descriptor object holds `enumerable` + `get` (+ `set` when settable), each a closure-taking - // `setProperty(_:)` host function — the same overload functions use — then + // `setProperty(_:)` host function (the same overload functions use), then // `object.defineProperty("ready", descriptor:)` installs it. A getter-only property omits `set`. let readyDescriptor = runtime.createObject() readyDescriptor.setProperty("enumerable", value: true) - readyDescriptor.setProperty("get") { [weak appContext, self] this, arguments in - guard let appContext else { throw Exceptions.AppContextLost() } - return self.ready.toJavaScriptValue(in: runtime) + readyDescriptor.setProperty("get") { [self] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + return try Bool.encode(self.ready, in: runtime) } - readyDescriptor.setProperty("set") { [weak appContext, self] this, arguments in - guard let appContext else { throw Exceptions.AppContextLost() } - self.ready = try arguments.unownedValue(at: 0).asBool() + readyDescriptor.setProperty("set") { [self] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in + self.ready = try Bool.decode(arguments.unownedValue(at: 0), in: runtime) return .undefined } object.defineProperty("ready", descriptor: readyDescriptor) @@ -948,54 +982,51 @@ public func _decorateModule(object: borrowing JavaScriptObject, ``` Notes: -- Uses real `expo-modules-jsi` types — `JavaScriptRuntime`, `JavaScriptValue` - (`arguments[i]`), `JavaScriptFunction`, `JavaScriptObject` — and - `runtime.createFunction(name) { this, arguments in … }`, the same primitive - `SyncFunctionDefinition.build` uses today (`SyncFunctionDefinition.swift:129`). `this` - is the JS owner; the native receiver is the macro's `self`, so the body calls - `self.add(…)` directly. +- Uses real `expo-modules-jsi` types: `JavaScriptRuntime`, `JavaScriptUnownedValue` + (`this`), `JavaScriptValuesBuffer` (`arguments`), `JavaScriptFunction`, `JavaScriptObject`, + plus the closure-taking `setProperty(_:)`, which calls the same + `runtime.createFunction` primitive `SyncFunctionDefinition.build` uses today + (`SyncFunctionDefinition.swift:129`). `this` is the JS owner; the native receiver is the + macro's `self`, so the body calls `self.add(…)` directly. - **The body is inlined into the `setProperty` closure**, not a separate named function. `_decorateModule` installs each `@JS func` via the closure-taking `JavaScriptObject.setProperty(_:)` overload (which creates the host function under the hood), with the full decode-call-encode body inlined into the closure. (An earlier design emitted a named `` `#name` `` host-function body per member and forwarded to it; inlining tested as no slower and dropped a whole naming/collision scheme, so the named bindings were removed.) The closure captures **`self` - strong** (the closure is what keeps the native callable alive while JS can invoke it, reclaimed - by the JS VM's GC) and **`appContext` weak + guarded** (`guard let appContext else { throw - Exceptions.AppContextLost() }`) — mirroring core's `SyncFunctionDefinition.build` - (`SyncFunctionDefinition.swift:129`). -- **Primitive arguments/returns use a typed fast path; everything else falls back to the dynamic - converter.** For `Bool`/`Int`/`Double`/`String`, decode is a direct **`try arguments[i].asBool()` - / `asInt()` / `asDouble()` / `asString()`** (a validating `JavaScriptValue` accessor that throws - `TypeError` on mismatch) and encode is **`result.toJavaScriptValue(in: runtime)`** (the typed - `JavaScriptRepresentable` conversion). No `getDynamicType()` allocation, no `Any` boxing, no - force-cast. **Other types** (arrays, records, optionals, shared objects, other numeric widths) - fall back to **`T.getDynamicType().cast(jsValue:appContext:) as! T`** / `.castToJS(...)`. The - `~` prefix sugar is **`internal`** to core (`DynamicType.swift:34`) so generated consumer code - can't spell it; the public equivalent is **`T.getDynamicType()`** — a `public nonisolated static` - requirement on `AnyArgument` (`Arguments/AnyArgument.swift:9`) that `~` just wraps - (`AnyDynamicType.castToJS` is public, `AnyDynamicType.swift:74`). The fallback path still boxes - to `Any`; extending the typed fast path to more types is the remaining optimization. -- **Measured (bare-expo BenchmarkingExpoModule, 100k calls):** `@JS` runs ~2.2× faster than the - DSL `Function` and lands within **1.04–1.23×** of `@OptimizedFunction` — e.g. `addStrings` - 66.5 ms (`@JS`) vs 64.1 ms (`@OptimizedFunction`) vs 143 ms (`Function`); `nothing()` 26.6 vs - 21.7 vs 58.4 ms. The realistic arg-marshaling cases (`addStrings`) are essentially tied with the - optimized path; the small fixed gap on `nothing()` is the per-call `guard let appContext` - weak-load that `@OptimizedFunction` skips. This validates the end state: once every synthesized - function is fast by default, `@OptimizedFunction` becomes redundant. + strong**: the closure is what keeps the native callable alive while JS can invoke it, reclaimed + by the JS VM's GC. It captures **no `appContext`**: the hooks dropped the parameter (this + branch), and where a converter needs an app context it recovers one from `runtime`, so there's + no weak capture or `guard let appContext` to thread. +- **Every argument/return is decoded/encoded through `JavaScriptCodable`**: `T.decode(jsValue, in: + runtime)` to read and `T.encode(value, in: runtime)` to write (#26, #27), the same converter the + `@Record` factories use (see [`@Record`](#record)). This replaced the earlier split of a + primitive `asInt()`/`asDouble()` fast path plus a `getDynamicType().cast(...)` dynamic fallback: + there is now one path for all types (primitives, arrays, records, optionals, shared objects, + etc.), each spelled as a `public` `JavaScriptDecodable`/`JavaScriptEncodable` conformance so the + generated code compiles inside user modules with no internal core symbols. The `decode`/`encode` + conversion validates and throws on a kind mismatch. +- **Measured (bare-expo BenchmarkingExpoModule, 100k calls), before the `JavaScriptCodable` and + `appContext`-removal changes:** `@JS` ran ~2.2× faster than the DSL `Function` and within + **1.04–1.23×** of `@OptimizedFunction`, e.g. `addStrings` 66.5 ms (`@JS`) vs 64.1 ms + (`@OptimizedFunction`) vs 143 ms (`Function`); `nothing()` 26.6 vs 21.7 vs 58.4 ms. The realistic + arg-marshaling cases (`addStrings`) are essentially tied with the optimized path; the small fixed + gap on `nothing()` was per-call closure overhead `@OptimizedFunction` skips. This validates the + direction: once every synthesized function is fast by default, `@OptimizedFunction` becomes + redundant. (Re-benchmark after the converter/signature changes to refresh the numbers.) - **Async** (`@JS func … async`) inlines an `async` body that `await`s `self.fn(...)`; the closure being `async` is what selects the **async `setProperty(_:)` overload**, so JS receives a promise. The decode-call-encode shape is otherwise identical to the sync case. Runs on `@JavaScriptActor` - (synchronous until the first suspension) — see [`@JS` › Async functions](#async-functions). -- This sketch decorates a module's single JS object. A **shared object** uses a - `static func _decorateSharedObject` (a class-level form) instead — same inlined per-member closures, but - applied to the **constructor** (statics) and **prototype** (instance funcs + properties), once - per class; receivers resolve from JS `this`. See - [Class-level decoration](#class-level-decoration-phase-2). + (synchronous until the first suspension); see [`@JS` › Async functions](#async-functions). +- This sketch decorates a module's single JS object. A **shared object** uses an + `override class func _decorateSharedObject` (a class-level form) instead: same inlined per-member + closures, but applied to the **constructor** (statics) and **prototype** (instance funcs + + properties), once per class; receivers resolve from JS `this`. See + [Class-level decoration](#class-level-decoration). - **The decorate function is the only generated entry point** (`_decorateModule` for a module, `_decorateSharedObject` for a shared object), `public` because the **runtime calls it** (the `_`-prefix is the convention for runtime-called synthesized members; the macro-name suffix makes - its origin clear and reads naturally at core's call site). It takes the target object core supplies (it doesn't create one) — mirroring + its origin clear and reads naturally at core's call site). It takes the target object core supplies (it doesn't create one), mirroring core's `ObjectDefinition.decorate(object:)`, including its **`borrowing`** parameter: it mutates the object through its reference (`setProperty`/`defineProperty`) without reassigning or taking ownership, so it borrows rather than `inout` (no rebinding) or `consuming` (caller keeps using @@ -1011,13 +1042,14 @@ conversion is decentralized across the dynamic-type converters (coordinated by behavior; it just hoists the arity check into the closure and selects each converter statically. The four pieces: -**1. Arity is a range, not an exact count — and the macro widens that range past what the +**1. Arity is a range, not an exact count, and the macro widens that range past what the DSL can express.** A trailing run of *omittable* parameters may be left off, so core computes a **required** count (total minus the number of trailing omittable params) and a **maximum** (total), then rejects `received < required || received > max` (`SyncFunctionDefinition.swift:55–76`, `JavaScriptUtils.swift:16`). The macro knows both -numbers at expansion time and emits the same range check, returning core's -`InvalidArgsNumberException((received:expected:required:))` rather than an ad-hoc `Exception`. +numbers at expansion time and emits the same range check. **Shipped (#19):** it throws +`Exceptions.ArgumentsRangeMismatch((functionName:received:required:maximum:))` (a typed, +name-bearing error) rather than the ad-hoc `Exception` the first cut used. What counts as **omittable** is where the macro gains over the DSL. The DSL binds a *closure*, and a closure parameter can't carry a Swift default value, so core's only notion of "optional" @@ -1032,18 +1064,18 @@ sees the declaration and can honor **both**: So `required = total − (length of the maximal trailing run of params that are defaulted **or** optional)`. A non-omittable param part-way through stops the run: `f(a: Int = 1, b: Int)` has -`required == 2` (the trailing `b` is required, so `a` can't be dropped either — args are -positional). `f(a: Int, b: String? = nil)` emits `guard (1...2).contains(arguments.count)`; -`f(a: Int, b: Int = 5, c: String? = nil)` emits `guard (1...3).contains(...)`, and at -`received == 1` it calls `self.f(a: arg0)` (Swift fills `b`, `c` is its own `nil` default). +`required == 2` (the trailing `b` is required, so `a` can't be dropped either, args are +positional). `f(a: Int, b: String? = nil)` emits `guard arguments.count >= 1 && arguments.count <= 2`; +`f(a: Int, b: Int = 5, c: String? = nil)` emits `guard arguments.count >= 1 && arguments.count <= 3`, +and at `received == 1` it calls `self.f(a: arg0)` (Swift fills `b`, `c` is its own `nil` default). The cost: an omitted arg can't be threaded through one static call expression, so when a trailing slot may or may not be present the binding **branches on `arguments.count`** and emits one `self.f(...)` shape per valid arity, decoding only the slots present in that branch. This -branch is needed for the **whole omittable trailing run**, optional or defaulted — not just -defaults — because the buffer is exactly JS-arity-sized and **`arguments[i]` traps out of -bounds** (`JavaScriptValuesBuffer.swift:67`); there's no missing-slot padding like core's DSL -path does (`SyncFunctionDefinition.swift:170`). Within a branch, a **defaulted** omitted param +branch is needed for the **whole omittable trailing run**, optional or defaulted, not just +defaults, because the buffer is exactly JS-arity-sized and **`arguments.unownedValue(at: i)` traps +out of bounds** (`JavaScriptValuesBuffer.swift:67`); there's no missing-slot padding like core's +DSL path does (`SyncFunctionDefinition.swift:170`). Within a branch, a **defaulted** omitted param drops its label from the call (Swift applies the default) and an **optional** omitted param is passed `nil`. The decode of each *present* slot is identical across arities (args are positional), so only the call expression multiplies, not the whole body: decode the **required @@ -1058,95 +1090,92 @@ prefix** once, then `switch` on the count. For the body is: ```swift -object.setProperty("resize") { [weak appContext, self] this, arguments in - guard let appContext else { throw Exceptions.AppContextLost() } +object.setProperty("resize") { [self] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in // required/max range; `height` is defaulted and `mode` optional, so the floor is 1 - guard (1...3).contains(arguments.count) else { - throw InvalidArgsNumberException((received: arguments.count, expected: 3, required: 1)) + guard arguments.count >= 1 && arguments.count <= 3 else { + throw Exceptions.ArgumentsRangeMismatch((functionName: "resize", received: arguments.count, required: 1, maximum: 3)) } // required prefix: always present, decoded once - let width = try arguments[0].asInt() + let arg0 = try Int.decode(arguments.unownedValue(at: 0), in: runtime) // one call per arity. Each branch only touches the slots that branch actually has, so no - // `arguments[i]` reads past `count`. Omitted `height` drops its label (Swift applies =100); - // omitted `mode` is passed `nil`. - let result: Bool - switch arguments.count { + // `arguments.unownedValue(at:)` reads past `count`. Omitted `height` drops its label (Swift + // applies =100); omitted `mode` is passed `nil`. + let result = switch arguments.count { case 1: - result = self.resize(width: width) // height = 100, mode = nil + self.resize(width: arg0) // height = 100, mode = nil case 2: - let height = try arguments[1].asInt() - result = self.resize(width: width, height: height, mode: nil) + let arg1 = try Int.decode(arguments.unownedValue(at: 1), in: runtime) + self.resize(width: arg0, height: arg1, mode: nil) default: // 3 - let height = try arguments[1].asInt() - let mode = try (String?).getDynamicType().cast(jsValue: arguments[2], appContext: appContext) as! String? - result = self.resize(width: width, height: height, mode: mode) + let arg1 = try Int.decode(arguments.unownedValue(at: 1), in: runtime) + let arg2 = try String?.decode(arguments.unownedValue(at: 2), in: runtime) + self.resize(width: arg0, height: arg1, mode: arg2) } - return result.toJavaScriptValue(in: runtime) + return try Bool.encode(result, in: runtime) } ``` The `switch` collapses to a single call only when the trailing run is empty (every param required): then `required == max`, the range guard becomes the exact-count check, and the body is the flat decode-all-then-call shown in the earlier `add` example. The moment any trailing -param is omittable — optional **or** defaulted — the per-arity branch is required, since the +param is omittable (optional **or** defaulted) the per-arity branch is required, since the binding can't index a slot the caller didn't pass. -> **Gap (shipped vs. target).** The current codegen (`DecorateModuleBuilder.swift:47–70`) -> emits an **exact** `arguments.count == ` check, throws an ad-hoc -> `Exception(name: "InvalidArgumentCount", …)`, and unconditionally decodes and passes every -> `arg`. So it over-rejects an omitted trailing optional, ignores author-written default -> values entirely, and diverges from the DSL'"'"'s error type. Moving to the required/max range -> (with the default-aware per-arity call shapes above) and `InvalidArgsNumberException` is part -> of completing phase 2. +> **Shipped (#19).** The codegen (`DecorateModuleBuilder.swift`, `requiredArgumentCount` + +> `callAndEncodeLines`) now emits the required/max **range** check with the default-aware +> per-arity call shapes above, throwing the typed +> `Exceptions.ArgumentsRangeMismatch((functionName:received:required:maximum:))`. The earlier +> first cut (exact `arguments.count == ` + ad-hoc `Exception(name: "InvalidArgumentCount")`) +> is gone, so omitted trailing optionals/defaults are honored rather than over-rejected. **2. `null` / `undefined` map by the parameter's static optionality.** Both JS `null` and -`undefined` decode to `nil` for an `Optional` parameter (`DynamicOptionalType.swift:26–31`), -and to a thrown `NullCastException` for a required non-optional one -(`Conversions.swift:328`, e.g. `DynamicBoolType.swift:17`). For a **present** slot the macro -decodes optional params through the dynamic-optional converter (which already encodes the -`null`/`undefined` → `nil` mapping), so it inherits that behavior; an **absent** slot is handled -by point 1's per-arity branch, not the converter. +`undefined` decode to `nil` for an `Optional` parameter, and throw for a required non-optional +one. For a **present** slot the macro decodes optional params through `Optional.decode`, which +already maps `null`/`undefined` → `nil`, so it inherits that behavior; an **absent** slot is +handled by point 1's per-arity branch, not the converter. **Omission** (a trailing slot the arity range allows to be absent) is resolved by point 1, not here: a defaulted param's slot is left out of the call so Swift applies the default; an -optional-no-default param's slot becomes `nil`. The two differ only for an *absent* arg — a +optional-no-default param's slot becomes `nil`. The two differ only for an *absent* arg: a slot **present** as JS `null`/`undefined` always decodes through the converter (so `null` → `nil` for an optional, `null` → throw for a required non-optional, regardless of any default). We do **not** treat an explicit in-range `undefined` as "use the Swift default"; only true omission (a shorter `arguments.count`) triggers a default, matching positional-argument semantics and keeping the per-arity branch the single place defaults are applied. -**3. Type mismatches throw a typed, index-bearing error.** A primitive fast-path accessor -(`asInt()`/`asDouble()`/`asString()`/`asBool()`) throws a conversion error on a kind mismatch -(`Conversions.swift:251`); the dynamic fallback throws `CastingException` / -`ConversionToNativeFailedException` (`Conversions.swift:275`). To match the DSL'"'"'s diagnostics, -each generated decode is wrapped so the failure carries the **argument index** and expected -type, the way core'"'"'s `ArgumentCastException((index:type:))` does (`JavaScriptUtils.swift:63`). -Because the decode runs inside the host-function closure, the throw propagates out as the -function'"'"'s result: a **sync** function surfaces it as a thrown JS error, an **async** one as a -promise rejection (the closure'"'"'s `async`-ness selects the rejecting overload). The first -failing argument short-circuits the rest. - -**4. What'"'"'s inline vs. borrowed from core.** Arity and the encode/decode calls are emitted -**inline** in the closure (that'"'"'s the point of phase 2: no per-call definition walk). The -*converters themselves* are not re-implemented — primitives use the validating -`JavaScriptValue` accessors and everything else calls the existing public -`T.getDynamicType().cast(...)`, so the per-type validation rules (and their error types) -stay in core and remain the single source of truth. The macro chooses *which* converter to -call statically; core still defines *what* each converter accepts. +**3. Type mismatches throw a typed error.** `T.decode(...)` throws a conversion error on a kind +mismatch. Because the decode runs inside the host-function closure, the throw propagates out as +the function's result: a **sync** function surfaces it as a thrown JS error, an **async** one as a +promise rejection (the closure's `async`-ness selects the rejecting overload). The first failing +argument short-circuits the rest. + +> **Gap (shipped vs. target).** Each decode (`DecorateModuleBuilder.decodeStatement`) currently +> lets the converter's own error propagate **unwrapped**: it does *not* yet rewrap with the +> **argument index** and expected type the way the DSL's `ArgumentCastException((index:type:))` +> does (`JavaScriptUtils.swift:63`). So a mismatch reaches JS as the raw converter error without +> "the Nth argument" framing. Wrapping each generated decode to carry the index, to match the DSL +> diagnostic, is remaining polish. + +**4. What's inline vs. borrowed from core.** Arity and the encode/decode calls are emitted +**inline** in the closure (that's the point of direct binding: no per-call definition walk). The +*converters themselves* are not re-implemented: every type goes through its +`JavaScriptDecodable`/`JavaScriptEncodable` conformance (`T.decode`/`T.encode`), so the per-type +validation rules (and their error types) stay in core and remain the single source of truth. The +macro chooses *which* converter to call statically; core still defines *what* each converter +accepts. ### Overloaded functions -Swift lets two `@JS func`s share a name; JS does not — one property holds one function. So N +Swift lets two `@JS func`s share a name; JS does not: one property holds one function. So N `@JS func`s with the same **JS name** can't each `setProperty(jsName)` (the last would silently win). Supporting overloads means collapsing them into **one** host function that dispatches at call time over the only things JS exposes at runtime: `arguments.count` and each argument's **JS kind** (`JavaScriptValue.Kind` + the `isArray()`/`isFunction()`/`isTypedArray()` object -refinements — `JavaScriptValue.swift:521`, `:121`). The macro groups the funcs by JS name and, +refinements: `JavaScriptValue.swift:521`, `:121`). The macro groups the funcs by JS name and, for each group of size > 1, emits a dispatcher instead of one binding per func. There are three tiers, by how cleanly the candidates separate: -**1. Arity-disjoint — always supported.** Candidates whose accepted arity *ranges* (point 1, +**1. Arity-disjoint, always supported.** Candidates whose accepted arity *ranges* (point 1, including defaults/optionals) don't overlap dispatch purely on `arguments.count`. This is the per-arity `switch` already in hand, except each branch calls a *different Swift function* rather than a different default-fill of one: @@ -1155,20 +1184,24 @@ than a different default-fill of one: @JS func at(_ i: Int) -> Element // arity 1 @JS func at(_ i: Int, _ j: Int) -> Element // arity 2 // → -object.setProperty("at") { [self] this, arguments in +object.setProperty("at") { [self] (this: borrowing JavaScriptUnownedValue, arguments: consuming JavaScriptValuesBuffer) in switch arguments.count { - case 1: let i = try arguments[0].asInt(); return self.at(i).toJavaScriptValue(in: runtime) - case 2: let i = try arguments[0].asInt(); let j = try arguments[1].asInt() - return self.at(i, j).toJavaScriptValue(in: runtime) + case 1: + let arg0 = try Int.decode(arguments.unownedValue(at: 0), in: runtime) + return try Element.encode(self.at(arg0), in: runtime) + case 2: + let arg0 = try Int.decode(arguments.unownedValue(at: 0), in: runtime) + let arg1 = try Int.decode(arguments.unownedValue(at: 1), in: runtime) + return try Element.encode(self.at(arg0, arg1), in: runtime) default: - throw InvalidArgsNumberException((received: arguments.count, expected: 2, required: 1)) + throw Exceptions.ArgumentsRangeMismatch((functionName: "at", received: arguments.count, required: 1, maximum: 2)) } } ``` The macro verifies the ranges are disjoint at expansion time; an overlap falls to tier 2/3. -**2. Same-arity, kind-distinguishable — supported when the deciding argument's JS kinds are +**2. Same-arity, kind-distinguishable: supported when the deciding argument's JS kinds are disjoint.** When two candidates share an arity, the dispatcher must pick on the JS kind of the first argument position where their types differ. That works *only if* those types map to **distinct** JS kinds: @@ -1176,7 +1209,7 @@ first argument position where their types differ. That works *only if* those typ ```swift @JS func write(_ s: String) { … } // arg0 kind: .string @JS func write(_ n: Double) { … } // arg0 kind: .number -// → if arguments[0].isString() { … } else if arguments[0].isNumber() { … } else { throw … } +// → if arguments.unownedValue(at: 0).isString() { … } else if arguments.unownedValue(at: 0).isNumber() { … } else { throw … } ``` The macro emits the kind tests in declaration order, calls the first match, and throws an @@ -1187,14 +1220,14 @@ Scope: the first cut distinguishes on a **single** argument position (the leftmo candidates' kinds differ) and treats `null`/`undefined` at that position as matching the optional candidate, if any. Genuinely multi-axis resolution (several positions differing, or arity *and* kind interacting) is a decision tree the macro could grow into, but it is **not** -full Swift overload resolution — anything the single-position kind test can't separate +full Swift overload resolution: anything the single-position kind test can't separate unambiguously is pushed to tier 3 rather than guessed. -**3. Runtime-indistinguishable — compile error.** When same-arity candidates collapse to the +**3. Runtime-indistinguishable: compile error.** When same-arity candidates collapse to the **same** JS kind at every position, no dispatcher can exist and the macro emits a diagnostic pointing at the colliding declarations. The unsupportable cases: -- `Int` vs `Double` (both `.number`), `Int` vs `CGFloat`, etc. — numeric widths are one JS kind. +- `Int` vs `Double` (both `.number`), `Int` vs `CGFloat`, etc.: numeric widths are one JS kind. - Two different record types, or two different shared-object classes (both `.object`). - `[Int]` vs `[String]` (both array), `[String]` vs `Set`. - **Return-type-only** overloads (`f() -> Int` vs `f() -> String`): identical call sites, nothing @@ -1206,18 +1239,19 @@ purely syntactic on the declared types (a fixed Swift-type → JS-kind table), s at expansion time with a clear diagnostic, never a silent last-writer-wins. **Grouping is per JS object, not per name globally.** In a shared object a **static** func and -an **instance** func may share a JS name without being overloads of each other — they decorate +an **instance** func may share a JS name without being overloads of each other: they decorate different JS objects (constructor vs. prototype), so `Cache.get` and `cache.get` coexist, the same way the doc already notes for properties ([static vs. instance](#static-vs-instance-members)). The macro therefore groups overload candidates **within** the static set and **within** the instance set separately; a static and an instance func never dispatch through one host function. By the same logic a `@JS var` and a `@JS func` that resolve to the same JS name on the same object **do** -collide (a property descriptor and a function can't share a key) — that's a tier-3-style +collide (a property descriptor and a function can't share a key): that's a tier-3-style diagnostic, not an overload. > **Status: designed, not built.** Today each `@JS func` emits its own `setProperty(jsName)` -> (`DecorateModuleBuilder.swift:113`), so same-named funcs would currently collide silently. -> Grouping-by-JS-name with the tiered dispatcher (and the tier-3 diagnostic) is phase-2 work; the +> (`DecorateModuleBuilder.swift:227,236`), with no grouping by JS name and no duplicate check, so +> same-named funcs currently collide silently (last `setProperty` wins). +> Grouping-by-JS-name with the tiered dispatcher (and the tier-3 diagnostic) is not built yet; the > safe interim is to **reject** a duplicate JS name outright until the dispatcher lands. ### Function signature surface @@ -1226,12 +1260,12 @@ The validation and overload rules above assume a parameter/return shape the bind This is the boundary of what `@JS func` accepts, and what each non-accepted shape does. **Accepted vs. rejected parameter shapes.** The macro decodes positional arguments by static -type, so a shape it can't reduce to "one JS value per position, of an `AnyArgument` type" is a +type, so a shape it can't reduce to "one JS value per position, of a `JavaScriptDecodable` type" is a **compile-time diagnostic**, not a silent miscompile: | shape | status | rationale | | --- | --- | --- | -| `T` where `T: AnyArgument` (the asserted boundary types) | accepted | the decode path (primitive accessor or `getDynamicType().cast`) | +| `T` where `T: JavaScriptDecodable` (the asserted boundary types) | accepted | the decode path (`T.decode(...)`) | | `T?` / `T = default` | accepted | the omittable-arg rule (arity range + per-arity branch) | | closure param `(@escaping (A...) -> R)` | accepted (new, see below) | synthesized JS-function wrapper | | `T...` (variadic) | rejected | JS has no variadic ABI; author writes `[T]` and spreads in JS | @@ -1244,8 +1278,8 @@ The first cut may start narrower (reject closures, land them later); the table i surface, and every "rejected" row is an explicit diagnostic naming the offending parameter. **Closure / callback arguments.** A JS function passed as an argument is a real need (a -completion callback, a comparator) that the **DSL can't express today** — closures aren't in -`AnyArgument` (`AnyArgument.swift`), so DSL modules fall back to taking a raw `JavaScriptValue` +completion callback, a comparator) that the **DSL can't express today**: a closure type isn't +a convertible boundary type, so DSL modules fall back to taking a raw `JavaScriptValue` and calling it by hand. Because the macro binds the real function, it can decode a function-typed parameter into a Swift closure that wraps the incoming `JavaScriptFunction` (`JavaScriptFunction.call(arguments:)`, `JavaScriptFunction.swift:37`): each Swift call encodes @@ -1259,8 +1293,8 @@ because they differ from a plain value: silently cross. - **Lifetime:** the wrapper holds the `JavaScriptFunction` **strong** (it's the only thing keeping the JS callable reachable), so a stored `@escaping` callback pins it until the Swift - side drops the closure — the same retention reasoning as `@Event` (see [events](#events)). -- **Types:** the closure's own parameters/return must themselves be `AnyArgument`-convertible + side drops the closure, the same retention reasoning as `@Event` (see [events](#events)). +- **Types:** the closure's own parameters/return must themselves be `JavaScriptCodable` (they go through the same encode/decode); an `async` closure return maps to awaiting a JS promise. A non-`@escaping`, synchronously-invoked callback is the simple case and a good first cut; `@escaping` storage is the part that needs the lifetime/thread guarantees above. @@ -1271,9 +1305,11 @@ because they differ from a plain value: cause chain is folded into the message text, not a structured `cause`. A **sync** `@JS func` throws that error **synchronously** at the JS call site; an **async** one **rejects its promise** (`AsyncFunctionDefinition.swift:123`). The arg-validation errors from the section above ride the -same channel: `InvalidArgsNumberException` and `ArgumentCastException` reach JS as ordinary -`Error`s, with the **argument index and expected type baked into the message string** -(`"The 2nd argument cannot be cast to type String"`, `JavaScriptUtils.swift:66`), not as separate +same channel: the macro's `Exceptions.ArgumentsRangeMismatch` and the converters' cast errors +reach JS as ordinary `Error`s. The DSL bakes the **argument index and expected type into the +message string** (`"The 2nd argument cannot be cast to type String"`, `JavaScriptUtils.swift:66`) +via `ArgumentCastException`; the macro doesn't yet rewrap casts with the index (see the gap note +in point 3 above), so a cast error currently reaches JS as the raw converter message, not as separate JS-visible fields. The binding's job is just to *throw the right core exception*; the boundary forwarding and the JS-Error shape are core's, kept identical to the DSL path so error handling in JS doesn't change between the two. @@ -1281,49 +1317,49 @@ JS doesn't change between the two. ### Proof of concept: `@OptimizedFunction` `@OptimizedFunction` (`ExpoModulesOptimizedMacro.swift`, -`OptimizedFunctionHelpers.swift`) is an early **proof of concept** of this idea — it +`OptimizedFunctionHelpers.swift`) is an early **proof of concept** of this idea: it demonstrates that a typed function can be bound with per-argument types instead of the dynamic path. It is **not the target shape**: -- It currently bridges through **ObjC** — a `@convention(block)` wrapper plus a +- It currently bridges through **ObjC**: a `@convention(block)` wrapper plus a hand-built ObjC type-encoding string (e.g. `"d@?dd"`) and an - `OptimizedFunctionDescriptor`. Phase 2 should be **pure Swift** (no ObjC encoding / + `OptimizedFunctionDescriptor`. The direct binding is **pure Swift** (no ObjC encoding / `@convention(block)` round-trip), reading arguments directly through the JSI Swift API. - Its converter table covers only primitives (`Double`/`Int`/`String`/`Bool`/`Void`); - phase 2 covers the full type set above. -- It's opt-in per function. Phase 2 makes the optimized strategy the **default for + the direct binding covers the full type set above via `JavaScriptCodable`. +- It's opt-in per function. The direct binding is the **default for every synthesized function** (sync + async, properties, constructors). **End state: `@OptimizedFunction` is removed.** Once every synthesized function is -optimized by definition, a separate opt-in attribute is redundant — the +optimized by definition, a separate opt-in attribute is redundant: the proof-of-concept macro and its ObjC bridge get deleted. ### Scope & dependencies -- **Author API is unchanged** from phase 1 — same `@JS`/`@ExpoModule` declarations; - only what the macro emits (and the core entry points it targets) changes. The DSL - itself can also remain as a hand-written fallback. -- **First cut needs little new core.** `runtime.createFunction` and the per-type - `(~T.self).cast(jsValue:appContext:)` / `castToJS` converters already exist in - `expo-modules-jsi` + core; the macro just emits the binding that uses them directly - (skipping `[Any]`/`toTuple`). What's needed is a clear **registration entry point** to - attach the generated functions/properties to the module's / shared object's JS object. -- **Deeper optimization is a later layer:** an `Any`-free converter (returns the concrete - `A` instead of `Any`, removing the boxing + force-cast in the sketch above). This is - the real perf dependency, pinned with core when we get there. -- Open: how non-`AnyArgument` / unconvertible types are diagnosed at expansion time +- **Author API is unchanged**: same `@JS`/`@ExpoModule` declarations; only what the macro + emits (and the core entry points it targets) changes. The DSL itself can also remain as a + hand-written fallback. +- **Needs little new core.** `runtime.createFunction` and the per-type `JavaScriptCodable` + conformances (`T.decode`/`T.encode`) already exist in `expo-modules-jsi` + core; the macro + just emits the binding that uses them directly (skipping `[Any]`/`toTuple`). The remaining + core surface is the **registration entry point** that attaches the generated + functions/properties to the module's / shared object's JS object. +- `JavaScriptCodable.decode` already returns the **concrete** type (no `Any` boxing or + force-cast), so the earlier "deeper optimization: Any-free converter" follow-up is subsumed + by the move to `decode`/`encode`. +- Open: how non-`JavaScriptCodable` / unconvertible types are diagnosed at expansion time (compile error vs. fallback to the dynamic path). --- -# Phase 3 — TypeScript type generation +# TypeScript generation Generate the module's `.d.ts` (function signatures, record shapes, **view props + event callbacks**) directly from the annotated Swift source, so JS types are always -in sync with native and never hand-maintained. The macro attributes from phases 1–2 +in sync with native and never hand-maintained. The authoring-surface macros (`@ExpoModule`, `@JS`, `@SharedObject`, `@Record`, `@ViewProps`, `@ExpoView`) form a -declarative, machine-readable description of the module's native + JS surface — the +declarative, machine-readable description of the module's native + JS surface: the basis for generation. The `@ViewProps`-vs-`@Record` distinction ([above](#viewprops-vs-record)) exists partly to make this unambiguous: a `@ViewProps` type maps to a React component-props type with event callbacks; a `@Record` maps to a @@ -1333,14 +1369,14 @@ plain data object. with **no filesystem or network access** (WWDC23, *Expand on Swift Macros*: "Compiler plug-ins run in a sandbox that stops macro implementations from reading files on disk or accessing the network") and must be deterministic for incremental builds. So a -macro can never write a `.ts` file — it can only transform Swift in-place. Type -generation is a **separate source-parsing tool** — and that tool already exists +macro can never write a `.ts` file: it can only transform Swift in-place. Type +generation is a **separate source-parsing tool**, and that tool already exists (`expo-type-information`, below). The generator reads the **declarations from source** (the decided signal), not a -runtime conformance — it doesn't need the code to compile or link. Open design points: +runtime conformance: it doesn't need the code to compile or link. Open design points: how view-props events map to TS callback signatures (payload `Record` → TS object), how -`AnyArgument` payload types resolve to TS, and optional/required field mapping. +`JavaScriptEncodable` payload types resolve to TS, and optional/required field mapping. ### Approach: a SwiftSyntax extractor feeding `expo-type-information`'s emitter @@ -1348,25 +1384,25 @@ how view-props events map to TS callback signatures (payload `Record` → TS obj `expo-type-information` package (`packages/expo-type-information`) is two cleanly separated halves joined by one model: -- **Front end** — `swift/sourcekittenTypeInformation.ts` shells out to the `sourcekitten` +- **Front end**: `swift/sourcekittenTypeInformation.ts` shells out to the `sourcekitten` CLI (`sourcekitten structure --file`, plus `source.request.cursorinfo` byte-offset requests) and folds the result into a typed model in `typeInformation.ts` (`Type`/`RecordType`/`EnumType`/`ModuleClassDeclaration`/`FunctionDeclaration`/…). -- **Back end** — `typescriptGeneration.ts` consumes that model through a +- **Back end**: `typescriptGeneration.ts` consumes that model through a `GenerationContext { fileInfo, module, view, missingTypes }` (`typescriptGeneration.ts:57`) and emits `.d.ts` via the TypeScript factory API; `mockgen.ts` emits mocks. It never - touches SourceKitten — it only sees the model. The CLI commands + touches SourceKitten: it only sees the model. The CLI commands (`generateModuleTypesCommand`, `generateViewTypesCommand`, `generateJSXIntrinsicsCommand`, `moduleInterfaceCommand`, …) sit on top. -Phase 3 swaps the **front end** for a SwiftSyntax extractor and reuses the back end +The generator swaps the **front end** for a SwiftSyntax extractor and reuses the back end verbatim. The seam is the `FileTypeInformation` model (`typeInformation.ts:255`): produce that, and the entire emitter + CLI + mock pipeline works unchanged. **Why SwiftSyntax, not SourceKit, for the v2 surface.** The current SourceKitten front end exists to buy *type resolution* the DSL doesn't write down. `Property("ready") { -self.ready }` has no annotation anywhere — the type only exists after `self.ready` is -resolved — so the tool falls back to a `cursorinfo` request at a byte offset, which its +self.ready }` has no annotation anywhere (the type only exists after `self.ready` is +resolved) so the tool falls back to a `cursorinfo` request at a byte offset, which its own comments flag as *"extremely slow and inefficient"* / *"really costly"* (`sourcekittenTypeInformation.ts:315,352`). It even ships a `PREPROCESS_AND_INFERENCE` mode (`typeInformation.ts:352`) that **rewrites the source to inject `return`s** so @@ -1387,23 +1423,25 @@ Every type the generator needs is now **lexically present**, so the expensive th SourceKit provided is no longer required for the common case. That flips the front-end choice: -- **One front end for the whole toolchain.** The phase-1/2 macros are already SwiftSyntax +- **One front end for the whole toolchain.** The macros are already SwiftSyntax (`ExpoModulesMacros` depends on `swift-syntax` 602.x; `JSMacro.swift`, `RecordMacro.swift`, `EventMacro.swift`, `MacroHelpers.swift`). A SwiftSyntax extractor reuses the *same* - classification logic the macros use — "is this property an event or a value prop?" is the - same `member.type.is(FunctionTypeSyntax.self)` check `@ViewProps` makes at expansion time — + classification logic the macros use: "is this property an event or a value prop?" is the + same `member.type.is(FunctionTypeSyntax.self)` check `@ViewProps` makes at expansion time, instead of maintaining a second, divergent parser. - **No SDK / compile / cursor dependency.** SourceKitten needs `sourcekitd`, an SDK path, and per-file compiler args (`-sdk`, `-target arm64-apple-ios7` are hardcoded, - `sourcekittenTypeInformation.ts:337`). SwiftSyntax parsing needs *only the source text* — + `sourcekittenTypeInformation.ts:337`). SwiftSyntax parsing needs *only the source text*, exactly what the plan already wants ("doesn't need the code to compile or link"). It runs on Linux/CI, not just macOS. - **Faster and deterministic.** The slow `cursorinfo`/preprocess path disappears because there's nothing to resolve. Parsing a package's module files is milliseconds and byte-identical every run (so the golden-file tests stay stable). -**Shape of the extractor.** A small **SwiftPM executable** (`ExpoTypeGen`) lives in the -macros package alongside the macro target, depends on `SwiftSyntax` + `SwiftParser`, and +**Shape of the extractor.** A small **SwiftPM executable** (built as `ExpoModulesScanner`, see +[Status](#status-the-extractor-exists-expomodulesscanner) below; this section's `ExpoTypeGen` name +predates it) lives in the macros package alongside the macro target, depends on +`SwiftSyntax` + `SwiftParser`, and shares the `@JS`/`@ViewProps`/`@Record` classification helpers with the macro target (one source of truth for "what is an event," "what is a field," requiredness inference). It walks each file's syntax and emits the **`FileTypeInformationSerialized` JSON** @@ -1422,51 +1460,88 @@ function-typed fields → the `props`/event-callback path (`PropDeclaration`); ` payload; `@ExpoView` → the view/JSX-intrinsics path (`generateViewTypesCommand` / `generateJSXIntrinsicsCommand`). +### Status: the extractor exists (`ExpoModulesScanner`) + +The SwiftPM executable above is built and on `main`'s `scanner` branch +([PR #21](https://github.com/expo/expo-modules-macros-plugin/pull/21)). It shipped under the +name **`ExpoModulesScanner`** (not `ExpoTypeGen`) because it turned out to serve *two* consumers, +not just type generation, so the binary is broader than the type-generation framing above. It lives +in the macros package next to the macro target, depends on `SwiftSyntax` + `SwiftParser`, and +recognizes the macros syntactically the same way the macros themselves do (a match on the spelled +attribute name), so it sees what the compiler would hand the plugin without compiling. + +It's a subcommand CLI, one mode per consumer: + +- **`scan-modules`** (built): fast path for `expo-modules-autolinking`: top-level `@ExpoModule` + types only (the module class names autolinking registers). This is the new consumer the plan + didn't anticipate; it's why the tool isn't named for type-gen. +- **`scan-exports`** (stubbed): the type-generation front end: the deep walk of each type's + JS-exported surface (`@JS`/`@SharedObject`/`@Record` members, params, return types, fields). + Recognized as a + subcommand but currently exits "not yet implemented"; the member-level extraction and the + `FileTypeInformationSerialized` emission described above land in a later PR (Staging step 1–2). + +Output today is a JSON object: `detections` (per type: macro, name, declaration kind, JS-name +override, every macro argument as label + source text, source location) plus `stats` +(files scanned, files parsed, scan duration). A precompiled `NSRegularExpression` pre-filter skips +parsing any file that doesn't mention a relevant macro attribute (≈20× faster than repeated +`String.contains` on a large tree, where almost nothing matches); the walk prunes `.build`/`Pods`/ +`.git` and avoids a per-entry `stat`. Over `expo/main` (~1600 `.swift` files) a `scan-modules` run +is ~3s with only a handful of files actually parsed. + +Two open decisions deferred with TODOs: whether to detect **nested** `@ExpoModule` types (valid +Swift, currently missed because only file-scope declarations are recorded), and how/whether the +`detections` model maps onto `FileTypeInformationSerialized` vs. staying a separate +autolinking-oriented shape. Path scoping (excluding test fixtures, etc.) is left to the caller by +design. + ### The inference gap, and why it's small SwiftSyntax sees only *what's written*. It cannot resolve a type the author left to the compiler. The cases, and how each is handled: -- **`func` with no `->` is `Void` — not a gap.** Swift does **not** infer a named +- **`func` with no `->` is `Void`, not a gap.** Swift does **not** infer a named function's return type from its body. `@JS func add(...) { a + b }` returns `Void` (the - `a + b` is a discarded expression — `warning: result of operator '+' is unused`), and + `a + b` is a discarded expression, `warning: result of operator '+' is unused`), and `@JS func add(...) { return a + b }` is a hard **compile error** (`error: unexpected non-void return value in void function`), so it never reaches the generator. Single-expression *return* inference is a **closure-only** feature; it never applies to a - `func`. So the absence of `->` is unambiguously `Void` in SwiftSyntax — no resolution + `func`. So the absence of `->` is unambiguously `Void` in SwiftSyntax: no resolution needed. (This is the one place the old tool's preprocess-inject-`return` trick was load-bearing for the *DSL*, because there the body **was** a closure; it's moot for `@JS func`.) -- **Un-annotated property initializers — the real gap.** `@JS var ready = false`, +- **Un-annotated property initializers, the real gap.** `@JS var ready = false`, `@Record`/`@ViewProps` `var name = makeDefault()`, `var tags = ["a", "b"]` have the type - only in the initializer. **Decision: require an explicit type at the JS boundary** — a + only in the initializer. **Decision: require an explicit type at the JS boundary**: a `@JS`/`@Record`/`@ViewProps` stored property without a `typeAnnotation` is a **macro diagnostic with a fix-it** ("annotate the type: `var ready: Bool = false`"). This is cheap (the macros already emit diagnostics, e.g. `@Event let`→`var`), keeps the generator - pure-syntax with no SDK/compile dependency, and only constrains the *exported* surface — + pure-syntax with no SDK/compile dependency, and only constrains the *exported* surface, where being explicit is reasonable anyway and is consistent with the plan already legislating requiredness from the declaration shape. A literal-initializer fallback (`= false`→`Bool`, `= "x"`→`String`, `= 0`→`Int`) can resolve the easy cases *if* we'd rather not diagnose, but `= someCall()` is unrecoverable, so the diagnostic is the primary path and literal resolution at most a convenience. -- **Cross-file type identity — resolved by name, not by a type checker.** Knowing a +- **Cross-file type identity, resolved by name, not by a type checker.** Knowing a `@Union` case payload `MediaConfig` is a `@Record` defined in another file is the same problem the tool already solves: it builds a `typeIdentifierDefinitionMap` (`typeInformation.ts:276`) and tracks `missingTypes` (`GenerationContext.missingTypes`). Run the extractor over all files first to collect every `@Record`/`@Union`/`@SharedObject` - name, then resolve references by exact name in a second pass — no resolution needed, + name, then resolve references by exact name in a second pass: no resolution needed, because the names match exactly. **SourceKit as an optional last resort, not the default.** If we decide *not* to diagnose un-annotated properties, the existing SourceKitten `cursorinfo` path can stay as a fallback -*scoped to just the un-annotated remainder* — but it reintroduces the SDK/compile -dependency this phase avoids, so the diagnostic-first path is preferred and SourceKit is +*scoped to just the un-annotated remainder*, but it reintroduces the SDK/compile +dependency this approach avoids, so the diagnostic-first path is preferred and SourceKit is kept (if at all) only behind a flag. ### Staging -1. **Extractor MVP.** `ExpoTypeGen` SwiftPM executable in the macros package; walk - functions + records, emit `FileTypeInformationSerialized` JSON for a single file; golden - test against the existing model shape. +1. **Extractor MVP.** SwiftPM executable in the macros package, *done* as + `ExpoModulesScanner` ([PR #21](https://github.com/expo/expo-modules-macros-plugin/pull/21)), + which detects the annotated types and the fast `scan-modules` path. Remaining for this step: the + `scan-exports` deep walk of functions + records emitting `FileTypeInformationSerialized` JSON for + a single file, golden-tested against the existing model shape. 2. **Wire into `expo-type-information`.** Add a SwiftSyntax-backed front end that shells out to `ExpoTypeGen` and `deserializeTypeInformation`s the JSON, selectable alongside the SourceKitten one; run the *existing* emitter unchanged. Prove the `.d.ts` matches on a @@ -1480,9 +1555,9 @@ kept (if at all) only behind a flag. ### When generation runs, and the source of truth -`expo-type-information` is a **standalone CLI today, invoked by nothing in the build** — +`expo-type-information` is a **standalone CLI today, invoked by nothing in the build**: no autolinking step, prebuild hook, or `et` command runs it; it's a manual/experimental -generator. Phase 3 has to decide where it plugs in, because that choice shapes packaging, +generator. We have to decide where it plugs in, because that choice shapes packaging, CI, and drift handling. Two models: - **Committed output (preferred).** `.d.ts` is generated and **checked in** next to the @@ -1500,18 +1575,18 @@ CI, and drift handling. Two models: ### Drift enforcement If `.d.ts` is committed it can rot against the Swift source. Guard it the same way the JS -`build/` output is guarded: a **CI check regenerates and diffs** — fail if the working tree +`build/` output is guarded: a **CI check regenerates and diffs**, fail if the working tree `.d.ts` differs from a fresh `ExpoTypeGen` run (`et check-packages`-style "you forgot to rebuild"). Determinism is what makes this viable: SwiftSyntax parsing is byte-identical per run (unlike the old async SourceKitten path, which sorted by `definitionOffset` to *recover* -determinism — `DefinitionOffset`, `typeInformation.ts:153`), so the diff is signal, not +determinism: `DefinitionOffset`, `typeInformation.ts:153`), so the diff is signal, not noise. ### The hand-written-wrapper boundary -Generated `.d.ts` types the **native surface** — what `requireNativeModule("Foo")` returns. +Generated `.d.ts` types the **native surface**: what `requireNativeModule("Foo")` returns. Real modules then hand-write a TS `index.ts` that wraps that surface for JS ergonomics -(re-exports, defaults, convenience overloads). **Decision: Phase 3 generates the native +(re-exports, defaults, convenience overloads). **Decision: generation targets the native interface (the `requireNativeModule` return type + record/union/view-props types) that authors import and re-export/wrap; it does not replace the hand-written wrapper.** The tool already distinguishes these (`moduleInterfaceCommand` / `inlineModulesInterfaceCommand` / @@ -1520,14 +1595,14 @@ the author. Generating or linting the wrapper is a follow-up (see Further ideas) ### Platform-neutral model (Android/KSP parity) -The `FileTypeInformation` model is **platform-neutral** — it describes the JS-facing surface +The `FileTypeInformation` model is **platform-neutral**: it describes the JS-facing surface (functions, records, unions, view props, events), not Swift specifics. So the Android KSP path (`expo-modules-v2-android.md`) can emit the *same* JSON model from Kotlin annotations and feed the -*same* TS emitter, giving one set of `.d.ts` for both platforms. Phase 3 ships Apple-only, +*same* TS emitter, giving one set of `.d.ts` for both platforms. Generation ships Apple-only, but the extractor's output contract (`FileTypeInformationSerialized`) is the shared -interface — keep it free of Swift-only concepts so KSP can target it unchanged. +interface: keep it free of Swift-only concepts so KSP can target it unchanged. -### `AnyArgument` → TypeScript mapping +### `JavaScriptCodable` → TypeScript mapping The non-obvious cases the extractor must map (primitives are trivial; these are the ones that bite). Mirrors the requiredness tables above: @@ -1538,7 +1613,7 @@ that bite). Mirrors the requiredness tables above: | `T?` | `T \| null` | optional *and* nullable (see requiredness tables) | | `[T]` | `T[]` | | | `[K: V]` | `Record` | | -| `Data` / `Uint8Array` typed arrays | `Uint8Array` | `ArrayBuffer` for raw `Data` — confirm against core's converter | +| `Data` / `Uint8Array` typed arrays | `Uint8Array` | `ArrayBuffer` for raw `Data`, confirm against core's converter | | `@Record S` | `S` (generated object type) | fields with inferred requiredness | | `@Union enum` / `Either` | `A \| B \| C` | from case payloads / type args | | `@SharedObject C` | `C` (generated class type) | reference type, by name | @@ -1546,15 +1621,28 @@ that bite). Mirrors the requiredness tables above: | `JavaScriptFunction` / `() -> Void` | function type | `@Event`/view-callback payloads → typed callback | | `Promise` / `async ->` | `Promise` | async return wrapping | | `URL` | `string` | | -| `Date` | `Date` or `string`? | **open** — confirm against core's `Date` converter | - -Open: `Data` (`Uint8Array` vs `ArrayBuffer`) and `Date` (`Date` vs ISO `string`) must match -what core's converters actually produce at runtime — pin against the converter, not guessed. +| `Date` | return: `Date`; parameter: `Date \| number \| string` | encode produces a JS `Date`; decode accepts a `Date`, ms-number, or a `new Date(str)`-parseable string (see below) | + +**`Date` (resolved).** The `Date` converter (`ExpoModulesJSI` `JavaScriptCodable+Date`) is asymmetric, +so the TS is position-dependent: a return value / event payload is always a JS **`Date`**, while a +parameter / decoded field accepts **`Date | number | string`**. Canonical decode contract (the single +source both platforms cite): *a `Date` decodes to exactly what JavaScript's `new Date(value)` produces +for that value.* A JS `Date` reads its `getTime()` instant; a number is milliseconds since the Unix +epoch; a string is parsed by the JS engine's own `Date` constructor (so string parsing is identical on +iOS and Android, since the same engine parses it). A value that yields an `Invalid Date` (`NaN` time) +is a decode error. Encode always constructs `new Date(ms)` from the Swift `Date`'s millisecond instant. +Both sides are absolute epoch-relative instants with no timezone/calendar; resolution is milliseconds +(sub-millisecond precision is dropped). Caveat inherited from `new Date()`: a zone-less date-time +string (`"2021-01-01T00:00:00"`) is read as *local* time, so prefer an explicit `Z`/offset or a +date-only string for an unambiguous instant. + +Open: `Data` (`Uint8Array` vs `ArrayBuffer`) must match what core's converter actually produces at +runtime: pin against the converter, not guessed. ### Verification -Matching the discipline of phases 1–2 (expansion-shape tests don't prove integration): -**generate `.d.ts` for existing real modules — expo-video, expo-image — and diff against +Matching the discipline of the macro work (expansion-shape tests don't prove integration): +**generate `.d.ts` for existing real modules (expo-video, expo-image) and diff against their hand-written types.** Those hand-maintained types are the ground truth; a generated file that matches (modulo wrapper ergonomics) proves the extractor + mapping are correct on a real surface, not just fixtures. State this in the PR. Unit-level: golden-file tests on the @@ -1568,19 +1656,18 @@ real surface, not just fixtures. State this in the PR. Unit-level: golden-file t ## Events model **Requirement: an event's payload must be typed.** The runtime is untyped end to -end — `Module.sendEvent(_:_: [String: Any?])`; `EventDispatcher`'s handler is +end: `Module.sendEvent(_:_: [String: Any?])`; `EventDispatcher`'s handler is `([String: Any]) -> Void`; the Fabric path ends at `dispatchEvent(name, payload: id)` → JSI. So "typed" means **compile-time typing in Swift**, funneled into the existing untyped runtime; no generic runtime type is required. -**Payload type = `AnyArgument`** (or no payload). "Everything we can convert to JS" -is exactly the `AnyArgument` protocol: primitives, `Record`, `Convertible`, shared -objects (`AnySharedObject: AnyArgument`), `UIView`, `JavaScriptObject`, -arrays/typed-arrays, `Either`, `Promise`. `Conversions.anyToJavaScriptValue` already -casts any `AnyArgument` to JS via `getDynamicType().castToJS()`. This is exactly the -`EventEmitter.emit(event:payload: sending P)` overload -(`Core/Events/EventEmitter.swift`), which core now provides on both `BaseModule` and -`SharedObject` via the `EventEmitter` conformance. +**Payload type = `JavaScriptEncodable`** (or no payload). A payload only ever travels +native → JS, so the constraint is the encode half: any `JavaScriptEncodable` type (primitive, +`@Record`, shared object, typed array, array/dictionary/optional of an encodable element, +`RawRepresentable` enum, `@Union`, etc.; see the [type catalog](#javascriptcodable-conforming-types)). +The typed payload `emit` overload (`Core/Events/EventEmitter.swift`) encodes the payload through +`P.encode(payload, in: runtime)`; core provides it on both `BaseModule` and `SharedObject` via the +`EventEmitter` conformance. **`EventDispatcher` is dropped from the macro's output.** It's a heap class found reflectively (`Mirror`) that only forwards to a `([String: Any]) -> Void` handler; @@ -1589,17 +1676,56 @@ the real emitters (`sendEvent`, `dispatchEvent`) take a dict directly. - **Views:** events fold into `@ViewProps` function-typed properties (above). Core dispatches by event name; the synthesized event property calls into a core-held dispatcher rather than core mutating a stored closure on the props instance (the - props are a `struct` — value semantics, so the old "assign `.handler` via `Mirror`" + props are a `struct`, value semantics, so the old "assign `.handler` via `Mirror`" approach in `SwiftUIViewProps.setUpEvents` doesn't apply). Still a *reducing* change vs. the `EventDispatcher` indirection. See [Core dependencies](#core-dependencies). - **Modules & shared objects:** a `@Event` function-typed property; the macro (not core) synthesizes a computed property whose getter returns a closure that dispatches by name - into the `EventEmitter` `emit` overloads — the same typed call on both, now that core + into the `EventEmitter` `emit` overloads: the same typed call on both, now that core conforms `BaseModule` and `SharedObject` to `EventEmitter`. The member is **not** stamped `@JavaScriptActor` (it stays non-isolated; `emit` schedules onto the JS thread - itself), and the macro emits **no `Events(…)`** — `@Event` is self-contained, with no + itself), and the macro emits **no `Events(…)`**: `@Event` is self-contained, with no touch-point in `@ExpoModule`/`@SharedObject`. +## JavaScriptCodable conforming types + +The boundary the macro binds against is **`JavaScriptDecodable`** (JS → native, the input/ +decode half) and **`JavaScriptEncodable`** (native → JS, the result/encode half); +`JavaScriptCodable` is the `typealias` for both +(`ExpoModulesJSI/Coding/JavaScriptCodable.swift`). An argument or a `@Record` field read must +be `JavaScriptDecodable`; a return value, a `@Record` field write, or an event payload must be +`JavaScriptEncodable`; a type used in both directions (the common case, e.g. a record field) +must be `JavaScriptCodable`. These **replace the legacy convertible constraint** the DSL used: +the macro path never references it (core keeps that older protocol only for the hand-written DSL). + +The witnesses are `static func decode(_:in:) throws -> Self` and `static func encode(_:in:) +throws -> JavaScriptValue`, both `@JavaScriptActor`, both returning/taking the **concrete** +type (no `Any` box, no `as!`). The conforming types: + +| Type | Conforms | Where | +|---|---|---| +| `Bool`, `String`, `Double`, `Float`, `CGFloat` | `JavaScriptCodable` | `ExpoModulesJSI` Primitives | +| `Int`, `Int8`, `Int16`, `Int32`, `Int64` | `JavaScriptCodable` | `ExpoModulesJSI` Primitives | +| `UInt`, `UInt8`, `UInt16`, `UInt32`, `UInt64` | `JavaScriptCodable` | `ExpoModulesJSI` Primitives | +| `Data` | `JavaScriptCodable` | `ExpoModulesJSI` Data | +| `Date` | `JavaScriptCodable` | `ExpoModulesJSI` Date (encode → JS `Date`; decode ← `Date`/number/string, see [TS mapping](#javascriptcodable--typescript-mapping)) | +| `JavaScriptValue` (the untyped escape hatch) | `JavaScriptCodable` | `ExpoModulesJSI` Builtins | +| `Array` where `E: JavaScriptCodable` | conditional | `ExpoModulesJSI` Containers | +| `Optional` where `W: JavaScriptCodable` | conditional | `ExpoModulesJSI` Containers | +| `Dictionary` where `V: JavaScriptCodable` | conditional | `ExpoModulesJSI` Containers | +| `TypedArray` + concrete subclasses (`Uint8Array`, …) | `JavaScriptCodable` | core `Coding/…+TypedArray` | +| any `Record` (every `@Record` type) | `JavaScriptCodable` | core `Coding/…+Record` | +| any `SharedObject` subclass | `JavaScriptCodable` | core `Coding/…+SharedObject` | +| `RawRepresentable` `Enumerable` whose `RawValue` is `JavaScriptCodable` (so `String`/integer-backed enums) | `JavaScriptCodable` | core `Coding/…+Enumerable` | + +**Types that still need conformances** (so the boundary covers the full intended surface): +`@Union` enums (the macro must synthesize the `decode`/`encode` witnesses, ordered over the +case payloads), `Either`, `URL`, `Promise`, and `JavaScriptObject`/`JavaScriptFunction` +where used directly. (`Date` is done, see the table above.) Until a type is `JavaScriptCodable`, a `@JS`/`@Record`/`@ViewProps` +member of that type is a compile-time conformance diagnostic (the never-called assertion the +macros emit), not a silent miscompile. Extending the catalog to these is tracked with the +`@Union` and closure-argument work above. + ## Verified core DSL signatures The contract generated code must match. All paths under @@ -1607,7 +1733,7 @@ The contract generated code must match. All paths under **Base classes** - `Module = AnyModule & BaseModule` (`Core/Modules/Module.swift:29`). Module init is - `required init(appContext:)`; overriding `init()` is unavailable — use lifecycle. + `required init(appContext:)`; overriding `init()` is unavailable: use lifecycle. - View base: `public typealias ExpoView = ExpoFabricView` (`Core/ExpoView.swift:3`); `required public init(appContext: AppContext? = nil)` (`Fabric/ExpoFabricView.swift`). @@ -1622,30 +1748,33 @@ The contract generated code must match. All paths under - **No `Exceptions(...)` factory exists** → out of scope. - `Function`/`AsyncFunction`: name + function-reference / name + closure overloads confirmed; existing macro output stays valid. -- `Constant(_ name, get:) -> ConstantDefinition` +- `Constant(_ name, get:) -> ConstantDefinition` (`ConstantFactories.swift:11`); `Constants(...)` dict form is deprecated. (Not - emitted — see the `@Constant` note above.) + emitted, see the `@Constant` note above. Its `Value` is constrained to the legacy DSL + convertible protocol, which `JavaScriptDecodable`/`JavaScriptEncodable` replace on the + macro path.) **Property + setters** - `Property(_ name, get:)` and a no-owner getter overload (`PropertyFactories.swift:11,18`). -- Chained `.set` setter (`PropertyDefinition.swift:105`) — see +- Chained `.set` setter (`PropertyDefinition.swift:105`); see [Property getter + setter](#property-getter--setter). **Views** - `View(_ viewType:, @ViewDefinitionBuilder _ elements) -> ViewDefinition` (`Factories/ViewFactories.swift:10`). Concrete return type, not `AnyViewDefinition`. (SwiftUI overloads exist; UIKit-only for v1.) -- `Prop(_ name, _ setter: @MainActor (ViewType, PropType) -> Void)` +- `Prop(_ name, _ setter: @MainActor (ViewType, PropType) -> Void)` + `defaultValue` overload (`ViewFactories.swift:38,52`). **Superseded by the props - object** — listed for reference. + object**, listed for reference. (`PropType` carries the legacy DSL convertible + constraint, replaced by `JavaScriptDecodable` on the macro path.) - View events (core today): names from `Events("onX")`; dispatcher is a property on the view/props (`var onTap = EventDispatcher()`), discovered via `Mirror` in `setUpEvents`. **Our design drops `EventDispatcher`** (see events model). - View lifecycle: only `OnViewDidUpdateProps(...)` (`ViewFactories.swift:70`). No `OnViewDestroys`. -**Existing macro declarations (the pattern to copy)** — `ios/Core/ExpoModulesMacros.swift`: +**Existing macro declarations (the pattern to copy)**, `ios/Core/ExpoModulesMacros.swift`: - `@attached(peer) public macro JS(_ jsName: String? = nil) = …` - `@attached(member, names: named(_synthesizedDefinition), named(appContext), named(init)) public macro ExpoModule(_ name: String? = nil, classes: [Any.Type] = []) = …` - `@attached(member, names: named(_synthesizedClassDefinition)) public macro SharedObject(_ name: String? = nil) = …` @@ -1660,7 +1789,7 @@ requirement. Until core's declarations and protocol are renamed to match, a modu built with the new plugin won't satisfy the declared `names` and won't conform. **2. Unified `@ViewProps` runtime (UIKit).** The props object is a **`struct`** for -v1 (value semantics, for performance — see [`@ViewProps`](#viewprops)), so the contract +v1 (value semantics, for performance, see [`@ViewProps`](#viewprops)), so the contract must be value-friendly. The macro depends on core adding: 1. A way to register a typed props `Record` **struct** for a UIKit `ExpoView` (a `Props(MyProps.self)` definition element, or a `View` overload). @@ -1671,7 +1800,7 @@ must be value-friendly. The macro depends on core adding: the new value and `oldProps` is the previous one. 4. An overridable `open func onViewPropsChanged(oldProps: Props?)` on the base view (generic or associated props type). `oldProps` is **`nil` on the first application** - (initial mount — no previous value); `view.props` is non-optional and always current. + (initial mount, no previous value); `view.props` is non-optional and always current. 4. **Event dispatch by name**, not by mutating a stored closure: since props are a value type, core can't assign a handler onto a function-typed field the way `setUpEvents` does for `EventDispatcher` today. The synthesized event property @@ -1686,11 +1815,12 @@ SwiftUI's `View` bound on the latter. A `struct` props then fails to conf SwiftUI view → clean compiler error; no macro type-check needed. UIKit binds on the value protocol. (See [`@ViewProps`](#struct-or-class--the-conformance-carries-the-constraint).) -**4. Typed `emit` on modules — ✅ done in core.** Module events synthesize a call to -`emit(event:payload:)`. Core has since added an **`EventEmitter` +**4. Typed `emit` on modules, ✅ done in core.** Module events synthesize a call to the typed +`emit(event:payload:)` overload (the payload is constrained to `JavaScriptEncodable`). Core has +since added an **`EventEmitter` protocol** (`Core/Events/EventEmitter.swift`) whose extension supplies the typed `emit` (plus `emit(event:)` no-payload and `emit(event:payload: JavaScriptValue)`) and conformed -**both** base types — `extension BaseModule: EventEmitter` (`Module.swift:28`) and +**both** base types: `extension BaseModule: EventEmitter` (`Module.swift:28`) and `extension SharedObject: EventEmitter` (`SharedObject.swift:81`). So module and shared-object events share one mechanism and `self.emit(...)` resolves on each; **`@Event` is no longer gated on core.** Note the payload param is `sending P` and the @@ -1698,7 +1828,7 @@ no-payload form is a dedicated overload (not `payload: .undefined`). (See [Module events](#module-events).) **5. No special toolchain/language-mode requirement.** The generated code uses only plain -identifiers (`_decorateModule`, `argN`) — no raw identifiers — so there's no +identifiers (`_decorateModule`, `argN`), no raw identifiers, so there's no `swift_version`/floor concern. (An earlier design used `` `#name` `` raw-identifier bindings, which would have needed the Swift 6.2 compiler; inlining the bodies into the `setProperty` closures removed them.) Build note: the macro plugin binary must be built with a swift-syntax major that the host compiler's @@ -1720,40 +1850,48 @@ non-macro modules, which don't provide `_jsName`). Same for `@SharedObject` clas [Module name](#module-name--retire-the-name-dsl-component).) Until these land, generated `@ViewProps`/`@ExpoView` code won't run; the macro -tests verify expansion shape only, not runtime. (`@Event` is the exception — its core +tests verify expansion shape only, not runtime. (`@Event` is the exception: its core side, `EventEmitter` + the `BaseModule`/`SharedObject` conformances, has shipped, so module/shared-object events can run against today's core.) ## Implementation plan -Phase-1 staging (DSL coverage). Phase 2 (direct JSI binding) and phase 3 (TS -generation) are sequenced after, each gated on its own core/tooling contract — see -those sections. Each step = PR + tests + `node build.js` (rebuild the plugin binary -before committing); each new impl struct → add to `providingMacros` in `Plugin.swift`. +The authoring-surface macros come first; TypeScript generation is sequenced after, each +gated on its own core/tooling contract; see those sections. (Direct JSI binding is not a +separate stage: it's how the `@JS`/`@SharedObject`/`@ExpoModule` macros emit their bindings, +so it lands with each macro.) Each step = PR + tests + `node build.js` (rebuild the plugin +binary before committing); each new impl struct → add to `providingMacros` in `Plugin.swift`. 1. **Helper refactor.** Extract the duplicated param-rewrite/closure builders from `SharedObjectMacro.swift` into `MacroHelpers.swift`; factor `@Record`'s field-descriptor synthesis out of `RecordMacro.swift` for `@ViewProps` reuse. No - behavior change. -2. **Module property setters.** Establishes the property-modifier pattern. (Module - lifecycle is core-only override work; confirm the rename lands in core.) + behavior change. *(`MacroHelpers.swift` exists; `@Record`-field reuse for `@ViewProps` + is not yet exercised since `@ViewProps` is unbuilt.)* +2. **Module property setters. ✅ done.** Shipped with the direct-binding property work + (#14): `JSProperty` emits a `set` accessor for a settable stored/computed var + (`DecorateModuleBuilder.swift`, the get/set descriptor). (Module lifecycle is core-only + override work; confirm the rename lands in core.) 3. **`@ViewProps` macro.** New `ViewPropsMacro.swift` reusing the record field synthesis, plus view-props conformance and function-typed-field event recognition. **Gated on the core props/events contract.** 4. **`@ExpoView` macro.** New `ExpoViewMacro.swift`, `_synthesizedViewDefinition()`, `: ExpoView` check, props metatype arg, `Props(...)`/`View` wrapper, event-name gathering, `@ExpoModule(views:)` wiring. **Gated on the core contract.** -5. **`@SharedObject` parity** — property setters. (No `Events` collection — `@Event` - emits no `Events(…)`.) -6. **`@Event` (modules + shared objects) — ✅ implemented (PR #17)** (`EventMacro.swift`): an +5. **`@SharedObject` parity, instance side ✅ done (#22).** `_decorateSharedObject` + binds instance funcs + properties (incl. setters) onto the prototype, receivers + recovered from JS `this`; `_constructSharedObject` handles the `@JS init`. (No `Events` + collection: `@Event` emits no `Events(…)`.) **Remaining:** static-member routing + (constructor side; needs the `constructor:` parameter + `StaticProperty`), per the + status note in [Static vs. instance members](#static-vs-instance-members). +6. **`@Event` (modules + shared objects), ✅ implemented (PR #17)** (`EventMacro.swift`): an `AccessorMacro` whose getter returns the weak-capturing dispatching closure (`emit(event:payload:)` with a payload, `emit(event:)` without), plus a `PeerMacro` - conformance assertion (payload `AnyArgument` + enclosing type `EventEmitter`, one + conformance assertion (payload `JavaScriptEncodable` + enclosing type `EventEmitter`, one merged helper naming the user's type via the lexical context), default-name derivation stripping the `on` prefix (`onStatusChange` → JS `"statusChange"`), the `@Event("name")` verbatim override, a `let`→`var` fix-it, `@JS`-exclusivity, full shape validation, and `@Event(sync: true)` (selects `emitSync` + the - `@JavaScriptActor` stamp from both member macros). **Async events are ungated** — + `@JavaScriptActor` stamp from both member macros). **Async events are ungated**: core's `EventEmitter` conformances have shipped; `sync: true` is gated on core adding `emitSync`. Remaining: the `@attached(accessor) @attached(peer, names: arbitrary) macro Event(_ name: String? = nil, sync: Bool = false)` declaration in core's @@ -1771,96 +1909,99 @@ before committing); each new impl struct → add to `providingMacros` in `Plugin - `swift test --package-path apple` green after each stage. - **Caveat:** these tests verify *expansion shape*, not real compilation against core. Integration is only proven by building a real module against the paired core - PR — state this in every PR. + PR; state this in every PR. ## Open questions -1. **Core view contract** — `Props(_:)` element vs `View` overload; the +1. **Core view contract**: `Props(_:)` element vs `View` overload; the props-object conformance/marker; the `onViewPropsChanged` base-class shape (generic vs associated type); the function-typed-field event wiring in `setUpEvents`. The macro output can't be pinned until this is decided; owned by the core side. -2. **`GroupView` / `ViewName`** — in scope or follow-up? -3. **Non-convertible record property** — every stored property is a field (no opt-out, - no `@Field`); how is a non-`AnyArgument` stored property handled (diagnostic vs. +2. **`GroupView` / `ViewName`**: in scope or follow-up? +3. **Non-convertible record property**: every stored property is a field (no opt-out, + no `@Field`); how is a non-`JavaScriptCodable` stored property handled (diagnostic vs. silent skip)? -4. **Discriminated unions** — `@Union` matches structurally in declaration order for v1; +4. **Discriminated unions**: `@Union` matches structurally in declaration order for v1; a tag-based mode (`@Union(discriminator: "type")`) for overlapping payload shapes is a deferred follow-up. Also: confirm `Union` is the right attribute name (vs. `JSUnion`). -5. **`@Event(sync:)` core contract** — the macro side is implemented (`sync: true` +5. **`@Event(sync:)` core contract**: the macro side is implemented (`sync: true` selects `emitSync` and gets the `@JavaScriptActor` stamp from `@ExpoModule`/`@SharedObject`), but the **core `emitSync` overloads don't exist yet**; the parameter must stay unused until they land. Open core-side details: exact signatures, and whether conversion failures throw or swallow + warn (the macro assumes non-throwing). The `@Event("customName")` name override is in scope for v1 (verbatim, no `on`-stripping). -6. **Phase 3 front end — decided: SwiftSyntax extractor (`ExpoTypeGen`) feeding +6. **TypeScript-generation front end, decided: SwiftSyntax extractor (`ExpoTypeGen`) feeding `expo-type-information`'s existing TS emitter** (the model at `typeInformation.ts:255` is the seam; the back end is reused verbatim). SourceKitten is retired (or demoted to an optional fallback for un-annotated properties). Remaining open: whether to fully drop the `cursorinfo`/`PREPROCESS_AND_INFERENCE` path or keep it behind a flag, and whether an un-annotated `@JS`/`@Record`/`@ViewProps` property is a hard diagnostic or gets a - literal-initializer fallback. See [Phase 3](#approach-a-swiftsyntax-extractor-feeding-expo-type-informations-emitter). -7. **`@JS struct` / `@JS enum`** — `@JS` is member-only today (funcs/properties/inits); a + literal-initializer fallback. See [TypeScript generation](#approach-a-swiftsyntax-extractor-feeding-expo-type-informations-emitter). +7. **`@JS struct` / `@JS enum`**: `@JS` is member-only today (funcs/properties/inits); a nested `struct`/`enum` is a silent no-op. Either **error and redirect** to `@Record`/`@Union`, or **accept as an alias** for them (the JavaScriptKit-familiar - spelling — see [Prior art](#prior-art-javascriptkit--bridgejs)). Leaning toward the + spelling, see [Prior art](#prior-art-javascriptkit--bridgejs)). Leaning toward the redirect, since our type attributes track distinct core concepts; revisit if the JavaScriptKit muscle-memory argument wins. -Resolved: events → typed payloads (`AnyArgument`), no `EventDispatcher`; view events +Resolved: events → typed payloads (`JavaScriptEncodable`), no `EventDispatcher`; view events fold into `@ViewProps` function-typed fields; **module & shared-object events are a `@Event` function-typed property → an accessor macro whose getter returns a weak-capturing closure (`[weak self]`, so a stored closure can't extend the emitter's lifetime) dispatching into the `EventEmitter` `emit` overloads (typed `emit

(event: payload: sending P)` for a payload, `emit(event:)` for none). The JS wire name strips the Swift property's `on` prefix (`onStatusChange` → `"statusChange"`, acronym-aware; -un-prefixed names verbatim; `@Event("name")` overrides verbatim — the escape hatch for +un-prefixed names verbatim; `@Event("name")` overrides verbatim, the escape hatch for legacy `onX` wire names), matching the bare-name listener idiom of the newest modules (expo-video) while the Swift side keeps the handler-style prefix; view events keep `onX` untouched since a `@ViewProps` function-typed field is a React handler prop. Core already ships `EventEmitter` and conforms both `BaseModule` and `SharedObject` to it, so this is -ungated. An async event is deliberately **not** stamped `@JavaScriptActor` — it stays +ungated. An async event is deliberately **not** stamped `@JavaScriptActor`; it stays non-isolated so it's callable from any thread, and `emit` schedules onto the JS thread itself; the macro emits **no `Events(…)`**. The synchronous opt-in is implemented too: `@Event(sync: true)` → the closure calls `emitSync` (inline dispatch) and `@ExpoModule`/`@SharedObject` stamp that member `@JavaScriptActor` (their only `@Event` -touch-point) — gated on core adding the `emitSync` overloads; unused until then**; +touch-point), gated on core adding the `emitSync` overloads; unused until then**; module + view lifecycle → overridable instance methods (core-called); view props → single `@ViewProps` object (no `@Prop`), exposed as non-optional `self.props` (always current); `onViewPropsChanged(oldProps:)` gets only the previous props, typed `Props?` (`nil` on first application); `@ViewProps` distinct from `@Record`; no `@Constant` macro; `@ExpoView` props via metatype arg; **`@Record`/`@ViewProps` fields -are every stored property — no `@Field` attribute**; field requiredness is +are every stored property, no `@Field` attribute**; field requiredness is **inferred** (default value → optional; optional type → nullable+optional; non-optional no-default → required); **`@ViewProps` may be -a `struct` (UIKit) or `class` (SwiftUI) — `@ViewProps` emits a value vs. class-bound +a `struct` (UIKit) or `class` (SwiftUI): `@ViewProps` emits a value vs. class-bound observable props conformance, and a `struct` used by a SwiftUI view is a compile error via that conformance (no macro type-check). Needs a core props-protocol split; SwiftUI deferred past v1**. Async: **drop Promise-based async; `async` Swift keyword → JS `Promise`; `async` functions stamped `@JavaScriptActor`; body runs synchronously on the JS thread until the first suspension (JS-like)**. Unions: **`@Union` on an enum = typed N-case union (tagged, no `Any?`, → TS `A | B | C`); `Either` kept for inline -2-type; structural match in declaration order, discriminated mode deferred**. Phase 2: +2-type; structural match in declaration order, discriminated mode deferred**. Direct JSI binding: **one generated decorate function (`_decorateModule` for a module, `_decorateSharedObject` for a shared object) decorates the JS object core supplies (module object / SO prototype / constructor) -via the closure-taking `JavaScriptObject.setProperty(_:)` — +via the closure-taking `JavaScriptObject.setProperty(_:)`; the decode-call-encode body is **inlined into each closure** (no separate named binding; tested as no slower than a named func). Mirrors `ObjectDefinition.decorate(object:)` (`borrowing` object). -The closure captures `self` strong + `appContext` weak-guarded (matching `SyncFunctionDefinition`). -Primitives (`Bool/Int/Double/String`) decode via `arguments[i].asDouble()`-style validating -accessors and encode via `toJavaScriptValue(in:)` (no `Any`, no dynamic-type alloc); other types -fall back to `T.getDynamicType().cast/castToJS`. Measured ~2.2× faster than the DSL and within -1.04–1.23× of `@OptimizedFunction`. The entry point uses the `_`-prefix (runtime-called) with a -macro-name suffix; a `@JS func decorate` can't collide since there are no named bindings.** +The hooks take **no `appContext`** (dropped, recovered from `runtime` where needed); a module +closure captures `self` strong, a shared-object closure captures nothing and recovers its receiver +from `this`. **Every argument/return decodes/encodes through `JavaScriptCodable`**: `T.decode(...)` +/ `T.encode(...)` for all types alike (the same converter `@Record` uses), no primitive fast-path / +dynamic-fallback split. Measured (before the `JavaScriptCodable`/`appContext` changes) ~2.2× faster +than the DSL and within 1.04–1.23× of `@OptimizedFunction`. The entry point uses the `_`-prefix +(runtime-called) with a macro-name suffix; a `@JS func decorate` can't collide since there are no +named bindings.** Shared objects: **`static` Swift modifier marks JS-static members (→ constructor, `StaticFunction`) vs instance (→ prototype). An instance member's closure decorates the prototype and recovers its -receiver from JS `this`; a static member's closure decorates the constructor and calls the Swift -`static` member. A JS instance + static member sharing a name don't clash — they're closures on -different JS objects. `static var` needs a core `StaticProperty`. Decoration is **class-level, once -per class** — `static func _decorateSharedObject` sets up constructor (statics) + prototype -(instance funcs *and* properties, receivers from `this`); no per-instance pass. A module decorates -its single object via an instance-method `_decorateModule` on real `self`**. Module name: +receiver from JS `this` (`SharedObject.native(from: this.asObject(in: runtime), as: …)`); a static +member's closure decorates the constructor and calls the Swift `static` member. A JS instance + +static member sharing a name don't clash: they're closures on different JS objects. `static var` +needs a core `StaticProperty`. Decoration is **class-level, once per class**: an +`override class func _decorateSharedObject` sets up constructor (statics) + prototype (instance +funcs *and* properties, receivers from `this`); no per-instance pass. A module decorates its single +object via an instance-method `_decorateModule` on real `self`**. Module name: **retire the `Name` DSL component, the macro never emits it. Instead it synthesizes the fully-resolved name (class name, or the `@ExpoModule("Bar")` arg) as a non-optional `public static let _jsName`, so core never runs `String(describing:)` for a macro @@ -1870,18 +2011,18 @@ fallback. Same for `@SharedObject` class names**. ## Further ideas -Beyond the three phases, but enabled by the same source-level description: +Beyond the current scope, but enabled by the same source-level description: -- **JS/JSI binding generation** — beyond `.d.ts` (phase 3), the same source +- **JS/JSI binding generation**: beyond `.d.ts`, the same source description could drive generated JS glue, reducing hand-written `requireNativeModule` boilerplate. -- **Android / Kotlin parity** — an equivalent annotation-processing path (KSP) so a +- **Android / Kotlin parity**: an equivalent annotation-processing path (KSP) so a single conceptual module surface generates both platforms' definitions and shared TS types. -- **Diagnostics from the description** — lint native modules against the generated +- **Diagnostics from the description**: lint native modules against the generated surface (e.g. flag a `@JS` member whose payload type isn't JS-convertible) at build time rather than runtime. -- **Dropping the inheritance clause entirely** — the `@ExpoModule`/`@ExpoView` +- **Dropping the inheritance clause entirely**: the `@ExpoModule`/`@ExpoView` metatype-arg design (props not tied to the inheritance line) is a step toward protocol-izing the base classes so `: Module` / `: ExpoView` become optional, with the macro supplying all conformances. @@ -1896,12 +2037,12 @@ Beyond the three phases, but enabled by the same source-level description: `@ViewProps` model can't run on UIKit until core ships the props-object decode + `onViewPropsChanged` runtime + function-field event wiring. View stages should not merge ahead of that core work. -- **Whitespace-sensitive tests** — the helper refactor limits duplication that would +- **Whitespace-sensitive tests**: the helper refactor limits duplication that would otherwise multiply formatting-drift breakage. - **Async execution semantics depend on the executor contract.** The "synchronous up to the first suspension" behavior (see [`@JS` › Async functions](#async-functions)) holds only if `@JavaScriptActor`'s custom `SerialExecutor` correctly reports being on the JS thread (SE-0471/SE-0424). If that's wrong, same-thread async calls would hop and lose the JS-like prefix. Also: stamping `@JavaScriptActor` on `async` functions - reverses current macro behavior — verify it doesn't over-isolate functions that + reverses current macro behavior; verify it doesn't over-isolate functions that intentionally hop off the JS thread. From c2c297bbd1752c33e7bb88741702dac9e9eb42e3 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Wed, 8 Jul 2026 10:25:54 +0200 Subject: [PATCH 15/15] Doc: reconcile status through #31; `scan-exports` built --- docs/expo-modules-v2.md | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/expo-modules-v2.md b/docs/expo-modules-v2.md index 56f75e5..0ed7adb 100644 --- a/docs/expo-modules-v2.md +++ b/docs/expo-modules-v2.md @@ -2,16 +2,16 @@ - **Status:** Draft for review (signatures verified against `expo/main` core) - **Author:** Tomasz Sapeta -- **Created:** 2026-05-31 · **Updated:** 2026-06-25 +- **Created:** 2026-05-31 · **Updated:** 2026-07-08 - **Scope:** Apple/Swift macros in this repo (`apple/Sources/ExpoModulesMacros`) -### Implementation status (2026-06-25) +### Implementation status (2026-07-08) This document describes the Apple/Swift macros; the JS-facing surface and TypeScript model are platform-neutral and an Android/KSP path can target them later (see [Platform-neutral model](#platform-neutral-model-androidksp-parity)). -**iOS / Swift**, reconciled against `main` (PRs through #27). **Landed:** direct JSI binding +**iOS / Swift**, reconciled against `main` (PRs through #31). **Landed:** direct JSI binding for `@JS` functions (#13), properties via `defineProperty` (#14), and `@SharedObject` via `_decorateSharedObject` (#22); range-based arity with default/optional-aware per-arity branches throwing `Exceptions.ArgumentsRangeMismatch` (#19: this closed the arity "Gap" the validation @@ -20,12 +20,14 @@ macro (#17); compile-time `@JS`/`@Record` conformance assertions (#15); unowned- closures (#20); IUO-type normalization (#23); the `ExpoModulesScanner` CLI with `scan-modules` (#21); `@Record` field synthesis without `@Field` (#9); `@ExpoModule` auto-conformance to `AnyModule` (#5); `@JS` bindings and `@Record` field conversions through `JavaScriptCodable` -(`decode`/`encode`) instead of the dynamic-type path (#26, #27); dropping the unused `appContext` -parameter from the synthesized JSI hooks (this branch). **Not built (unblocked, pure-macro):** +(`decode`/`encode`) instead of the dynamic-type path (#26, #27); the `scan-exports` deep +type-export walk emitting `FileTypeInformationSerialized` JSON (#25); dropping the unused `appContext` +parameter from the synthesized JSI hooks (#28); the omittable-trailing-arg per-arity `switch` +codegen fix (#29); async `@JS` members stamped `@JavaScriptActor` (#30); unwrapping the async +`@SharedObject` receiver from an owning `this` (#31). **Not built (unblocked, pure-macro):** overloaded-`@JS func` grouping/dispatch (duplicates collide silently today), `@Union`, and the decode index-bearing error wrap. **Not built (blocked on core):** `@ViewProps`/`@ExpoView` (view -contract), `@JS static var` (`StaticProperty`), `@Event(sync:)` runtime (`emitSync`). **Stubbed:** -`scan-exports` (the deep type-export walk for TypeScript generation). Per-section status callouts +contract), `@JS static var` (`StaticProperty`), `@Event(sync:)` runtime (`emitSync`). Per-section status callouts below carry the detail. **Android / Kotlin (KSP):** **nothing built yet.** No KSP processor, no annotations, no @@ -453,6 +455,9 @@ parameter**. > Promise-param `AsyncFunction` overload + `takesPromise` path can be retired from core > once nothing emits them. +> **Status: shipped.** Async members are stamped `@JavaScriptActor` (#30), and the async +> `@SharedObject` receiver is unwrapped from its owning `this` (#31). + ### `@SharedObject` Member macro on a `SharedObject` subclass; synthesizes @@ -1475,11 +1480,9 @@ It's a subcommand CLI, one mode per consumer: - **`scan-modules`** (built): fast path for `expo-modules-autolinking`: top-level `@ExpoModule` types only (the module class names autolinking registers). This is the new consumer the plan didn't anticipate; it's why the tool isn't named for type-gen. -- **`scan-exports`** (stubbed): the type-generation front end: the deep walk of each type's - JS-exported surface (`@JS`/`@SharedObject`/`@Record` members, params, return types, fields). - Recognized as a - subcommand but currently exits "not yet implemented"; the member-level extraction and the - `FileTypeInformationSerialized` emission described above land in a later PR (Staging step 1–2). +- **`scan-exports`** (built, #25): the type-generation front end: the deep walk of each type's + JS-exported surface (`@JS`/`@SharedObject`/`@Record` members, params, return types, fields), + emitting `FileTypeInformationSerialized` JSON. Output today is a JSON object: `detections` (per type: macro, name, declaration kind, JS-name override, every macro argument as label + source text, source location) plus `stats` @@ -1539,9 +1542,9 @@ kept (if at all) only behind a flag. 1. **Extractor MVP.** SwiftPM executable in the macros package, *done* as `ExpoModulesScanner` ([PR #21](https://github.com/expo/expo-modules-macros-plugin/pull/21)), - which detects the annotated types and the fast `scan-modules` path. Remaining for this step: the - `scan-exports` deep walk of functions + records emitting `FileTypeInformationSerialized` JSON for - a single file, golden-tested against the existing model shape. + which detects the annotated types and the fast `scan-modules` path, plus the + `scan-exports` deep walk of functions + records emitting `FileTypeInformationSerialized` JSON + ([PR #25](https://github.com/expo/expo-modules-macros-plugin/pull/25)). 2. **Wire into `expo-type-information`.** Add a SwiftSyntax-backed front end that shells out to `ExpoTypeGen` and `deserializeTypeInformation`s the JSON, selectable alongside the SourceKitten one; run the *existing* emitter unchanged. Prove the `.d.ts` matches on a