Uh oh!
There was an error while loading. Please reload this page.
[BridgeJS] Synthesize typed-closure init access from declaration surface (#709) - #727
Conversation
Resolvesswiftwasm#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:
privatemutatingfunc recordSignature(
_ signature:ClosureSignature,
accessLevel:BridgeJSAccessLevel){iflet 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:
privatemutatingfunc withAccessLevel(
_ rawLevel:String?,
_ body:(inoutBridgeSkeletonWalker)->Void){letlevel:BridgeJSAccessLevel?iflet 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`.
publicinit(moduleName:String, signatures:Set<ClosureSignature>){- Or offer a dictionary-based init alongside it:
publicinit(moduleName:String, signatureAccessLevels:[ClosureSignature:BridgeJSAccessLevel]=[:]){self.moduleName = moduleName
self.signatureAccessLevels = signatureAccessLevels
}krodak
commented
Apr 29, 2026
@matthewa26 please address feedback at your convenience and have a read on |
matthewa26
commented
Apr 29, 2026
@krodak Will do! Just now seeing this. |
- Make `accessLevel` decode-tolerant on imported skeleton structs
(`ImportedFunctionSkeleton`, `ImportedConstructorSkeleton`,
`ImportedGetterSkeleton`, `ImportedSetterSkeleton`,
`ImportedTypeSkeleton`) by writing explicit `init(from:)` decoders
that fall back to `.internal` when the key is missing. Without this,
any pre-existing skeleton JSON without the new field fails decoding —
the `build-examples` CI job hit `DecodingError.keyNotFound` for
`accessLevel` against externally consumed skeletons.
- Extract a private `recordSignature` helper so `visitClosure` and
`recordInjectedSignature` share a single merge implementation.
- Assert in `withAccessLevel(rawLevel:)` so unknown access strings
("open", "private", future schema additions) surface in debug
builds instead of silently inheriting the outer level.
- Document the `.internal` seeding assumption on
`ClosureSignatureCollectorVisitor.init(moduleName:signatures:)`.
- Regenerate the BridgeJS pre-generated artifacts under Benchmarks/,
Examples/PlayBridgeJS/, Tests/BridgeJSIdentityTests/, and
Tests/BridgeJSRuntimeTests/ via `./Utilities/bridge-js-generate.sh`,
per CONTRIBUTING.md. The runtime-tests Swift output now emits
`public init` on three `JSTypedClosure` extensions whose signatures
surface through public exported types.swiftwasm#731 added the GC lifecycle test (with new imported function entries) to main while this branch was open. Re-running the BridgeJS regen against the merged tree fills in the `accessLevel` field on the new entries that were absent at merge time.
matthewa26
commented
Apr 30, 2026
@krodak Looks like everything is green now. Thanks for the review and the follow up! |
kateinoigakukun
left a comment
There was a problem hiding this comment.
Seems good to me, thanks @matthewa26 (and thanks @krodak for reviewing!)
Uh oh!
There was an error while loading. Please reload this page.
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.