Compound - Swift 6.2 and Main actor isolation (#5109)

* compound is now using 6.2 and main actor isolation + some tweaks to make it build tests and EX

* better handling of the nonisolated context for CompoundUIColors

* improving docs

* fix comment

* remove unused Sendable conformance
This commit is contained in:
Mauro
2026-02-16 11:19:12 +01:00
committed by GitHub
parent b4eedfef59
commit 26bfa6ed7c
20 changed files with 65 additions and 34 deletions
@@ -10,11 +10,11 @@ import SwiftUI
extension View { extension View {
func progressMask(progress: CGFloat, func progressMask(progress: CGFloat,
trackColor: Color = .compound.iconSecondary, trackColor: @autoclosure @MainActor () -> Color = .compound.iconSecondary,
backgroundTrackColor: Color = .compound.iconQuaternary) -> some View { backgroundTrackColor: @autoclosure @MainActor () -> Color = .compound.iconQuaternary) -> some View {
modifier(ProgressMaskModifier(progress: progress, modifier(ProgressMaskModifier(progress: progress,
trackColor: trackColor, trackColor: trackColor(),
backgroundTrackColor: backgroundTrackColor)) backgroundTrackColor: backgroundTrackColor()))
} }
} }
@@ -10,6 +10,7 @@ import Compound
import SwiftUI import SwiftUI
import SwiftUIIntrospect import SwiftUIIntrospect
@MainActor
extension TextFieldStyle where Self == ElementTextFieldStyle { extension TextFieldStyle where Self == ElementTextFieldStyle {
static func element(labelText: String? = nil, static func element(labelText: String? = nil,
footerText: String? = nil, footerText: String? = nil,
@@ -34,6 +35,7 @@ extension TextFieldStyle where Self == ElementTextFieldStyle {
} }
/// The text field style used in authentication screens. /// The text field style used in authentication screens.
@MainActor
struct ElementTextFieldStyle: @MainActor TextFieldStyle { struct ElementTextFieldStyle: @MainActor TextFieldStyle {
enum State { enum State {
case success case success
@@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
@MainActor
struct RoomInviterDetails: Equatable { struct RoomInviterDetails: Equatable {
let id: String let id: String
let displayName: String? let displayName: String?
@@ -9,6 +9,7 @@ import Compound
import SwiftUI import SwiftUI
struct ToolbarButton: View { struct ToolbarButton: View {
@MainActor
enum Role { enum Role {
static let cancel = Role.cancel(title: L10n.actionCancel) static let cancel = Role.cancel(title: L10n.actionCancel)
static let done = Role.confirm(title: L10n.actionDone) static let done = Role.confirm(title: L10n.actionDone)
@@ -128,6 +128,7 @@ struct RoomMembersListScreen: View {
} }
} }
@MainActor
private enum MembersSection { private enum MembersSection {
case joined case joined
case invited case invited
@@ -38,6 +38,7 @@ struct FormattingToolbar: View {
} }
} }
@MainActor
private extension FormatItem { private extension FormatItem {
var foregroundColor: Color { var foregroundColor: Color {
switch state { switch state {
@@ -109,6 +109,7 @@ enum RoomScreenFooterViewDetails {
case verificationViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL) case verificationViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL)
} }
@MainActor
enum PinnedEventsBannerState: Equatable { enum PinnedEventsBannerState: Equatable {
case loading(numbersOfEvents: Int) case loading(numbersOfEvents: Int)
case loaded(state: PinnedEventsState) case loaded(state: PinnedEventsState)
@@ -213,6 +214,7 @@ enum PinnedEventsBannerState: Equatable {
} }
} }
@MainActor
struct PinnedEventsState: Equatable { struct PinnedEventsState: Equatable {
var pinnedEventContents: OrderedDictionary<String, AttributedString> = [:] { var pinnedEventContents: OrderedDictionary<String, AttributedString> = [:] {
didSet { didSet {
@@ -243,6 +243,7 @@ private struct VerifiedUserSendFailureView: View {
} }
} }
@MainActor
private extension EncryptionAuthenticity { private extension EncryptionAuthenticity {
var foregroundStyle: SwiftUI.Color { var foregroundStyle: SwiftUI.Color {
switch color { switch color {
@@ -16,10 +16,10 @@ extension View {
/// - color: self explanatory, defaults to subtle secondary /// - color: self explanatory, defaults to subtle secondary
func bubbleBackground(isOutgoing: Bool = true, func bubbleBackground(isOutgoing: Bool = true,
insets: EdgeInsets = .init(top: 8, leading: 12, bottom: 8, trailing: 12), insets: EdgeInsets = .init(top: 8, leading: 12, bottom: 8, trailing: 12),
color: Color? = .compound.bgSubtleSecondary) -> some View { color: @autoclosure @MainActor () -> Color? = .compound.bgSubtleSecondary) -> some View {
modifier(TimelineItemBubbleBackgroundModifier(isOutgoing: isOutgoing, modifier(TimelineItemBubbleBackgroundModifier(isOutgoing: isOutgoing,
insets: insets, insets: insets,
color: color)) color: color()))
} }
} }
@@ -238,6 +238,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
} }
} }
@MainActor
private extension EventBasedTimelineItemProtocol { private extension EventBasedTimelineItemProtocol {
var bubbleBackgroundColor: Color? { var bubbleBackgroundColor: Color? {
let defaultColor: Color = isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming let defaultColor: Color = isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming
@@ -112,6 +112,7 @@ private struct TimelineItemSendInfoLabel: View {
} }
/// All the data needed to render a timeline item's send info label. /// All the data needed to render a timeline item's send info label.
@MainActor
private struct TimelineItemSendInfo { private struct TimelineItemSendInfo {
enum Status { enum Status {
case sendingFailed case sendingFailed
@@ -185,6 +186,7 @@ private extension TimelineItemSendInfo {
} }
} }
@MainActor
private extension EncryptionAuthenticity { private extension EncryptionAuthenticity {
var foregroundStyle: SwiftUI.Color { var foregroundStyle: SwiftUI.Color {
switch color { switch color {
@@ -9,6 +9,7 @@
@testable import ElementX @testable import ElementX
import XCTest import XCTest
@MainActor
class PinnedEventsBannerStateTests: XCTestCase { class PinnedEventsBannerStateTests: XCTestCase {
func testEmpty() { func testEmpty() {
var state = PinnedEventsBannerState.loading(numbersOfEvents: 0) var state = PinnedEventsBannerState.loading(numbersOfEvents: 0)
+4 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 5.9 // swift-tools-version: 6.2
import PackageDescription import PackageDescription
@@ -22,6 +22,9 @@ let package = Package(
.product(name: "CompoundDesignTokens", package: "compound-design-tokens"), .product(name: "CompoundDesignTokens", package: "compound-design-tokens"),
.product(name: "SwiftUIIntrospect", package: "SwiftUI-Introspect"), .product(name: "SwiftUIIntrospect", package: "SwiftUI-Introspect"),
.product(name: "SFSafeSymbols", package: "SFSafeSymbols") .product(name: "SFSafeSymbols", package: "SFSafeSymbols")
],
swiftSettings: [
.defaultIsolation(MainActor.self)
] ]
), ),
.testTarget( .testTarget(
@@ -42,7 +42,7 @@ public class CompoundColors {
/// Customise the colour at the specified key path with the supplied colour. /// Customise the colour at the specified key path with the supplied colour.
/// Supplying `nil` as the colour will remove any existing customisation. /// Supplying `nil` as the colour will remove any existing customisation.
@MainActor public func override(_ keyPath: KeyPath<CompoundColorTokens, Color>, with color: Color?) { public func override(_ keyPath: KeyPath<CompoundColorTokens, Color>, with color: Color?) {
overrides[keyPath] = color overrides[keyPath] = color
} }
@@ -15,10 +15,12 @@ public extension UIColor {
} }
/// The colours used by Element as defined in Compound Design Tokens. /// The colours used by Element as defined in Compound Design Tokens.
/// This struct contains only the colour tokens in a more usable form. /// This class contains only the colour tokens in a more usable form.
/// Since this can be used by attributed strings which may run in non isolated concurrent contexts,
/// The object needs to be nonisolated.
@Observable @Observable
@dynamicMemberLookup @dynamicMemberLookup
public class CompoundUIColors { public final nonisolated class CompoundUIColors {
/// The base colour tokens that form the palette of available colours. /// The base colour tokens that form the palette of available colours.
/// ///
/// Normally these shouldn't be necessary, however in practice we may need /// Normally these shouldn't be necessary, however in practice we may need
@@ -35,7 +37,7 @@ public class CompoundUIColors {
/// Customise the colour at the specified key path with the supplied colour. /// Customise the colour at the specified key path with the supplied colour.
/// Supplying `nil` as the colour will remove any existing customisation. /// Supplying `nil` as the colour will remove any existing customisation.
@MainActor public func override(_ keyPath: KeyPath<CompoundUIColorTokens, UIColor>, with color: UIColor?) { public func override(_ keyPath: KeyPath<CompoundUIColorTokens, UIColor>, with color: UIColor?) {
overrides[keyPath] = color overrides[keyPath] = color
} }
@@ -44,7 +46,10 @@ public class CompoundUIColors {
// swiftformat:disable numberFormatting // swiftformat:disable numberFormatting
/// This token is a placeholder and hasn't been finalised. /// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgCodeBlock = coreTokens.gray100 public var _bgCodeBlock: UIColor {
CompoundUIColors.coreTokens.gray100
}
/// This token is a placeholder and hasn't been finalised. /// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgSubtleSecondaryAlpha = coreTokens.alphaGray300 public let _bgSubtleSecondaryAlpha = coreTokens.alphaGray300
@@ -11,7 +11,6 @@ import SwiftUIIntrospect
public extension View { public extension View {
/// Styles a search bar text field using the Compound design tokens. /// Styles a search bar text field using the Compound design tokens.
/// This modifier is to be used in combination with `.searchable`. /// This modifier is to be used in combination with `.searchable`.
@MainActor
func compoundSearchField() -> some View { func compoundSearchField() -> some View {
introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in
// Uses the navigation stack as .searchField is unreliable when pushing the second search bar, during the create rooms flow. // Uses the navigation stack as .searchField is unreliable when pushing the second search bar, during the create rooms flow.
@@ -11,6 +11,7 @@ import Foundation
import SwiftUI import SwiftUI
import XCTest import XCTest
@MainActor
final class DecorativeColorsTests: XCTestCase { final class DecorativeColorsTests: XCTestCase {
struct TestCase { struct TestCase {
let input: String let input: String
@@ -10,6 +10,7 @@
import SwiftUI import SwiftUI
import XCTest import XCTest
@MainActor
final class FontSizeTests: XCTestCase { final class FontSizeTests: XCTestCase {
/// Test all system text styles to assert mapping between `Font` and `UIFont`. /// Test all system text styles to assert mapping between `Font` and `UIFont`.
func testTextStyle() { func testTextStyle() {
@@ -103,3 +104,4 @@ final class FontSizeTests: XCTestCase {
XCTAssertEqual(styledCustomFootnote15FontSize?.style, .footnote) XCTAssertEqual(styledCustomFootnote15FontSize?.style, .footnote)
} }
} }
@@ -10,20 +10,25 @@
import Foundation import Foundation
import XCTest import XCTest
@MainActor final class OverrideColorTests: XCTestCase {
class OverrideColorTests: XCTestCase { func testSwiftUI() async {
func testSwiftUI() { // For some very weird reason we need this to be async, `@MainActor` is not enough
let colors = CompoundColors() // it will compile but when running it will crash at the end of the run due to some deinit problems.
let tokens = CompoundColorTokens() // The other solution would be to make CompoundColors nonisolated but we don't really need that.
XCTAssertEqual(colors.textPrimary, tokens.textPrimary) await MainActor.run {
let colors = CompoundColors()
let tokens = CompoundColorTokens()
XCTAssertEqual(colors.textPrimary, tokens.textPrimary)
colors.override(\.textPrimary, with: .pink) colors.override(\.textPrimary, with: .pink)
XCTAssertEqual(colors.textPrimary, .pink) XCTAssertEqual(colors.textPrimary, .pink)
colors.override(\.textPrimary, with: nil) colors.override(\.textPrimary, with: nil)
XCTAssertEqual(colors.textPrimary, tokens.textPrimary) XCTAssertEqual(colors.textPrimary, tokens.textPrimary)
}
} }
/// UIColors are nonisolated, so this is fine.
func testUIKit() { func testUIKit() {
let colors = CompoundUIColors() let colors = CompoundUIColors()
let tokens = CompoundUIColorTokens() let tokens = CompoundUIColorTokens()
@@ -8,7 +8,7 @@
import Combine import Combine
@testable import Compound @testable import Compound
@testable import SnapshotTesting @preconcurrency @testable import SnapshotTesting
import SwiftUI import SwiftUI
import XCTest import XCTest
@@ -28,15 +28,17 @@ class PreviewTests: XCTestCase {
.init(name: "iPad", device: "iPad")] .init(name: "iPad", device: "iPad")]
private var recordMode: SnapshotTestingConfiguration.Record = .missing private var recordMode: SnapshotTestingConfiguration.Record = .missing
override func setUp() { override func setUp() async throws {
super.setUp() try await super.setUp()
if ProcessInfo().environment["RECORD_FAILURES"].map(Bool.init) == true { await MainActor.run {
recordMode = .failed if ProcessInfo().environment["RECORD_FAILURES"].map(Bool.init) == true {
recordMode = .failed
}
checkEnvironments()
UIView.setAnimationsEnabled(false)
} }
checkEnvironments()
UIView.setAnimationsEnabled(false)
} }
/// Check environments to avoid problems with snapshots on different devices or OS. /// Check environments to avoid problems with snapshots on different devices or OS.
@@ -183,6 +185,7 @@ private extension PreviewDevice {
} }
private extension Snapshotting where Value: SwiftUI.View, Format == UIImage { private extension Snapshotting where Value: SwiftUI.View, Format == UIImage {
@MainActor
static func prefireImage(drawHierarchyInKeyWindow: Bool = false, static func prefireImage(drawHierarchyInKeyWindow: Bool = false,
preferences: SnapshotPreferences, preferences: SnapshotPreferences,
layout: SwiftUISnapshotLayout = .sizeThatFits, layout: SwiftUISnapshotLayout = .sizeThatFits,
@@ -204,7 +207,7 @@ private extension Snapshotting where Value: SwiftUI.View, Format == UIImage {
} }
return SimplySnapshotting<UIImage>(pathExtension: "png", diffing: .prefireImage(preferences: preferences, scale: traits.displayScale)) return SimplySnapshotting<UIImage>(pathExtension: "png", diffing: .prefireImage(preferences: preferences, scale: traits.displayScale))
.asyncPullback { view in .asyncPullback { @MainActor view in
var config = config var config = config
let controller: UIViewController let controller: UIViewController