Skip to content

feat(swift-interop): adapter lifetime, cancellation, subscripts, Combine and operators (6/7) - #523

Draft
NathanWalker wants to merge 6 commits into
swift-interop/5-valuesfrom
swift-interop/6-lifetime-async
Draft

NathanWalker wants to merge 6 commits into
swift-interop/5-valuesfrom
swift-interop/6-lifetime-async

Conversation

@NathanWalker

@NathanWalker NathanWalker commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Swift interop stack — review bottom-up; each PR shows only its own commits. Merge in order; after a squash merge, rebase the next branch onto main. Design discussion: RFC NativeScript/rfcs#57.

  1. feat(metadata-generator): Swift symbol graphs to thunks and TypeScript declarations (Swift interop 1/7) #518 generator: symbol graphs → IR → thunks + .d.ts
  2. feat(runtime): ns:swift calls Swift through generated thunks (Swift interop 2/7) #519 runtime: ns:swift core
  3. feat(swift-interop): modules, generics, protocols, Observation and SwiftUI (3/7) #520 modules, generics, protocols, Observation, SwiftUI
  4. feat(swift-interop): generate and link thunks in app builds (4/7) #521 app builds
  5. feat(swift-interop): returning closures, generic functions, tuples, Result and C structs (5/7) #522 more values: returning closures, generic functions, tuples, Result, C structs
  6. feat(swift-interop): adapter lifetime, cancellation, subscripts, Combine and operators (6/7) #523 adapter lifetime, cancellation, subscripts, Combine, operators ← this PR
  7. feat(swift-interop): Any, UUID and Decimal, performance, and docs (Swift interop 7/7) #517 Any/UUID/Decimal, performance, docs

Lifetime and call-shape work.

SwiftDemo.introduce(delegate, "ada");            // one adapter per JS object; weak Swift delegates live as long as JS holds them
await SwiftDemo.waitForCancel(10, controller.signal); // AbortSignal rejects and cancels the Swift task
shelf.setSubscript("apples", 3); shelf.subscript("apples"); // subscripts
for await (const v of publisher.values) { break; } // AsyncSequence types, Combine publishers, early exit ends the stream
a.plus(b); versions.sort((x, y) => x.compare(y)); // operators as named methods, Equatable/Comparable
SwiftDemo.sumAll([1, 2, 3]);                     // some Sequence<Int>, C: Collection, any Sequence<E> take arrays

Protocol adapters follow NativeScript's lifetime rule for the JS side of Objective-C objects (ABI 14). Async calls take a trailing AbortSignal (ABI 15).

Evidence

  • Before: each call made a new adapter, so Swift saw a new identity each time (removeAll { $0 === listener } never matched), and a weakly held adapter was freed as soon as the call returned. Async calls could not be cancelled.
    After: 134 ns:swift TestRunner specs (17 new), covering adapter identity, weak delegates across GC, cancellation, subscripts, Combine and collection protocols.

Merge Danger

Door: two-way

Blast Radius: ns:swift

Adapters hold JS objects strongly while Swift shares them and weakly otherwise, so leak or early-release bugs would show here first.

@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…fetime rule (ABI 14)

A JS object passed as a protocol value now gets one Swift adapter per
protocol instead of a new one per call. Swift sees one identity, so
`removeAll { $0 === listener }` matches.

The runtime owns each adapter while its JS object lives. Before each
garbage collection it holds the JS object strongly if anything else
retains the adapter, and weakly otherwise. This follows NativeScript's
rule for the JS side of Objective-C objects:
- strong Swift references (`var delegate`) keep the JS object alive;
- `weak var delegate` holds it for as long as JS keeps it, then becomes
  nil.
Once neither side holds them, both are collected. Previously a weakly
held adapter was freed as soon as the call returned.

Calls through an adapter whose JS object was collected fail like a JS
implementation that throws.

