Skip to content

Commit 36e02d7

Browse files
authored
Bind @SharedObject members directly into the JS object via _decorateSharedObject (#22)
1 parent 5d086b6 commit 36e02d7

7 files changed

Lines changed: 723 additions & 271 deletions

File tree

apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift

Lines changed: 133 additions & 105 deletions
Large diffs are not rendered by default.

apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift

Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -244,55 +244,3 @@ private func hasAppContextInitializer(_ classDecl: ClassDeclSyntax) -> Bool {
244244
}
245245
return false
246246
}
247-
248-
// MARK: - Member builders
249-
250-
private func collectProperties(
251-
varDecl: VariableDeclSyntax,
252-
attribute: AttributeSyntax
253-
) -> [JSProperty] {
254-
let jsNameOverride = jsNameArgument(of: attribute)
255-
// A `let` is never settable; only `var` bindings can carry a setter.
256-
let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
257-
258-
return varDecl.bindings.compactMap { binding in
259-
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
260-
return nil
261-
}
262-
let swiftName = ident.identifier.text
263-
// Prefer the explicit annotation; recover the type from a literal default (`var x = false`)
264-
// when there's none. `nil` falls back to inference at the use site.
265-
let valueType = binding.typeAnnotation?.type.trimmedDescription
266-
?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) }
267-
return JSProperty(
268-
swiftName: swiftName,
269-
jsName: jsNameOverride ?? swiftName,
270-
valueType: valueType,
271-
isSettable: isVar && bindingIsSettable(binding)
272-
)
273-
}
274-
}
275-
276-
/// Whether a `var` binding is settable from JS. A stored property (no accessor block) is settable;
277-
/// a computed property is settable only when it declares an explicit `set` accessor. A getter-only
278-
/// computed property (`{ get }` or a single getter body) stays read-only. `willSet`/`didSet`
279-
/// observers imply stored storage, which is also settable.
280-
private func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool {
281-
guard let accessorBlock = binding.accessorBlock else {
282-
return true
283-
}
284-
switch accessorBlock.accessors {
285-
case .accessors(let accessors):
286-
return accessors.contains { accessor in
287-
switch accessor.accessorSpecifier.tokenKind {
288-
case .keyword(.set), .keyword(.willSet), .keyword(.didSet):
289-
return true
290-
default:
291-
return false
292-
}
293-
}
294-
case .getter:
295-
return false
296-
}
297-
}
298-
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import SwiftSyntax
2+
3+
/// A `@JS init` collected for direct JSI binding. A shared-object type has at most one (JS classes
4+
/// have a single constructor). Instead of a `Constructor { … }` DSL entry, the macro synthesizes a
5+
/// static `_constructSharedObject(...)` that decodes the JS arguments and returns a fresh instance;
6+
/// unlike the method/property bindings it produces the native instance rather than recovering one.
7+
internal struct JSConstructor {
8+
let parameters: [FunctionParameterSyntax]
9+
10+
init(initDecl: InitializerDeclSyntax) {
11+
self.parameters = Array(initDecl.signature.parameterClause.parameters)
12+
}
13+
14+
/// The body statements, indented with `indent`: arity guard, per-argument decode (primitives via a
15+
/// typed accessor, others via the dynamic converter), then `return <Type>(label: arg0, …)`.
16+
private func bodyStatements(typeName: String, indent: String) -> String {
17+
var lines: [String] = []
18+
19+
lines.append(
20+
"""
21+
guard arguments.count == \(parameters.count) else {
22+
throw Exceptions.ArgumentsRangeMismatch((functionName: "\(typeName)", received: arguments.count, required: \(parameters.count), maximum: \(parameters.count)))
23+
}
24+
""")
25+
26+
var callArguments: [String] = []
27+
for (index, parameter) in parameters.enumerated() {
28+
let type = parameter.type.trimmedDescription
29+
30+
if let accessor = fastDecodeAccessor(for: type) {
31+
lines.append("let arg\(index) = try arguments.unownedValue(at: \(index)).\(accessor)()")
32+
} else {
33+
let exprType = expressionType(type)
34+
lines.append(
35+
"let arg\(index) = try \(exprType).getDynamicType().cast(jsValue: arguments[\(index)], appContext: appContext) as! \(exprType)")
36+
}
37+
38+
let label = parameter.firstName.text
39+
callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
40+
}
41+
42+
lines.append("return \(typeName)(\(callArguments.joined(separator: ", ")))")
43+
44+
return lines
45+
.flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
46+
.map { indent + $0 }
47+
.joined(separator: "\n")
48+
}
49+
50+
/// The static `_constructSharedObject` entry point the runtime calls to build an instance from JS
51+
/// arguments, returning the concrete type. `this`/`appContext` may go unreferenced, which is harmless.
52+
func buildConstructor(typeName: String) -> DeclSyntax {
53+
return """
54+
@JavaScriptActor
55+
public static func _constructSharedObject(this: JavaScriptValue, arguments: borrowing JavaScriptValuesBuffer, in runtime: JavaScriptRuntime, appContext: AppContext) throws -> \(raw: typeName) {
56+
\(raw: bodyStatements(typeName: typeName, indent: " "))
57+
}
58+
"""
59+
}
60+
}

apple/Sources/ExpoModulesMacros/MacroHelpers.swift

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,76 @@ internal func expressionType(_ type: String) -> String {
256256
}
257257
return type.dropLast() + "?"
258258
}
259+
260+
// MARK: - @JS property collection
261+
262+
/// Collects the `@JS var` bindings of a declaration into `JSProperty` values for direct JSI binding.
263+
/// Shared between `@ExpoModule` and `@SharedObject` — the resulting properties are receiver-agnostic;
264+
/// the decorator that emits them picks the receiver (module `self` vs. shared-object `_self`).
265+
internal func collectProperties(
266+
varDecl: VariableDeclSyntax,
267+
attribute: AttributeSyntax
268+
) -> [JSProperty] {
269+
let jsNameOverride = jsNameArgument(of: attribute)
270+
// A `let` is never settable; only `var` bindings can carry a setter.
271+
let isVar = varDecl.bindingSpecifier.tokenKind == .keyword(.var)
272+
273+
return varDecl.bindings.compactMap { binding in
274+
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self) else {
275+
return nil
276+
}
277+
let swiftName = ident.identifier.text
278+
// Prefer the explicit annotation; recover the type from a literal default (`var x = false`)
279+
// when there's none. `nil` falls back to inference at the use site.
280+
let valueType = binding.typeAnnotation?.type.trimmedDescription
281+
?? binding.initializer.flatMap { inferredLiteralType(of: $0.value) }
282+
return JSProperty(
283+
swiftName: swiftName,
284+
jsName: jsNameOverride ?? swiftName,
285+
valueType: valueType,
286+
isSettable: isVar && bindingIsSettable(binding)
287+
)
288+
}
289+
}
290+
291+
/// Whether a `var` binding is settable from JS. A stored property (no accessor block) is settable;
292+
/// a computed property is settable only when it declares an explicit `set` accessor. A getter-only
293+
/// computed property (`{ get }` or a single getter body) stays read-only. `willSet`/`didSet`
294+
/// observers imply stored storage, which is also settable.
295+
private func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool {
296+
guard let accessorBlock = binding.accessorBlock else {
297+
return true
298+
}
299+
switch accessorBlock.accessors {
300+
case .accessors(let accessors):
301+
return accessors.contains { accessor in
302+
switch accessor.accessorSpecifier.tokenKind {
303+
case .keyword(.set), .keyword(.willSet), .keyword(.didSet):
304+
return true
305+
default:
306+
return false
307+
}
308+
}
309+
case .getter:
310+
return false
311+
}
312+
}
313+
314+
/// The throwing `JavaScriptUnownedValue` accessor that decodes the given primitive type directly
315+
/// (`asDouble()` for `Double`, etc.), bypassing the dynamic-type converter. Returns `nil` for types
316+
/// without a dedicated accessor (arrays, records, optionals, shared objects, other numeric widths),
317+
/// which decode through `getDynamicType().cast(...)`.
318+
func fastDecodeAccessor(for type: String) -> String? {
319+
switch type {
320+
case "Bool":
321+
return "asBool"
322+
case "Int":
323+
return "asInt"
324+
case "Double":
325+
return "asDouble"
326+
case "String":
327+
return "asString"
328+
default:
329+
return nil
330+
}
331+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import SwiftSyntax
2+
3+
/// Where a directly-bound closure gets the Swift value it calls into. A module is a singleton, so its
4+
/// bindings call `self` and ignore the JS `this`; a shared object has a distinct native instance per JS
5+
/// object, so its bindings recover the typed receiver from `this`.
6+
internal enum Receiver {
7+
/// The module singleton; the closure captures `self` strong.
8+
case module
9+
/// A shared object of the given concrete type; the closure captures nothing and recovers the receiver
10+
/// from `this` per call.
11+
case sharedObject(typeName: String)
12+
13+
/// The expression the body calls members on: `self` for a module, `_self` (bound by `unwrapStatement`)
14+
/// for a shared object. The leading underscore avoids colliding with a user member like `var owner`.
15+
var callee: String {
16+
switch self {
17+
case .module:
18+
return "self"
19+
case .sharedObject:
20+
return "_self"
21+
}
22+
}
23+
24+
/// The JS object the decorator binds members onto, matching its first parameter: `object` for a
25+
/// module (its own JS object), `prototype` for a shared object (the shared class prototype).
26+
var decoratedObject: String {
27+
switch self {
28+
case .module:
29+
return "object"
30+
case .sharedObject:
31+
return "prototype"
32+
}
33+
}
34+
35+
/// The leading body line binding the receiver, or `nil` for a module (it reads `self` directly). For a
36+
/// shared object, `native(from:as:)` recovers the typed instance from the borrowed `this`, throwing on
37+
/// a foreign object or a type mismatch.
38+
var unwrapStatement: String? {
39+
switch self {
40+
case .module:
41+
return nil
42+
case .sharedObject(let typeName):
43+
return "let _self = try SharedObject.native(from: this.asObject(in: runtime), as: \(typeName).self)"
44+
}
45+
}
46+
47+
/// The capture-clause fragment (with a trailing space, or empty when nothing is captured). A module
48+
/// captures `self` strong; a shared object captures nothing of the instance. `appContext`, when used,
49+
/// is captured weak in both cases.
50+
func captureClause(usesAppContext: Bool) -> String {
51+
switch self {
52+
case .module:
53+
return usesAppContext ? "[weak appContext, self] " : "[self] "
54+
case .sharedObject:
55+
return usesAppContext ? "[weak appContext] " : ""
56+
}
57+
}
58+
}

0 commit comments

Comments
 (0)