Skip to content

Commit 02b8bce

Browse files
BridgeJS: fix named-import regressions found in review
Do not require a module export for a wrapper-only `@JSClass`, since a named import is a link-time requirement and nothing looks that name up; accept `jsName: nil` and the explicit `.name(...)` spelling; and validate the tagged `from` form like the plain-string form.
1 parent 7d83dec commit 02b8bce

7 files changed

Lines changed: 184 additions & 6 deletions

File tree

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2570,6 +2570,30 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
25702570
return ExtractedJSName(memberName: value, isDefaultExportSpelling: false)
25712571
}
25722572

2573+
// An explicit `jsName: nil` means the same as omitting the argument.
2574+
if argument.expression.is(NilLiteralExprSyntax.self) {
2575+
return nil
2576+
}
2577+
2578+
// Accept the explicit `.name("...")` spelling of a plain member name.
2579+
if let call = argument.expression.as(FunctionCallExprSyntax.self),
2580+
call.calledExpression.trimmedDescription.split(separator: ".").last == "name"
2581+
{
2582+
guard call.arguments.count == 1,
2583+
let literal = call.arguments.first?.expression.as(StringLiteralExprSyntax.self),
2584+
let value = literal.representedLiteralValue
2585+
else {
2586+
errors.append(
2587+
DiagnosticError(
2588+
node: call.arguments.first?.expression ?? argument.expression,
2589+
message: "jsName must be a string literal or '.default'."
2590+
)
2591+
)
2592+
return nil
2593+
}
2594+
return ExtractedJSName(memberName: value, isDefaultExportSpelling: false)
2595+
}
2596+
25732597
// Accept `.default`, `JSName.default`, and the backticked spellings.
25742598
let description = argument.expression.trimmedDescription
25752599
let caseName = description.split(separator: ".").last.map(String.init) ?? description

Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,21 @@ final class ImportedJSModuleRegistry {
6161

6262
for (index, reference) in references.enumerated() {
6363
let members = (membersByReference[reference] ?? []).sorted()
64+
// A reference with no member lookups keeps the namespace form: a named import
65+
// is a hard link-time requirement, so importing a name nothing references
66+
// would fail the whole module load if the module does not export it.
6467
bindings[reference] = Binding(
6568
index: index,
6669
members: members,
67-
usesNamedImports: members.allSatisfy(Self.isValidJSIdentifier)
70+
usesNamedImports: !members.isEmpty && members.allSatisfy(Self.isValidJSIdentifier)
6871
)
6972
}
7073
}
7174

