Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion IMPLEMENTATION_DETAILS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ RoundedRectangle(cornerRadius: 12)
Theme tokens are resolved lazily through SwiftUI's `ShapeStyle` protocol. Under the hood, `ThemeShapeStyle<Style>` holds a key path into the `Theme` and resolves the correct light/dark variant at render time using the environment's color scheme:

```swift
struct ThemeShapeStyle<Style: ShapeStyle>: ShapeStyle, Sendable {
struct ThemeShapeStyle<Style: ShapeStyle>: ShapeStyle, @unchecked Sendable {
let keyPath: KeyPath<Theme, ThemeAdaptiveStyle<Style>>

func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
Expand Down
1 change: 0 additions & 1 deletion Sources/ThemeKit/KeyPath+Sendable.swift

This file was deleted.

11 changes: 8 additions & 3 deletions Sources/ThemeKitGenerator/ThemeShadowedStyleGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ nonisolated public struct ThemeShadowedStyleGenerator: Sendable {
import SwiftUI
import ThemeKit

nonisolated public struct ThemeShadowedStyle<Base: ShapeStyle>: ShapeStyle {
nonisolated let base: Base
nonisolated let shadowKeyPath: KeyPath<Theme, ThemeAdaptiveStyle<Shadow>>
// `@unchecked Sendable` because the stored `shadowKeyPath` is a read-only
// `KeyPath` over the `Sendable` `Theme` type. Sendability is asserted
// here rather than through a blanket retroactive `KeyPath: Sendable`
// conformance, which would leak into every consumer and conflict with
// libraries (e.g. TCA) that manage key-path sendability themselves.
nonisolated public struct ThemeShadowedStyle<Base: ShapeStyle>: ShapeStyle, @unchecked Sendable {
let base: Base
let shadowKeyPath: KeyPath<Theme, ThemeAdaptiveStyle<Shadow>>

nonisolated public init(base: Base, shadowKeyPath: KeyPath<Theme, ThemeAdaptiveStyle<Shadow>>) {
self.base = base
Expand Down
9 changes: 7 additions & 2 deletions Sources/ThemeKitGenerator/ThemeShapeStyleGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ nonisolated public struct ThemeShapeStyleGenerator: Sendable {
import SwiftUI
import ThemeKit

nonisolated public struct ThemeShapeStyle<Style: ShapeStyle & Sendable & Codable & Equatable>: ShapeStyle, Equatable {
nonisolated let keyPath: KeyPath<Theme, ThemeAdaptiveStyle<Style>>
// `@unchecked Sendable` because the stored `keyPath` is a read-only
// `KeyPath` over the `Sendable` `Theme` type. Sendability is asserted
// here rather than through a blanket retroactive `KeyPath: Sendable`
// conformance, which would leak into every consumer and conflict with
// libraries (e.g. TCA) that manage key-path sendability themselves.
nonisolated public struct ThemeShapeStyle<Style: ShapeStyle & Sendable & Codable & Equatable>: ShapeStyle, Equatable, @unchecked Sendable {
let keyPath: KeyPath<Theme, ThemeAdaptiveStyle<Style>>

nonisolated public init(keyPath: KeyPath<Theme, ThemeAdaptiveStyle<Style>>) {
self.keyPath = keyPath
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Testing
import SwiftUI
import GeneratedCodeSwift5
import GeneratedCodeSwift5MainActor
import GeneratedCodeSwift6
Expand Down Expand Up @@ -44,4 +45,37 @@ struct GeneratedCodeCompilationTests {
_ = theme.meshGradients.aurora
_ = theme.shadows.card
}

// MARK: - Sendability

/// Requires its argument to be `Sendable`. Calls fail to compile if the
/// generated style types lose their `Sendable` conformance.
private func requireSendable<T: Sendable>(_ value: T) -> T { value }

/// The generated style types store a `KeyPath` into `Theme` and must be
/// `Sendable` on their own — ThemeKit intentionally ships no retroactive
/// `KeyPath: Sendable` conformance, which would leak into every consumer
/// and conflict with libraries that manage key-path sendability themselves.
@Test func generatedStyles_areSendable() {
_ = requireSendable(GeneratedCodeSwift5.ThemeShapeStyle(keyPath: \.colors.surface))
_ = requireSendable(GeneratedCodeSwift5MainActor.ThemeShapeStyle(keyPath: \.colors.surface))
_ = requireSendable(GeneratedCodeSwift6.ThemeShapeStyle(keyPath: \.colors.surface))
_ = requireSendable(GeneratedCodeSwift6MainActor.ThemeShapeStyle(keyPath: \.colors.surface))

_ = requireSendable(GeneratedCodeSwift5.ThemeShadowedStyle(base: Color.red, shadowKeyPath: \.shadows.card))
_ = requireSendable(GeneratedCodeSwift5MainActor.ThemeShadowedStyle(base: Color.red, shadowKeyPath: \.shadows.card))
_ = requireSendable(GeneratedCodeSwift6.ThemeShadowedStyle(base: Color.red, shadowKeyPath: \.shadows.card))
_ = requireSendable(GeneratedCodeSwift6MainActor.ThemeShadowedStyle(base: Color.red, shadowKeyPath: \.shadows.card))
}

/// Generated styles must survive a hop across an actor boundary.
@Test func generatedStyles_canCrossActorBoundary() async {
let style = GeneratedCodeSwift6.ThemeShapeStyle(keyPath: \.colors.surface)
let shadowed = GeneratedCodeSwift6MainActor.ThemeShadowedStyle(base: Color.red, shadowKeyPath: \.shadows.card)

await Task { @Sendable in
_ = style
_ = shadowed
}.value
}
}
20 changes: 20 additions & 0 deletions Tests/ThemeKitGeneratorTests/ThemeFileGeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,17 @@ struct ThemeFileGeneratorTests {
#expect(shapeStyle.content.contains("Style: ShapeStyle & Sendable & Codable"))
}

@Test func themeShapeStyle_declaresUncheckedSendable() throws {
let files = try ThemeFileGenerator().generate(fromJSON: fullJSON).files
let shapeStyle = try #require(files.first { $0.name == "ThemeShapeStyle.swift" })

// Sendability must be asserted on the struct itself — ThemeKit deliberately
// ships no retroactive `KeyPath: Sendable` conformance.
#expect(shapeStyle.content.contains("ShapeStyle, Equatable, @unchecked Sendable"))
#expect(shapeStyle.content.contains("let keyPath: KeyPath<Theme, ThemeAdaptiveStyle<Style>>"))
#expect(!shapeStyle.content.contains("nonisolated let keyPath"))
}

// MARK: - Config Section

@Test func configSection_providesOutputPath() throws {
Expand Down Expand Up @@ -315,6 +326,15 @@ struct ThemeFileGeneratorTests {
#expect(file.content.contains("ThemeShadowedStyle: Equatable where Base: Equatable"))
}

@Test func themeShadowedStyle_declaresUncheckedSendable() throws {
let files = try ThemeFileGenerator().generate(fromJSON: shadowsOnlyJSON).files
let file = try #require(files.first { $0.name == "ThemeShadowedStyle.swift" })

#expect(file.content.contains("ShapeStyle, @unchecked Sendable"))
#expect(file.content.contains("let shadowKeyPath: KeyPath<Theme, ThemeAdaptiveStyle<Shadow>>"))
#expect(!file.content.contains("nonisolated let"))
}

@Test func shadowShapeStyle_containsBothStaticAndInstanceProperties() throws {
let files = try ThemeFileGenerator().generate(fromJSON: shadowsOnlyJSON).files
let shadowExt = try #require(files.first { $0.name == "ShapeStyle+ThemeShadows.swift" })
Expand Down
66 changes: 0 additions & 66 deletions Tests/ThemeKitTests/KeyPathSendableTests.swift

This file was deleted.

Loading