Skip to content

[Spike][DNM] Broadly isolate Epoxy to the main actor - #185

Draft
andreacipriani wants to merge 27 commits into
airbnb:masterfrom
andreacipriani:agc--mainactor-isolation-spike
Draft

[Spike][DNM] Broadly isolate Epoxy to the main actor#185
andreacipriani wants to merge 27 commits into
airbnb:masterfrom
andreacipriani:agc--mainactor-isolation-spike

Conversation

@andreacipriani

@andreacipriani andreacipriani commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Spike: broadly isolate Epoxy to the main actor

Open points:

  • should we keep diffing outside the main actor? with the default isolation this PR puts it on main
  • seems like bumping to Swift 6.2 is required for SPM .defaultIsolation(MainActor.self)
  • investigate why lint-podspec --configuration=Debug breaks the build
  • --allow-warnings hides possible data races, check if we can remove
  • Token deinit is now async instead of sync

andreacipriani and others added 8 commits July 2, 2026 16:44
Follow-up to airbnb#183: annotate the StyledView / ContentConfigurableView /
BehaviorsConfigurableView view protocols as @preconcurrency @mainactor, and
promote the model-builder DSL entry points (itemModel / barModel /
supplementaryItemModel / groupItem / swiftUIView) from nonisolated to
@preconcurrency @mainactor. Their deferred view-construction and configuration
closures bridge to the main actor via MainActor.assumeIsolated.

This lets consumers building under strict concurrency / the Swift 6 language
mode use these factories from main-actor contexts without warnings, while
@preconcurrency keeps existing call sites compiling.
Annotate the PresentationModel / NavigationModel view-construction and
lifecycle closures (makeViewController / makePresentable / dismiss / remove /
didPresent / didDismiss / didShow / didHide / didAdd / didRemove) and their
backing PresentationQueue / NavigationQueue as @mainactor.

These closures only ever run on the main thread — they construct and configure
UIViewControllers, driven from the already-main-actor entry points
UIViewController.setPresentation(_:animated:) and
NavigationController.setStack(_:animated:) — so this lets consumers building
under strict concurrency / the Swift 6 language mode present and navigate from
@mainactor contexts without warnings or MainActor.assumeIsolated. The Diffable
conformance and model identity/equality remain nonisolated so model diffing can
continue off the main thread.
NavigationQueue and PresentationQueue are @mainactor, but Quick's spec() stores
its beforeEach/it/afterEach closures as nonisolated @escaping closures that it
invokes later, so those closures can't call into the queues directly (unlike
UIKit-backed types, whose @mainactor is imported @preconcurrency and only warns).

Add a shared MainActorSpec opt-in protocol: Quick runs every example on the main
thread via XCTest, so it shadows the global Quick DSL with @mainactor variants that
bridge through MainActor.assumeIsolated. NavigationQueueSpec and PresentationQueueSpec
opt in by conforming. Keeps the queues as plain @mainactor (no @preconcurrency) and
needs no production or Package.swift changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enable package-wide default main-actor isolation (swift-tools 6.2 +
.defaultIsolation(MainActor.self)) across all library targets, building
under the Swift 6 language mode, to test whether the concurrency compiler
can be satisfied with @mainactor.

Library changes required (all mechanical):
- ~24 isolated-conformance annotations (': @mainactor SomeProtocol')
- 1 isolated deinit (Token in BarInstaller)
- 1 missing 'import EpoxyCore' (was a warning, now an error under Swift 6)
- Make Constrainable identity (dataID / isEqual(to:)) nonisolated so the
  HGroup/VGroup ItemAlignment value enums can stay nonisolated data

Result: all 7 modules + the EpoxyExample app build green. Test-target
migration to the MainActorSpec bridge is deliberately left as follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make EpoxyTests compile and pass (286 tests) against the now main-actor
library, without any nonisolated(unsafe):

- Conform every QuickSpec that drives the main-actor API to the existing
  MainActorSpec bridge (its DSL takes @mainactor closures and hops on via
  MainActor.assumeIsolated). BaseBarInstallerSpec refines MainActorSpec.
- Annotate XCTestCase-based tests and test-only helper types that model
  main-actor library protocols (EpoxyModeledSpec's providers/TestModel,
  CollectionViewSpec mocks) with @mainactor.
- Move shared spec setup that builds main-actor values into beforeEach.
- Keep the test targets on the Swift 5 language mode: Quick specs share
  non-Sendable state through captured vars across the bridged closures,
  which Swift 6's region-based sending analysis rejects even though the
  state never leaves the main thread. Actor-isolation is still enforced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With package-wide default main-actor isolation, the @preconcurrency
