Skip to content
10 changes: 9 additions & 1 deletion Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,14 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
className: classNameForABI
)

if let mutatingModifier = node.modifiers.first(where: { $0.name.tokenKind == .keyword(.mutating) }) {
diagnose(
node: mutatingModifier,
message: "@JS does not support mutating struct methods: mutations to 'self' cannot be propagated back to JavaScript",
hint: "Remove the mutating keyword or redesign the API to return the updated value instead"
)
return nil
}
guard let effects = collectEffects(signature: node.signature, isStatic: isStatic) else {
return nil
}
Expand Down Expand Up @@ -1522,7 +1530,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
}
}

/// Walks extension members under the matching types state, returning whether the type was found.
/// Walks extension members under the matching type's state, returning whether the type was found.
///
/// Note: The lookup scans dictionaries keyed by `makeKey(name:namespace:)`, matching only by
/// plain name. If two types share a name but differ by namespace, `.first(where:)` picks
Expand Down
26 changes: 25 additions & 1 deletion Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -635,11 +635,35 @@ public struct Effects: Codable, Equatable, Sendable {
public var isAsync: Bool
public var isThrows: Bool
public var isStatic: Bool
public var isMutating: Bool

public init(isAsync: Bool, isThrows: Bool, isStatic: Bool = false) {
public init(isAsync: Bool, isThrows: Bool, isStatic: Bool = false, isMutating: Bool = false) {
self.isAsync = isAsync
self.isThrows = isThrows
self.isStatic = isStatic
self.isMutating = isMutating
}

private enum CodingKeys: String, CodingKey {
case isAsync, isThrows, isStatic, isMutating
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.isAsync = try container.decode(Bool.self, forKey: .isAsync)
self.isThrows = try container.decode(Bool.self, forKey: .isThrows)
self.isStatic = try container.decode(Bool.self, forKey: .isStatic)
self.isMutating = try container.decodeIfPresent(Bool.self, forKey: .isMutating) ?? false
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(isAsync, forKey: .isAsync)
try container.encode(isThrows, forKey: .isThrows)
try container.encode(isStatic, forKey: .isStatic)
if isMutating {
try container.encode(isMutating, forKey: .isMutating)
}
}
}

Expand Down
93 changes: 93 additions & 0 deletions Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -319,4 +319,97 @@ import Testing
// No line 3 in source, so output must not show a " 3 |" context line after the pointer
#expect(!description.contains(" 3 |"))
}

// MARK: - Mutating struct method diagnostic

/// `@JS` on a `mutating` struct method cannot be supported: the JS-side
/// bridge calls a Swift function whose `self` was lowered across the WASM
/// boundary, so mutations to `self` cannot be propagated back to the
/// JavaScript caller. The codegen detects the `mutating` modifier and
/// emits an explicit diagnostic pointing the user at the right fix,
/// rather than silently producing a thunk that would discard their
/// mutations at runtime.
@Test
func mutatingStructMethodEmitsDiagnostic() throws {
let source = """
@JS struct Counter {
var number: Int

@JS public mutating func increment() {
number += 1
}
}
"""
let swiftAPI = SwiftToSkeleton(
progress: .silent,
moduleName: "TestModule",
exposeToGlobal: false,
externalModuleIndex: .empty
)
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
#expect(throws: BridgeJSCoreDiagnosticError.self) {
_ = try swiftAPI.finalize()
}
}

@Test
func mutatingStructMethodDiagnosticMessageAndHint() throws {
let source = """
@JS struct Counter {
var number: Int

@JS public mutating func increment() {
number += 1
}
}
"""
let swiftAPI = SwiftToSkeleton(
progress: .silent,
moduleName: "TestModule",
exposeToGlobal: false,
externalModuleIndex: .empty
)
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
do {
_ = try swiftAPI.finalize()
Issue.record("Expected finalize() to throw for a mutating @JS struct method")
} catch let error as BridgeJSCoreDiagnosticError {
let allMessages = error.diagnostics.map { $0.diagnostic.message }.joined(separator: "\n")
#expect(
allMessages.contains(
"@JS does not support mutating struct methods: mutations to 'self' cannot be propagated back to JavaScript"
)
)
let allHints = error.diagnostics.compactMap { $0.diagnostic.hint }.joined(separator: "\n")
#expect(
allHints.contains(
"Remove the mutating keyword or redesign the API to return the updated value instead"
)
)
}
}

