Skip to content

Commit 555585c

Browse files
committed
feat: Add per-class identityMode via @js macro parameter
Add identityMode: Bool parameter to @js macro. When set to true on a class, that class uses pointer identity caching. When false or not set, the class uses the bridge-js.config.json default. Identity is resolved entirely at codegen time - no runtime option. Classes with identity mode get static __identityCache passed to __wrap. Classes without it pass null. No runtime branching in __construct. Resolution: @js(identityMode: true/false) > bridge-js.config.json > default (off).
1 parent f562fcb commit 555585c

108 files changed

Lines changed: 1673 additions & 364 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1197,6 +1197,14 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
11971197
return nil
11981198
}
11991199

1200+
private func extractIdentityMode(from jsAttribute: AttributeSyntax) -> Bool? {
1201+
guard let arguments = jsAttribute.arguments?.as(LabeledExprListSyntax.self),
1202+
let identityArg = arguments.first(where: { $0.label?.text == "identityMode" })
1203+
else { return nil }
1204+
let text = identityArg.expression.trimmedDescription
1205+
return text == "true"
1206+
}
1207+
12001208
override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind {
12011209
guard let jsAttribute = node.attributes.firstJSAttribute else { return .skipChildren }
12021210

@@ -1384,14 +1392,16 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
13841392
for: node,
13851393
message: "Class visibility must be at least internal"
13861394
)
1395+
let classIdentityMode = extractIdentityMode(from: jsAttribute)
13871396
let exportedClass = ExportedClass(
13881397
name: name,
13891398
swiftCallName: swiftCallName,
13901399
explicitAccessControl: explicitAccessControl,
13911400
constructor: nil,
13921401
methods: [],
13931402
properties: [],
1394-
namespace: namespaceResult.namespace
1403+
namespace: namespaceResult.namespace,
1404+
identityMode: classIdentityMode
13951405
)
13961406
let uniqueKey = makeKey(name: name, namespace: namespaceResult.namespace)
13971407

Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ public struct BridgeJSLink {
2525
self.sharedMemory = sharedMemory
2626
}
2727