7275
static func collectReferences(skeletons: [BridgeJSSkeleton]) -> [Reference] {
7376
var references = Set<Reference>()
7477
for skeleton in skeletons {
75-
forEachMemberLookup(skeleton: skeleton) { reference, _ in
78+
forEachOrigin(skeleton: skeleton) { reference in
7679
references.insert(reference)
7780
}
7881
}
@@ -86,12 +89,40 @@ final class ImportedJSModuleRegistry {
8689
}
8790
}
8891

92+
/// Visits every module origin mentioned by the skeleton, whether or not code
93+
/// generation looks a member up on it.
94+
///
95+
/// This is what decides which modules are imported at all, and for local paths
96+
/// which files packaging copies. It stays broader than `forEachMemberLookup` so
97+
/// that a module mentioned only by a wrapper-only `@JSClass` is still imported,
98+
/// preserving its side effects.
99+
private static func forEachOrigin(
100+
skeleton: BridgeJSSkeleton,
101+
_ body: (Reference) -> Void
102+
) {
103+
func visit(from: JSImportFrom?) {
104+
guard let reference = Self.reference(swiftModuleName: skeleton.moduleName, from: from) else { return }
105+
body(reference)
106+
}
107+
for file in skeleton.imported?.children ?? [] {
108+
for function in file.functions { visit(from: function.from) }
109+
for getter in file.globalGetters { visit(from: getter.from) }
110+
for type in file.types { visit(from: type.from) }
111+
}
112+
}
113+
89114
/// Visits every module member lookup that code generation will emit.
90115
///
91116
/// The member name taken here must match what the corresponding emitter in
92-
/// `BridgeJSLink` looks up: a class contributes a single binding that serves both
93-
/// its constructor and its static methods, and instance methods contribute nothing
94-
/// because they call through an already-constructed instance.
117+
/// `BridgeJSLink` looks up, and must not include names it never emits: a named
118+
/// import is a hard link-time requirement, so recording a member that no
119+
/// generated code references would make the module fail to load whenever the
120+
/// module does not happen to export that name.
121+
///
122+
/// A class contributes a single binding that serves both its constructor and its
123+
/// static methods, and only when it has one of those. Instance methods, getters,
124+
/// and setters contribute nothing because they go through an already-constructed
125+
/// instance, so a wrapper-only `@JSClass` needs no export from the module at all.
95126
private static func forEachMemberLookup(
96127
skeleton: BridgeJSSkeleton,
97128
_ body: (Reference, String) -> Void
@@ -108,6 +139,7 @@ final class ImportedJSModuleRegistry {
108139
visit(from: getter.from, memberName: getter.jsName ?? getter.name)
109140
}
110141
for type in file.types {
142+
guard type.constructor != nil || !type.staticMethods.isEmpty else { continue }
111143
visit(from: type.from, memberName: type.jsName ?? type.name)
112144
}
113145
}

Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1161,7 +1161,34 @@ public enum JSImportFrom: Codable, Equatable, Sendable {
11611161
debugDescription: "Unknown import origin kind '\(kind)'. Expected \"module\"."
11621162
)
11631163
}
1164-
self = .module(try container.decode(String.self, forKey: .specifier))
1164+
let specifier = try container.decode(String.self, forKey: .specifier)
1165+
// Apply the same rules as the single-value form above, so a specifier cannot
1166+
// reach code generation through this path that the string path would reject.
1167+
guard !specifier.isEmpty else {
1168+
throw DecodingError.dataCorruptedError(
1169+
forKey: .specifier,
1170+
in: container,
1171+
debugDescription: "Module specifier must not be empty."
1172+
)
1173+
}
1174+
guard !specifier.hasPrefix(".") else {
1175+
throw DecodingError.dataCorruptedError(
1176+
forKey: .specifier,
1177+
in: container,
1178+
debugDescription: "Module specifier '\(specifier)' must not be relative."
1179+
)
1180+
}
1181+
// A rooted path names a file we resolve inside the Swift target, so it must not
1182+
// traverse out of it. A bare specifier is resolved by the JavaScript host, where
1183+
// path segments carry no such meaning, so it is left alone.
1184+
guard !specifier.hasPrefix("/") || !specifier.split(separator: "/").contains("..") else {
1185+
throw DecodingError.dataCorruptedError(
1186+
forKey: .specifier,
1187+
in: container,
1188+
debugDescription: "Local module path '\(specifier)' must not contain '..'."
1189+
)
1190+
}
1191+
self = .module(specifier)
11651192
}
11661193

11671194
public func encode(to encoder: any Encoder) throws {

Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,19 @@ import Testing
8888
}
8989
}
9090

91+
/// The keyed form must reject what the string form rejects, so a specifier cannot reach
92+
/// code generation through the tagged object that the plain-string path would refuse.
93+
@Test(arguments: [
94+
#"{"kind": "module", "specifier": ""}"#,
95+
#"{"kind": "module", "specifier": "./relative.mjs"}"#,
96+
#"{"kind": "module", "specifier": "/../../escape.mjs"}"#,
97+
])
98+
func invalidKeyedJSImportFromFailsToDecode(json: String) {
99+
#expect(throws: DecodingError.self) {
100+
try JSONDecoder().decode(JSImportFrom.self, from: Data(json.utf8))
101+
}
102+
}
103+
91104
private func snapshotCodegen(
92105
skeleton: BridgeJSSkeleton,
93106
name: String,

Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,26 @@ import Testing
109109
#expect(diagnostics.description.contains("is not supported on a class member"))
110110
}
111111

