[BridgeJS] Synthesize typed-closure init access from declaration surface (#709)#727
Conversation
Resolves swiftwasm#709: a public `@JSClass` exposing a `JSTypedClosure<...>` parameter could not be consumed from another target because the synthesized `extension JSTypedClosure { init(...) }` was always internal, leaving downstream callers no way to construct the closure value without hand-rolling a public wrapper. Imported skeleton entries now record the source access level (`public`/`package`/`internal`); the closure-signature collector takes the maximum across every surface that references a given signature, and `ClosureCodegen` prefixes the synthesized init with the resulting modifier (internal stays bare). This matches the pattern `JSClassMacro` already uses for `init(unsafelyWrapping:)`.
There was a problem hiding this comment.
Hey Matthew, appreciate clean fix for a real usability problem 👌🏻
The approach of threading access levels through the skeleton/walker and merging via max fits the existing architecture well. Test fixture covers the key scenarios. A few suggestions below, nothing blocking.
| accessLevel: BridgeJSAccessLevel | ||
| ) { | ||
| if let existing = signatureAccessLevels[signature] { | ||
| signatureAccessLevels[signature] = max(existing, accessLevel) |
There was a problem hiding this comment.
The "check existing, take max, else insert" pattern here is duplicated in recordInjectedSignature below. If the merge logic ever needs to change (e.g. adding a diagnostic for conflicting levels), you'd need to update both spots.
Small extract:
private mutating func recordSignature(
_ signature: ClosureSignature,
accessLevel: BridgeJSAccessLevel
) {
if let existing = signatureAccessLevels[signature] {
signatureAccessLevels[signature] = max(existing, accessLevel)
} else {
signatureAccessLevels[signature] = accessLevel
}
}Then both visitClosure and recordInjectedSignature call through to it.
| _ body: (inout BridgeSkeletonWalker) -> Void | ||
| ) { | ||
| withAccessLevel(rawLevel.flatMap(BridgeJSAccessLevel.init(rawValue:)), body) | ||
| } |
There was a problem hiding this comment.
rawLevel.flatMap(BridgeJSAccessLevel.init(rawValue:)) silently drops unknown strings (e.g. "open", "private") and falls back to inheriting the outer level. That's fine today since the macros reject those, but it's a quiet invariant. An assertion for unexpected values would save debugging time if the exported side ever gains new access strings:
private mutating func withAccessLevel(
_ rawLevel: String?,
_ body: (inout BridgeSkeletonWalker) -> Void
) {
let level: BridgeJSAccessLevel?
if let rawLevel {
level = BridgeJSAccessLevel(rawValue: rawLevel)
assert(level != nil, "Unexpected access level string: \(rawLevel)")
} else {
level = nil
}
withAccessLevel(level, body)
}| self.signatures = signatures | ||
| for signature in signatures { | ||
| signatureAccessLevels[signature] = .internal | ||
| } |
There was a problem hiding this comment.
This seeds every pre-existing signature as .internal. That's correct for the only current caller (BridgeJSLink, exported side), but the API doesn't communicate the assumption. If someone later pre-seeds signatures that should be public, they'd silently get capped.
Two options (both low-effort):
- Add a doc comment on this init noting the assumption:
/// Convenience for callers that only need to seed signatures without
/// access metadata (e.g. exported-side walking where closure init
/// access is irrelevant). All seeded signatures default to `.internal`.
public init(moduleName: String, signatures: Set<ClosureSignature>) {- Or offer a dictionary-based init alongside it:
public init(moduleName: String, signatureAccessLevels: [ClosureSignature: BridgeJSAccessLevel] = [:]) {
self.moduleName = moduleName
self.signatureAccessLevels = signatureAccessLevels
}
Summary
Fixes #709. The
extension JSTypedClosure where Signature == ... { init(...) }synthesized by BridgeJS is always emitted asinternal, so a public@JSClassexposing aJSTypedClosure<...>parameter cannot be consumed from another target — downstream callers have no way to construct the closure value without hand-rolling a public wrapper.This change derives the synthesized init's access level from the originating Swift declaration:
ImportedFunctionSkeleton,ImportedTypeSkeleton,ImportedConstructorSkeleton,ImportedGetterSkeleton,ImportedSetterSkeleton) record the source access level (newBridgeJSAccessLevelenum:internal < package < public, defaultinternal).BridgeSkeletonWalkerthreads the enclosing decl's access level intoBridgeSkeletonVisitor.visitClosure(...). Exported decls reuse the existingexplicitAccessControl: String?field ("public"/"package"/"internal"/ nil → inherit).ClosureSignatureCollectorVisitornow stores[ClosureSignature: BridgeJSAccessLevel]and takes the max access level across every surface that references a given signature, so a closure shape used by both apublicand aninternalmethod becomespublic(one extension is generated per signature).signatures: Set<ClosureSignature>is preserved as a computed view forBridgeJSLink.ClosureCodegen.renderClosureHelpersprefixes the synthesized init withpublic/package(or leaves it bare for internal). Mirrors the patternJSClassMacroalready uses forinit(unsafelyWrapping:).The user's example from #709 now generates
public init(...):Test plan
SwiftTypedClosureAccess.swiftcovering: a public@JSClass/@JSFunction(→public init), apackagesurface (→package init), an internal-only surface (→ bareinit), and a closure shape shared between a public and an internal method (→ merges topublic init).swift test --package-path Plugins/BridgeJS— all 107 tests in 9 suites pass.accessLevelfield on imported decls (defaults to"internal"for unchanged fixtures, so no semantic drift).swift build --package-path Examples/Basicbuilds clean end-to-end.