Skip to content

Commit 64fa9ad

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 regardless of the global config. Resolution priority: per-class annotation > config file > runtime option. Each class's __construct decides independently whether to pass the identity cache or null to __wrap. The global shouldUseIdentityMap const is replaced with per-class __useIdentityCache flags and a hasIdentitySupport capability check. Runtime options.identityMode still overrides everything for benchmarking.
1 parent f562fcb commit 64fa9ad

57 files changed

Lines changed: 1282 additions & 203 deletions

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: 26 additions & 7 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,9 +125,9 @@ public struct BridgeJSLink {
110125
return obj;
111126
};
112127
113-
if (!shouldUseIdentityMap) {
114-
return makeFresh(null);
115-
}
128+
if (!identityCache) {
129+
return makeFresh(null);
130+
}
116131
117132
const cached = identityCache.get(pointer)?.deref();
118133
if (cached && !cached.__swiftHeapObjectState.hasReleased) {
@@ -983,10 +998,9 @@ public struct BridgeJSLink {
983998

984999
try printer.indent {
9851000
printer.write(lines: generateVariableDeclarations())
986-
let configIdentityMode = skeletons.compactMap(\.exported).compactMap(\.identityMode).first ?? "none"
9871001
printer.write("const identityMode = options.identityMode ?? \"\(configIdentityMode)\";")
9881002
printer.write(
989-
"const shouldUseIdentityMap = identityMode === \"pointer\" && typeof WeakRef !== \"undefined\" && typeof FinalizationRegistry !== \"undefined\";"
1003+
"const hasIdentitySupport = typeof WeakRef !== \"undefined\" && typeof FinalizationRegistry !== \"undefined\";"
9901004
)
9911005

9921006
let bodyPrinter = CodeFragmentPrinter()
@@ -1993,14 +2007,19 @@ extension BridgeJSLink {
19932007
dtsExportEntryPrinter.write("\(klass.name): {")
19942008
jsPrinter.write("class \(klass.name) extends SwiftHeapObject {")
19952009

1996-
// Always add __construct and constructor methods for all classes
2010+
// Per-class identity mode: determine at codegen time whether this class uses identity caching
2011+
let useIdentity = shouldUseIdentityCache(for: klass)
19972012
jsPrinter.indent {
19982013
jsPrinter.write("static __identityCache = new Map();")
2014+
jsPrinter.write("static __useIdentityCache = \(useIdentity ? "true" : "false");")
19992015
jsPrinter.nextLine()
20002016
jsPrinter.write("static __construct(ptr) {")
20012017
jsPrinter.indent {
20022018
jsPrinter.write(
2003-
"return SwiftHeapObject.__wrap(ptr, instance.exports.bjs_\(klass.abiName)_deinit, \(klass.name).prototype, \(klass.name).__identityCache);"
2019+
"const useCache = hasIdentitySupport && ((identityMode === \"pointer\") || (\(klass.name).__useIdentityCache && identityMode !== \"none\"));"
2020+
)
2021+
jsPrinter.write(
2022+
"return SwiftHeapObject.__wrap(ptr, instance.exports.bjs_\(klass.abiName)_deinit, \(klass.name).prototype, useCache ? \(klass.name).__identityCache : null);"
20042023
)
20052024
}
20062025
jsPrinter.write("}")

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: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -107,43 +107,26 @@ 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+
#expect(cachedClass?.identityMode == true)
126+
#expect(uncachedClass?.identityMode == nil)
124127

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-
)
147-
)
128+
// Verify generated JS via snapshot
129+
let bridgeJSLink = BridgeJSLink(skeletons: [outputSkeleton], sharedMemory: false)
130+
try snapshot(bridgeJSLink: bridgeJSLink, name: "IdentityModeClass.PerClass")
148131
}
149132
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
{
2+
"exported" : {
3+
"classes" : [
4+
{
5+
"constructor" : {
6+
"abiName" : "bjs_CachedModel_init",
7+
"effects" : {
8+
"isAsync" : false,
9+
"isStatic" : false,
10+
"isThrows" : false
11+
},
12+
"parameters" : [
13+
{
14+
"label" : "name",
15+
"name" : "name",
16+
"type" : {
17+
"string" : {
18+
19+
}
20+
}
21+
}
22+
]
23+
},
24+
"identityMode" : true,
25+
"methods" : [
26+
27+
],
28+
"name" : "CachedModel",
29+
"properties" : [
30+
{
31+
"isReadonly" : false,
32+
"isStatic" : false,
33+
"name" : "name",
34+
"type" : {
35+
"string" : {
36+
37+
}
38+
}
39+
}
40+
],
41+
"swiftCallName" : "CachedModel"
42+
},
43+
{
44+
"constructor" : {
45+
"abiName" : "bjs_UncachedModel_init",
46+
"effects" : {
47+
"isAsync" : false,
48+
"isStatic" : false,
49+
"isThrows" : false
50+
},
51+
"parameters" : [
52+
{
53+
"label" : "value",
54+
"name" : "value",
55+
"type" : {
56+
"integer" : {
57+
"_0" : {
58+
"isSigned" : true,
59+
"width" : "word"
60+
}
61+
}
62+
}
63+
}
64+
]
65+
},
66+
"methods" : [
67+
68+
],
69+
"name" : "UncachedModel",
70+
"properties" : [
71+
{
72+
"isReadonly" : false,
73+
"isStatic" : false,
74+
"name" : "value",
75+
"type" : {
76+
"integer" : {
77+
"_0" : {
78+
"isSigned" : true,
79+
"width" : "word"
80+
}
81+
}
82+
}
83+
}
84+
],
85+
"swiftCallName" : "UncachedModel"
86+
}
87+
],
88+
"enums" : [
89+
90+
],
91+
"exposeToGlobal" : false,
92+
"functions" : [
93+
94+
],
95+
"protocols" : [
96+
97+
],
98+
"structs" : [
99+
100+
]
101+
},
102+
"moduleName" : "TestModule"
103+
}

0 commit comments

Comments
 (0)