escape hatch on the core protocols (StyledView, ContentConfigurableView,
BehaviorsConfigurableView) and the model factory methods is no longer
needed — we're committing to Swift concurrency, so callers should see the
full @mainactor requirement.

Dropping it exposed two test call sites that built models via the (now
strictly main-actor) factories outside a bridged closure; moved those
into beforeEach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@andreacipriani
andreacipriani marked this pull request as ready for review July 20, 2026 15:48
@andreacipriani
andreacipriani marked this pull request as draft July 20, 2026 15:48
CI is pinned to Xcode 16.4 (Swift 6.1), which doesn't support the
swift-tools-version 6.2 '.defaultIsolation(MainActor.self)' package
setting. Lower the manifest to tools 6.0 and drive default main-actor
isolation through the '-default-isolation MainActor' frontend flag via
.unsafeFlags instead, so the package resolves on Swift 6.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@andreacipriani andreacipriani self-assigned this Jul 20, 2026
An isolated deinit is only available in iOS 18.4+, but Epoxy deploys to iOS 13,
so the podspec build failed. Make Token nonisolated (its deinit is then not
main-actor isolated) and hop to the main actor to run the main-actor dispose.
The -default-isolation flag requires a Swift 6.2 toolchain; Xcode 16.4 rejects it.
macos-15 provides Xcode 26.3 and the iPhone 16 simulators the suite uses.
…ailure

- ConfigurePodspec.rb: build the pods in Swift 6 with the same -default-isolation
  and InferIsolatedConformances flags as SwiftPM, so CocoaPods consumers get the
  same isolation and pod lib lint stops warning on main-actor conformances.
- Rakefile: on xcodebuild failure, print the raw log tail (xcbeautify hides the
  crash reason for test-package). TEMPORARY debug aid; revert once diagnosed.