28+
/// The identity mode from the config file, resolved from skeletons.
29+
var configIdentityMode: String {
30+
skeletons.compactMap(\.exported).compactMap(\.identityMode).first ?? "none"
31+
}
32+
33+
/// Whether a class should use identity caching based on its annotation and the config default.
34+
private func shouldUseIdentityCache(for klass: ExportedClass) -> Bool {
35+
// Per-class annotation takes priority
36+
if let classOverride = klass.identityMode {
37+
return classOverride
38+
}
39+
// Fall back to config default
40+
return configIdentityMode == "pointer"
41+
}
42+
2843
mutating func addSkeletonFile(data: Data) throws {
2944
do {
3045
let unified = try JSONDecoder().decode(BridgeJSSkeleton.self, from: data)
@@ -110,7 +125,7 @@ public struct BridgeJSLink {
110125
return obj;
111126
};
112127
113-
if (!shouldUseIdentityMap) {
128+
if (!identityCache) {
114129
return makeFresh(null);
115130
}
116131
@@ -937,7 +952,6 @@ public struct BridgeJSLink {
937952
printer.write("export function createInstantiator(options: {")
938953
printer.indent {
939954
printer.write("imports: Imports;")
940-
printer.write("identityMode?: \"none\" | \"pointer\";")
941955
}
942956
printer.write("}, swift: any): Promise<{")
943957
printer.indent {
@@ -983,11 +997,6 @@ public struct BridgeJSLink {
983997

984998
try printer.indent {
985999
printer.write(lines: generateVariableDeclarations())
986-
let configIdentityMode = skeletons.compactMap(\.exported).compactMap(\.identityMode).first ?? "none"
987-
printer.write("const identityMode = options.identityMode ?? \"\(configIdentityMode)\";")
988-
printer.write(
989-
"const shouldUseIdentityMap = identityMode === \"pointer\" && typeof WeakRef !== \"undefined\" && typeof FinalizationRegistry !== \"undefined\";"
990-
)
9911000

9921001
let bodyPrinter = CodeFragmentPrinter()
9931002
let allStructs = exportedSkeletons.flatMap { $0.structs }
@@ -1993,15 +2002,24 @@ extension BridgeJSLink {
19932002
dtsExportEntryPrinter.write("\(klass.name): {")
19942003
jsPrinter.write("class \(klass.name) extends SwiftHeapObject {")
19952004

1996-
// Always add __construct and constructor methods for all classes
2005+
// Per-class identity mode: determine at codegen time whether this class uses identity caching
2006+
let useIdentity = shouldUseIdentityCache(for: klass)
19972007
jsPrinter.indent {
1998-
jsPrinter.write("static __identityCache = new Map();")
1999-
jsPrinter.nextLine()
2008+
if useIdentity {
2009+
jsPrinter.write("static __identityCache = new Map();")
2010+
jsPrinter.nextLine()
2011+
}
20002012
jsPrinter.write("static __construct(ptr) {")
20012013
jsPrinter.indent {
2002-
jsPrinter.write(
2003-
"return SwiftHeapObject.__wrap(ptr, instance.exports.bjs_\(klass.abiName)_deinit, \(klass.name).prototype, \(klass.name).__identityCache);"
2004-
)
2014+
if useIdentity {
2015+
jsPrinter.write(
2016+
"return SwiftHeapObject.__wrap(ptr, instance.exports.bjs_\(klass.abiName)_deinit, \(klass.name).prototype, \(klass.name).__identityCache);"
2017+
)
2018+
} else {
2019+
jsPrinter.write(
2020+
"return SwiftHeapObject.__wrap(ptr, instance.exports.bjs_\(klass.abiName)_deinit, \(klass.name).prototype, null);"
2021+
)
2022+
}
20052023
}
20062024
jsPrinter.write("}")
20072025
jsPrinter.nextLine()

Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,7 @@ public struct ExportedClass: Codable, NamespacedExportedType {
784784
public var methods: [ExportedFunction]
785785
public var properties: [ExportedProperty]
786786
public var namespace: [String]?
787+
public var identityMode: Bool? // nil = use config default, true/false = override
787788

788789
public init(
789790
name: String,
@@ -792,7 +793,8 @@ public struct ExportedClass: Codable, NamespacedExportedType {
792793
constructor: ExportedConstructor? = nil,
793794
methods: [ExportedFunction],
794795
properties: [ExportedProperty] = [],
795-
namespace: [String]? = nil
796+
namespace: [String]? = nil,
797+
identityMode: Bool? = nil
796798
) {
797799
self.name = name
798800
self.swiftCallName = swiftCallName
@@ -801,6 +803,7 @@ public struct ExportedClass: Codable, NamespacedExportedType {
801803
self.methods = methods
802804
self.properties = properties
803805
self.namespace = namespace
806+
self.identityMode = identityMode
804807
}
805808
}
806809

Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -107,43 +107,51 @@ import Testing
107107
}
108108

109109
@Test
110-
func emitsIdentityModeOptionAndRuntimeScaffolding() throws {
111-
let url = Self.inputsDirectory.appendingPathComponent("SwiftClass.swift")
110+
func perClassIdentityModeFromAnnotation() throws {
111+
let url = Self.inputsDirectory.appendingPathComponent("IdentityModeClass.swift")
112112
let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8))
113-
let swiftAPI = SwiftToSkeleton(progress: .silent, moduleName: "TestModule", exposeToGlobal: false)
114-
swiftAPI.addSourceFile(sourceFile, inputFilePath: "SwiftClass.swift")
115-
let outputSkeleton = try swiftAPI.finalize()
116-
let bridgeJSLink = BridgeJSLink(
117-
skeletons: [
118-
outputSkeleton
119-
],
120-
sharedMemory: false
113+
let swiftAPI = SwiftToSkeleton(
114+
progress: .silent,
115+
moduleName: "TestModule",
116+
exposeToGlobal: false,
117+
identityMode: nil // no config default
121118
)
119+
swiftAPI.addSourceFile(sourceFile, inputFilePath: "IdentityModeClass.swift")
120+
let outputSkeleton = try swiftAPI.finalize()
122121

123-
let (outputJs, outputDts) = try bridgeJSLink.link()
122+
// Verify skeleton has per-class identity mode (not captured by snapshots)
123+
let cachedClass = outputSkeleton.exported!.classes.first { $0.name == "CachedModel" }
124+
let uncachedClass = outputSkeleton.exported!.classes.first { $0.name == "UncachedModel" }
125+
let explicitlyUncachedClass = outputSkeleton.exported!.classes.first { $0.name == "ExplicitlyUncachedModel" }
126+
#expect(cachedClass?.identityMode == true)
127+
#expect(uncachedClass?.identityMode == nil)
128+
#expect(explicitlyUncachedClass?.identityMode == false)
124129

125-
#expect(outputDts.contains("identityMode?: \"none\" | \"pointer\";"))
126-
#expect(
127-
outputJs.contains("const identityMode = options.identityMode ?? \"none\";")
128-
)
129-
#expect(
130-
outputJs.contains(
131-
"const shouldUseIdentityMap = identityMode === \"pointer\" && typeof WeakRef !== \"undefined\" && typeof FinalizationRegistry !== \"undefined\";"
132-
)
133-
)
134-
#expect(outputJs.contains("if (!shouldUseIdentityMap) {"))
135-
#expect(outputJs.contains("state.identityMap?.delete(state.pointer);"))
136-
#expect(!outputJs.contains("static finalizerByDeinit"))
137-
#expect(!outputJs.contains("static __getFinalizer"))
138-
#expect(!outputJs.contains("static identityCache = new Map();"))
139-
#expect(!outputJs.contains("identityCacheByDeinit"))
140-
#expect(!outputJs.contains("identityCache ??"))
141-
#expect(outputJs.contains("static __wrap(pointer, deinit, prototype, identityCache)"))
142-
#expect(outputJs.contains("static __identityCache = new Map();"))
143-
#expect(
144-
outputJs.contains(
145-
"return SwiftHeapObject.__wrap(ptr, instance.exports.bjs_Greeter_deinit, Greeter.prototype, Greeter.__identityCache);"
146-
)
130+
// Verify generated JS via snapshot
131+
let bridgeJSLink = BridgeJSLink(skeletons: [outputSkeleton], sharedMemory: false)
132+
try snapshot(bridgeJSLink: bridgeJSLink, name: "IdentityModeClass.PerClass")
133+
}
134+
135+
@Test
136+
func perClassIdentityModeWithConfigOverride() throws {
137+
let url = Self.inputsDirectory.appendingPathComponent("IdentityModeClass.swift")
138+
let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8))
139+
let swiftAPI = SwiftToSkeleton(
140+
progress: .silent,
141+
moduleName: "TestModule",
142+
exposeToGlobal: false,
143+
identityMode: "pointer" // config says pointer for all classes
147144
)
145+
swiftAPI.addSourceFile(sourceFile, inputFilePath: "IdentityModeClass.swift")
146+
let outputSkeleton = try swiftAPI.finalize()
147+
148+
// When config says "pointer", classes without annotation get identity mode from config.
149+
// But @JS(identityMode: false) should still override to "without identity".
150+
let explicitlyUncachedClass = outputSkeleton.exported!.classes.first { $0.name == "ExplicitlyUncachedModel" }
151+
#expect(explicitlyUncachedClass?.identityMode == false)
152+
153+
// Verify generated JS via snapshot
154+
let bridgeJSLink = BridgeJSLink(skeletons: [outputSkeleton], sharedMemory: false)
155+
try snapshot(bridgeJSLink: bridgeJSLink, name: "IdentityModeClass.ConfigPointer")
148156
}
149157
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import JavaScriptKit
2+
3+
@JS(identityMode: true)
4+
class CachedModel {
5+
@JS var name: String
6+
7+
@JS init(name: String) {
8+
self.name = name
9+
}
10+
}
11+
12+
@JS
13+
class UncachedModel {
14+
@JS var value: Int
15+
16+
@JS init(value: Int) {
17+
self.value = value
18+
}
19+
}
20+
21+
@JS(identityMode: false)
22+
class ExplicitlyUncachedModel {
23+
@JS var count: Int
24+
25+
@JS init(count: Int) {
26+
self.count = count
27+
}
28+
}

0 commit comments

Comments
 (0)