112+
/// `jsName: nil` is valid Swift and means the same as omitting the argument.
113+
@Test
114+
func explicitNilJSNameIsAccepted() throws {
115+
let source = """
116+
let unrelated = 0
117+
@JSFunction(jsName: nil, from: .global) func imported() throws(JSException)
118+
"""
119+
#expect(moduleDiagnostics(source: source) == nil)
120+
}
121+
122+
/// `JSName.name(_:)` is public and documented, so its explicit spelling must work.
123+
@Test
124+
func explicitNameCaseSpellingIsAccepted() throws {
125+
let source = """
126+
let unrelated = 0
127+
@JSFunction(jsName: .name("basename"), from: .module("node:path")) func imported() throws(JSException)
128+
"""
129+
#expect(moduleDiagnostics(source: source) == nil)
130+
}
131+
112132
@Test
113133
func jsNameMustBeStringLiteralOrDefault() throws {
114134
let source = """

Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,66 @@ import Testing
6969
#expect(lines.contains { $0.contains("bridge-js-modules/Beta/utils.mjs") })
7070
}
7171

72+
/// A named import is a hard link-time requirement, so a class whose module export is
73+
/// never looked up must not produce one. A wrapper-only `@JSClass` has no constructor
74+
/// and no static methods, so nothing references the module's export of that name and
75+
/// the module need not export it at all; requiring it would fail the whole module load.
76+
@Test func wrapperOnlyClassDoesNotRequireANamedExport() throws {
77+
let lines = try importLines([
78+
skeleton(
79+
moduleName: "Alpha",
80+
types: [
81+
ImportedTypeSkeleton(
82+
name: "Wrapper",
83+
from: .module("some-pkg"),
84+
methods: [
85+
ImportedFunctionSkeleton(name: "read", parameters: [], returnType: .void)
86+
]
87+
)
88+
]
89+
)
90+
])
91+
#expect(lines.count == 1)
92+
#expect(lines[0].hasPrefix("import * as "))
93+
#expect(!lines[0].contains("Wrapper as "))
94+
}
95+
96+
/// A class with a constructor does have its export looked up, so it keeps a named import.
97+
@Test func classWithConstructorUsesANamedImport() throws {
98+
let lines = try importLines([
99+
skeleton(
100+
moduleName: "Alpha",
101+
types: [
102+
ImportedTypeSkeleton(
103+
name: "Wrapper",
104+
from: .module("some-pkg"),
105+
constructor: ImportedConstructorSkeleton(parameters: [])
106+
)
107+
]
108+
)
109+
])
110+
#expect(lines == [#"import { Wrapper as __bjs_import_0_Wrapper } from "some-pkg";"#])
111+
}
112+
113+
/// A class with only static methods also looks its export up.
114+
@Test func classWithOnlyStaticMethodsUsesANamedImport() throws {
115+
let lines = try importLines([
116+
skeleton(
117+
moduleName: "Alpha",
118+
types: [
119+
ImportedTypeSkeleton(
120+
name: "Wrapper",
121+
from: .module("some-pkg"),
122+
staticMethods: [
123+
ImportedFunctionSkeleton(name: "create", parameters: [], returnType: .void)
124+
]
125+
)
126+
]
127+
)
128+
])
129+
#expect(lines == [#"import { Wrapper as __bjs_import_0_Wrapper } from "some-pkg";"#])
130+
}
131+
72132
/// Local references are emitted before bare ones, each group sorted, so that the numbered
73133
/// aliases in generated JavaScript are stable across runs.
74134
@Test func referencesAreOrderedDeterministically() throws {

Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ An **external module** is any other value, passed to the JavaScript module resol
2121

2222
Generated packages use static ECMAScript module imports. This works with the existing PackageToJS browser and Node ESM entry points. CommonJS and classic non-module script output are not generated or translated. Note that named exports of a CommonJS package are only importable when Node can statically detect them; when in doubt, use `jsName: .default` and reach members through the default export.
2323

24+
A module export is called through a named import, so `this` is `undefined` inside the called function rather than the module namespace object. A function that reaches sibling exports through `this` — which happens in CommonJS packages consumed through Node's ESM interop — will fail. Import the default export and call the member through it when a package needs that receiver.
25+
2426
Module origins apply to top-level `@JSFunction`, top-level `@JSGetter`, and an entire `@JSClass`. Per-member origins, top-level setters, inline JavaScript source, package-root-relative paths, and per-member module overrides are not supported. `jsName: .default` is likewise only valid on those three declaration forms and only together with `from: .module(...)`; it cannot be used on `@JSSetter`, because ECMAScript module bindings are read-only.
2527

2628
The TypeScript-definition workflow (`bridge-js.d.ts`) always imports from `globalThis` and cannot yet target a module origin. To import from a module, declare the API with the macros instead.

0 commit comments

Comments
 (0)