- MainActorSpec: Quick doesn't guarantee example closures run on the main thread,
  and MainActor.assumeIsolated traps with SIGABRT off-main (crashed BarStackViewSpec
  on CI's iOS 26.2). Hop to the main thread explicitly before assuming isolation.
- Rakefile: run pod lib lint --verbose to surface the underlying xcodebuild error.
Quick 4 ran example closures off the main thread under whole-package main-actor
isolation, crashing (SIGABRT) on CI's iOS 26.2 sim inside the assumeIsolated bridge.
Quick 7 runs examples on the main actor (its DSL closures are @mainactor), so:

- Bump Quick to 7.6.2 and Nimble to 14.0.0.
- Delete the MainActorSpec assumeIsolated bridge; specs call @mainactor code directly.
- spec() is now a static 'class func spec()' (Quick 7). Convert all specs, and make
  the shared BaseBarInstallerSpec helpers + CollectionViewSpec mocks static.
- Nimble 14: use equal(...) instead of the identity matcher be(...) for value checks.
…e 26.5

- Pin airbnb/swift to 1.0.3 so the SwiftFormat version stays 0.51-beta-6; the
  Quick/Nimble bump had let it drift to 1.2.0 (SwiftFormat nightly), which
  reformatted ~170 unrelated files and broke lint-swift.
- Run test-package on macos-26 / Xcode 26.5 (iOS 26.5 simulator): the BarStackViewSpec
  SIGABRT reproduces only on the iOS 26.2 simulator (Xcode 26.3), not on 26.5, where
  the full suite passes locally.
macos-26 ships iPhone 17-family simulators, not iPhone 16, so the test destination
must name an available device.
The Release optimizer (-Os) crashes swiftc 6.3 in the SIL EarlyPerfInliner pass
while compiling EpoxySwiftUIHostingView's synthesized main-actor deinit — a compiler
bug (it prints 'Please submit a bug report'), not a source error; SPM builds -Onone
and is unaffected. Lint the pod in Debug. Also drop the temporary raw-log debug now
that test-package is diagnosed and green.
The main-actor migration makes the .system PresentationModel NotificationCenter
closure infer @sendable, producing non-Sendable-capture warnings. The code compiles
(SPM builds don't fail on warnings); allow warnings for the CocoaPods lint too.
@andreacipriani andreacipriani changed the title [Spike] Broadly isolate Epoxy to the main actor [Spike][DNM] Broadly isolate Epoxy to the main actor Jul 21, 2026

@bachand bachand left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great @andreacipriani. 🙌 Thank you!

It's very helpful to see where we're going as we work to get there incrementally. 👍


deinit {
dispose()
Task { @MainActor [dispose] in dispose() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if introducing the asynchronous invocation may affect the behavior 🤔 Do we have any other options? I suspect we are somewhat limited, given the above comment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I added this in the PR description as one the points to further investigate, since I agree that it changes the behavior. I didn't find a great solution unless we bump the min iOS version of the library.

  • isolated deinit needs iOS 18.4+ but Epoxy targets iOS 13.
  • nonisolated deinit with an assume isolated is risky as the object could be disposed off main

For broader context, would bumping the iOS min version a viable solution here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine for us to bump the min iOS version here - my understanding is that we may be introducing incompatible changes anyways.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, updating the PR to bumb the iOS version

/// - SeeAlso: `BottomBarInstaller`
/// - SeeAlso: `TopBarInstaller`
public struct BarModel<View: UIView>: ViewEpoxyModeled {
public struct BarModel<View: UIView>: @MainActor

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment thread Package.swift
Comment on lines +15 to +18
let mainActorIsolation: [SwiftSetting] = [
.unsafeFlags(["-default-isolation", "MainActor"]),
.enableUpcomingFeature("InferIsolatedConformances"),
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asking to learn: are these flags necessary given the annotations we added? Or now that we've added the annotations, would we remove these?

I know that this is a spike PR that we don't intend to merge, so either way is reasonable — I am asking only to build my understanding. 👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's okay for us to raise the minimum toolchain as well as the min iOS target.

My understanding is that "assume main actor" is just saving us on lots of @MainActor annotations, which I think can be preferable to keep the code simpler.

Meanwhile InferIsolatedConformances is necessary to solve annoyances with isolated types that conform to Equatable and Decodable, which are nonisolated. I think we'd need to keep it. There may be other workarounds I haven't understood.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, it's easier to set the default isolation to main: I followed up and removed 40+ redundant standalone MainActor tags. We still need to annotate types (like closures) and isolated conformances since the default isolation doesn't reach there.

Comment thread Example/EpoxyExample/Extensions/UIImageView+RemoteImage.swift Outdated
var lastBaselineAnchor: NSLayoutYAxisAnchor { get }
/// unique identifier for this constrainable
var dataID: AnyHashable { get }
nonisolated var dataID: AnyHashable { get }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coming back to the discussion in #183 (comment) and below, does the fact that this needs to be nonisolated indicate that Epoxy is doing work off the main queue?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think diffing is done by Epoxy off the main thread, I don't see any proof of that.
I don't have broader context to know if this was a design decision to keep diffing nonisolated and give consumers the possibility of doing that outside the main thread. For example, if a consumer wants to diff a large list off main, they can do it today as the nonisolated is flexible to support both ways (main/bg).

But if you all tell me this is a no-goal, I can change this and isolate it to the main actor.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think i initially made these nonisolated because it had fewer downstream issues in our codebase (there were so many new warnings/error I couldn't really follow what was happening), but I've come around that we should just make everything MainActor if it simplifies thing.

We should probably test out the impact of changing this against our codebase - would that help us find any off-main callers to these types, or would we have a lot of callers that aren't properly annotated?

@andreacipriani andreacipriani Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After trying this out, I've changed my mind and think we should keep diffing nonisolated.

Isolating the example app to the main actor was harder than expect. With a Equatable or Hashable isolated conformance to main actor, every comparison must also be on the main actor. Many happen in nonisolated contexts you don't control, for example the compiler synthesized == hash, and Set/Dictionary.

One example was AlignableTextRow.Style

struct Style: Hashable {
  var titleAlignment: VGroup.ItemAlignment
}

If ItemAlignment conformance is main actor isolated, the synthesized hash (nonisolated) can't use it 🙈
Of course it compiles if Style becomes @mainactor too, but that cascades to every type holding an alignment. The only way I got the example to build was pushing default main actor isolation through the whole app, which seems exactly the adoption cost we don't want to impose on customers and ourselves.

My final proposal is to keep Diffable and the Equatable/Hashable conformances (e.g. ItemAlignment) nonisolated. Epoxy will still only diff on the main actor, but the value types stay usable from nonisolated code.
Happy to document on the code if we're aligned.

cc @bachand @jqsilver @brynbodayle

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for investigating with an example app, I think that brings the clarity we need.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds good to me @andreacipriani. Thanks for looking into this!

Comment thread Sources/EpoxyLayoutGroups/Groups/HGroupItemAlignment.swift Outdated
@@ -35,8 +35,8 @@ public struct NavigationModel {
public init<Params: Equatable>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coming back to https://github.com/airbnb/epoxy-ios/pull/184/changes#r3546985071, could we see if we can remove the assumeIsolated(…) calls in this spike?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

absolutely, removed them all

Now that the package is fully main-actor, the only remaining assumeIsolated bridges
were in the model factories, because the stored closure typealiases were still
nonisolated function *types* (-default-isolation doesn't apply to type annotations).

Annotate MakeView/SetContent/SetBehaviors (+ the WillDisplay/DidDisplay/DidEndDisplaying/
DidSelect/DidChangeState callbacks) and SwiftUIView's makeContent/Configuration as
@mainactor, add a @mainactor EpoxyModelProperty.chain() overload, and drop all 42
assumeIsolated calls across the 5 factories. This is the EpoxyCore model-layer isolation
that airbnb#183 deferred; it answers the review question on airbnb#185.
Per review feedback, the incremental Swift Concurrency work shouldn't carry unrelated
style churn. The churn came from airbnb/swift 1.0.3 (SwiftFormat 0.51-beta-6) enforcing
redundantOptionalBinding/redundantSelf, which master's files don't follow. Pin
airbnb/swift to 1.0.1 (SwiftFormat 0.50-beta-2, matching master) and restore the
formatting-only files to their base state, leaving a concurrency-focused diff.
Xcode 26.3 rejects two chain<A>() overloads differing only by @mainactor on the
return closure as an invalid redeclaration (26.5 allowed it). Keep the original
nonisolated chain<A>() and add a distinctly-named chainMainActor<A>() for the
main-actor callback properties; revert the arity-1 test to the nonisolated closure.
With default main-actor isolation, explicit @mainactor on declarations (funcs,
types, members) is redundant — the compiler already isolates them. Remove the 40
declaration-level annotations, keeping only what's genuinely required: @mainactor on
function *types* (closures/typealiases/stored props, which default isolation doesn't
reach) and the isolated-conformance annotations the compiler still demands (e.g.
EpoxySwiftUIHostingView, Constrainable conformers). 132 -> 92 annotations.
Per review, the spike should show only Swift-concurrency differences. Disable the
lint-swift and lint-podspec CI jobs (they only forced non-concurrency formatting and
the CocoaPods swiftc-optimizer-crash workarounds), and revert the airbnb/swift
formatter pin, ConfigurePodspec, and Rakefile to master. Keeps the build/test CI
(iOS/tvOS/macOS + unit/perf) plus the concurrency source changes.
…aque generics)

Earlier 0.51-beta-6 format runs had baked non-concurrency style changes into the
concurrency-touched files (if let x = x -> if let x; <View: UIView> -> some UIView).
Revert those so the diff shows only Swift-concurrency changes.
Now that the spike can raise the floors:
- swift-tools-version 6.2 + .defaultIsolation(MainActor.self) instead of the
  -default-isolation raw flag via .unsafeFlags — the sanctioned API, and it lets the
  package be consumed as a normal SPM dependency (.unsafeFlags forbids that).
- Min iOS 18.4, so the observer Token uses a synchronous isolated deinit instead of
  hopping to the main actor via Task { @mainactor } — removes the async-disposal
  behavior change. Example app deployment target bumped to match.
- name: Test Package
run: bundle exec rake test:package

lint-swift:

@andreacipriani andreacipriani Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed from the spike to avoid including unnecessary stylistic changes

GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
IPHONEOS_DEPLOYMENT_TARGET = 18.4;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bumped for the spike to allow for isolated deinit. Might not be necessary on real PR

Push the spike all the way: drop the nonisolated carve-outs on Constrainable
(dataID/isEqual) and the HGroup/VGroup ItemAlignment enums so the identity/diffing
layer is main-actor like everything else. The package now has zero nonisolated, zero
@preconcurrency, and zero assumeIsolated. Trade-off: diffing is now locked to the main
actor — a consumer can no longer diff off-main (compute off / apply on).
dataID: dataID,
params: style,
content: content,
makeView: { style in MainActor.assumeIsolated { Self(style: style) } },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉


private var setContentProperty: EpoxyModelProperty<SetContent?> {
.init(keyPath: \Self.setContent, defaultValue: nil, updateStrategy: .chain())
.init(keyPath: \Self.setContent, defaultValue: nil, updateStrategy: .chainMainActor())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to keep the previous signature and update chain() to be bound to the main actor?

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.

3 participants