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
3 changes: 2 additions & 1 deletion .github/workflows/swift.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ jobs:
- name: Install iOS ${{ matrix.sdk }}
if: ${{ matrix.installation_required }}
run: |
sudo xcodes runtimes install "iOS ${{ matrix.sdk }}"
sudo xcodebuild -downloadPlatform iOS -buildVersion "${{ matrix.sdk }}"
sudo xcodebuild -runFirstLaunch
xcrun simctl list

- name: Ensure sim exists
Expand Down
9 changes: 8 additions & 1 deletion Workflow/Sources/RuntimeConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ public enum Runtime {

private static var _defaultConfiguration = Configuration()

static var configuration: Configuration {
/// The configuration active for the current task, falling back to the default configuration.
package static var configuration: Configuration {
_currentConfiguration ?? _defaultConfiguration
}

Expand Down Expand Up @@ -80,5 +81,11 @@ extension Runtime {
/// Whether action handling should be delegated to the `SinkEventHandler` type.
/// This is expected to eventually be removed and become the default behavior.
public var useSinkEventHandler: Bool = false

/// Whether WorkflowSwiftUI suppresses Perception's debug-only runtime warning when using
/// native Observation.
///
/// Defaults to `false`, so Store access continues through Perception normally.
public var suppressPerceptionCheckingWhenUsingObservation: Bool = false
}
}
27 changes: 9 additions & 18 deletions WorkflowSwiftUI/Sources/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import CasePaths
import IdentifiedCollections
import Perception
import SwiftUI
import Workflow
@_spi(WorkflowRuntimeConfig) import Workflow

/// Provides access to a workflow's state and actions from within an ``ObservableScreen``.
///
Expand Down Expand Up @@ -57,28 +57,23 @@ public final class Store<Model: ObservableModel>: Perceptible {
}
}

/// Suppresses Perception's debug-only runtime warning on iOS 17+.
/// Funnel point for suppressing Perception's debug-only runtime warning when state is accessed
/// outside of `WithPerceptionTracking`.
///
/// On iOS 17+, `Store` conforms to `Observable` and SwiftUI's native observation tracks state
/// access. However, `PerceptionRegistrar.access` resolves to the `Perceptible` overload at
/// compile time (the `Observable` overload is unavailable since `Store.state` is not
/// `@available(iOS 17, *)`). That overload calls `check()`, which fires a debug warning when
/// state is accessed outside of `WithPerceptionTracking` — even though native observation is
/// tracking the access. `WithPerceptionTracking` does not suppress the warning either, because
/// binding getters and child store scoping are evaluated by SwiftUI's attribute graph outside
/// of the `WithPerceptionTracking` closure. Setting `skipPerceptionChecking` directly bypasses
/// the debug-only `check()` gate on iOS 17+ while preserving the warning on earlier OS
/// versions.
/// Suppression is opt-in through
/// `Runtime.Configuration.suppressPerceptionCheckingWhenUsingObservation`, so Store access
/// executes normally by default.
private func withPerceptionCheckSuppressed<T>(_ operation: () -> T) -> T {
#if DEBUG && canImport(Observation)
if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) {
if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *),
Runtime.configuration.suppressPerceptionCheckingWhenUsingObservation
{
return _PerceptionLocals.$skipPerceptionChecking.withValue(true, operation: operation)
}
#endif
return operation()
}

/// Reads a value from the state, suppressing Perception's debug-only runtime warning on iOS 17+.
private func readState<T>(keyPath: KeyPath<State, T>) -> T {
withPerceptionCheckSuppressed {
state[keyPath: keyPath]
Expand Down Expand Up @@ -242,10 +237,6 @@ extension Store {
}

/// Track access to a child store wrapper.
///
/// On iOS 17+, `skipPerceptionChecking` is set for the same reason as
/// ``readState(keyPath:)`` — the `Perceptible` overload is selected at compile time and fires
/// a false-positive warning in debug builds.
func access(
keyPath key: KeyPath<Model, some Any>,
isChanged: @escaping (Model, Model) -> Bool,
Expand Down
100 changes: 99 additions & 1 deletion WorkflowSwiftUI/Tests/StoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import CasePaths
import IdentifiedCollections
import Perception
import SwiftUI
import Workflow
@_spi(WorkflowRuntimeConfig) import Workflow
import XCTest
@testable import WorkflowSwiftUI

Expand Down Expand Up @@ -775,6 +775,58 @@ final class StoreTests: XCTestCase {

// MARK: - Native SwiftUI Bindings

@MainActor
func test_perceptionRuntimeWarningsWhenUsingObservation() throws {
#if DEBUG
guard #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) else {
throw XCTSkip("Requires native Observation")
}

let child = StateAccessor(state: ParentModel.ChildState()) { _ in }
let model = ParentModel(
accessor: StateAccessor(state: State()) { _ in },
child: child,
optional: child
)
let (store, _) = Store.make(model: model)

let image = ImageRenderer(content: PerceptionRuntimeWarningView(store: store)).cgImage
_ = image
#else
throw XCTSkip("Perception runtime warnings are debug-only")
#endif
}

@MainActor
func test_perceptionRuntimeWarningsCanBeSuppressedWhenUsingObservation() throws {
#if DEBUG
guard #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) else {
throw XCTSkip("Requires native Observation")
}

let child = StateAccessor(state: ParentModel.ChildState()) { _ in }
let model = ParentModel(
accessor: StateAccessor(state: State()) { _ in },
child: child,
optional: child
)
let (store, _) = Store.make(model: model)

// Rendering evaluates the Store reads inside the override. If suppression fails,
// Perception reports an unexpected XCTest failure, so the absence of a failure is the
// assertion.
let image = Runtime.withConfiguration(
override: { $0.suppressPerceptionCheckingWhenUsingObservation = true },
operation: {
ImageRenderer(content: SuppressedPerceptionRuntimeWarningView(store: store)).cgImage
}
)
_ = image
#else
throw XCTSkip("Perception runtime warnings are debug-only")
#endif
}

@MainActor
func test_nativeBindings() async throws {
guard #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) else {
Expand Down Expand Up @@ -985,6 +1037,52 @@ final class StoreTests: XCTestCase {
}
}

/// Reads Store values from SwiftUI so Perception recognizes the AttributeGraph call stack.
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
private struct PerceptionRuntimeWarningView: View {
let store: Store<ParentModel>

var body: some View {
VStack {
Text(
expectPerceptionRuntimeWarning {
store.count
}.description
)
Text(
expectPerceptionRuntimeWarning {
store.optional == nil
}.description
)
}
}
}

/// Reads Store values from SwiftUI without expecting Perception runtime warnings.
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
private struct SuppressedPerceptionRuntimeWarningView: View {
let store: Store<ParentModel>

var body: some View {
VStack {
Text(store.count.description)
Text((store.optional == nil).description)
}
}
}

/// Runs a Store read and verifies Perception reports the untracked-state runtime warning.
private func expectPerceptionRuntimeWarning<Result>(
_ operation: () -> Result
) -> Result {
XCTExpectFailure(failingBlock: operation) {
$0.compactDescription.contains("Perceptible state")
&& $0.compactDescription.contains(
"was accessed from a view but is not being tracked"
)
}
}

@ObservableState
private struct State {
var count = 0
Expand Down
Loading