@Test
func nonMutatingStructMethodSucceeds() throws {
// Regression guard: an otherwise-identical struct method WITHOUT the
// `mutating` modifier should still pass through the codegen cleanly.
let source = """
@JS struct Counter {
var number: Int

@JS public func describe() -> String {
return "Counter(\\(number))"
}
}
"""
let swiftAPI = SwiftToSkeleton(
progress: .silent,
moduleName: "TestModule",
exposeToGlobal: false,
externalModuleIndex: .empty
)
swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift")
let skeleton = try swiftAPI.finalize()
#expect(skeleton.exported != nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@JS struct Counter {
var number: Int
}

extension Counter {
@JS public mutating func increment() {
number += 1
}
@JS public mutating func add(_ value: Int) {
number += value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"exported" : {
"classes" : [

],
"enums" : [

],
"exposeToGlobal" : false,
"functions" : [

],
"protocols" : [

],
"structs" : [
{
"methods" : [

],
"name" : "Counter",
"properties" : [
{
"isReadonly" : true,
"isStatic" : false,
"name" : "number",
"type" : {
"integer" : {
"_0" : {
"isSigned" : true,
"width" : "word"
}
}
}
}
],
"swiftCallName" : "Counter"
}
]
},
"moduleName" : "TestModule",
"usedExternalModules" : [

]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
extension Counter: _BridgedSwiftStruct {
@_spi(BridgeJS) @_transparent public static func bridgeJSStackPop() -> Counter {
let number = Int.bridgeJSStackPop()
return Counter(number: number)
}

@_spi(BridgeJS) @_transparent public consuming func bridgeJSStackPush() {
self.number.bridgeJSStackPush()
}

init(unsafelyCopying jsObject: JSObject) {
_bjs_struct_lower_Counter(jsObject.bridgeJSLowerParameter())
self = Self.bridgeJSStackPop()
}

func toJSObject() -> JSObject {
let __bjs_self = self
__bjs_self.bridgeJSStackPush()
return JSObject(id: UInt32(bitPattern: _bjs_struct_lift_Counter()))
}
}

#if arch(wasm32)
@_extern(wasm, module: "bjs", name: "swift_js_struct_lower_Counter")
fileprivate func _bjs_struct_lower_Counter_extern(_ objectId: Int32) -> Void
#else
fileprivate func _bjs_struct_lower_Counter_extern(_ objectId: Int32) -> Void {
fatalError("Only available on WebAssembly")
}
#endif
@inline(never) fileprivate func _bjs_struct_lower_Counter(_ objectId: Int32) -> Void {
return _bjs_struct_lower_Counter_extern(objectId)
}

#if arch(wasm32)
@_extern(wasm, module: "bjs", name: "swift_js_struct_lift_Counter")
fileprivate func _bjs_struct_lift_Counter_extern() -> Int32
#else
fileprivate func _bjs_struct_lift_Counter_extern() -> Int32 {
fatalError("Only available on WebAssembly")
}
#endif
@inline(never) fileprivate func _bjs_struct_lift_Counter() -> Int32 {
return _bjs_struct_lift_Counter_extern()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit,
// DO NOT EDIT.
//
// To update this file, just rebuild your project or run
// `swift package bridge-js`.

export interface Counter {
number: number;
}
export type Exports = {
}
export type Imports = {
}
export function createInstantiator(options: {
imports: Imports;
}, swift: any): Promise<{
addImports: (importObject: WebAssembly.Imports) => void;
setInstance: (instance: WebAssembly.Instance) => void;
createExports: (instance: WebAssembly.Instance) => Exports;
}>;
Loading