Thunk ABI 14: API slots 54 (adapterOf) and 55 (adoptAdapter).
An async member now takes an optional AbortSignal after its arguments:
`await fetchItems(query, controller.signal)`. Aborting rejects the
Promise at once with the signal's reason, as fetch does, and cancels the
Swift task. Results the task reports afterwards are dropped. A signal
that is already aborted rejects without calling Swift. The signal is
taken off before overload resolution, so it never competes with real
arguments.

Async thunks keep their Task and register a cancel hook for the call's
token. TypeScript declares `signal?: AbortSignal` on async members, and
types/ns-swift.d.ts declares a minimal AbortSignal for programs without
the DOM library.

Thunk ABI 15: API slot 56 (setCancel).
…hods

A subscript with indices is now a JS method: `shelf.subscript("apples")`
reads it. A settable one also gets `shelf.setSubscript("apples", 3)`,
which assigns it, writing through a struct's box in place. Subscripts
are overloaded like methods, by their indices. Named methods keep
property access untouched and can't collide with a type's own `get` or
`set`.

The surface adds the setter as a synthetic member, and the emitter
spells `receiver[indices]` and `receiver[indices] = value`. Against the
iOS 18 SDK this adds 2 WeatherKit, 4 Foundation, 5 SwiftUI, 1 SwiftData
and 1 Charts callables. Every module's thunks still type-check.
…oop exit

A bridged class or struct conforming to AsyncSequence now keeps its JS
class and other members, and is also async-iterable through a synthetic
`[Symbol.asyncIterator]()`. The element comes from its `Element`
typealias, or else from its iterator's own `next()`.

AnyPublisher, PassthroughSubject and CurrentValueSubject are async
iterators over their `values` on iOS 15+, and null on earlier systems.
Thunks import Combine when one is bridged.

Async iterators now implement `return()`, so leaving a `for await` loop
early releases the Swift iterator. That ends a stream Swift still
produces: AsyncStream's onTermination sees `.cancelled`, and a
publisher's subscription is cancelled.
Operators are now JS methods named for what they do:
- `+` `-` `*` `/` `%` are `plus`, `minus`, `times`, `dividedBy` and
  `remainder`;
- `==`, `<`, `<=`, `>` and `>=` are `equals`, `lessThan`,
  `lessThanOrEqualTo`, `greaterThan` and `greaterThanOrEqualTo`;
- prefix `-` and `!` are `negated()` and `not()`.

An operator whose left operand is its own type is an instance method of
that operand (`a.plus(b)`); any other is static (`Vector2.times(2, v)`).
Compound assignment, bitwise and custom operators stay unexposed.

Equatable classes and structs (also through Hashable or Comparable) now
get `equals(other)`, and Comparable ones get `compare(other)`, which
returns -1, 0 or 1 from Swift's `<`. Symbol graphs omit the `==` the
compiler synthesizes, so these come from the conformances.

Against the iOS 18 SDK this adds 238 Foundation, 106 SwiftUI, 42
WeatherKit, 20 SwiftData, 6 Charts and 3 Observation callables. Every
module's thunks still type-check.
A type parameter constrained to Sequence, Collection or another protocol
arrays conform to now stands in as an array. With
`where C.Element == X` the stand-in is `[X]`; otherwise it is an array
of each of the element's own stand-ins. `C.Element` in the signature is
substituted to match. `some Sequence<Int>` stands in as `[Int]`.

A parameter typed `any Sequence<E>` (or another such protocol) takes an
array, and a result of that type is copied out as one.

Against the iOS 18 SDK this adds 5 Foundation and 2 SwiftUI callables.
Every module's thunks still type-check.
@NathanWalker
NathanWalker force-pushed the swift-interop/5-values branch from 7a3876e to ae7d342 Compare October 21, 2026 16:44
@NathanWalker
NathanWalker force-pushed the swift-interop/6-lifetime-async branch from d43b891 to 1c568f9 Compare October 21, 2026 16:44
@NathanWalker NathanWalker added this to the 10.0 milestone Oct 21, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant