The English README is the canonical, most up-to-date version. The localized README files are companion entry points that summarize the framework and link back to the English source of truth.
InnoFlow is a SwiftUI-first unidirectional architecture framework for business and domain state transitions.
The framework now treats the following as source-of-truth principles:
- Official feature authoring is
var body: some Reducer<State, Action>. @InnoFlowfeatures implementReducerthroughbody, and the macro generates the requiredreduce(into:action:)entry point from that composition.- Composition happens through
Reduce,CombineReducers,Scope,IfLet,IfCaseLet, andForEachReducer. PhaseTransitionGraphis an opt-in validation layer, not a generic automata runtime.- Binding remains explicit opt-in through
@BindableField, and SwiftUI bindings use projected key paths such as\.$step. - The
TestStore.exhaustivitycontract defaults to.on, requiring complete state-transition and effect-action assertions; uncancelled runtime effect errors always fail independently of that policy. Storeserializes effect cancellation and run-failure arbitration on the MainActor. Once cancellation wins, a late error from uncooperative work is not reclassified asdidFailRun.- InnoFlow owns business/domain transitions only.
Cross-framework ownership stays explicit:
- App-layer navigation state or another navigation library owns concrete route stacks.
- Transport and session lifecycle stay outside InnoFlow.
- Construction-time dependency graphs stay outside InnoFlow and enter reducers as explicit bundles.
Boundary references:
docs/ADVANCED_AUTHORING.mdbridges dependencies, instrumentation, and cross-framework boundaries for non-trivial featuresdocs/CROSS_FRAMEWORK.mdfor navigation / transport / DI ownershipdocs/DEPENDENCY_PATTERNS.mdfor reducer-facing dependency construction patternsMIGRATION.mdfor 5.1.0 changes and prior release migrationsdocs/INSTRUMENTATION_COOKBOOK.mdfor.sink,.osLog,.signpost, and.combinedexamplesdocs/PERFORMANCE_BASELINES.mdfor maintainer baseline policydocs/FRAMEWORK_COMPARISON.mdfor TCA, ReactorKit, ReSwift, and SwiftRex positioning- InnoFlow API reference for the facade, core runtime, composition, and phase symbols
- InnoFlowTesting API reference for the public test harness, clock, and instrumentation symbols
For stable framework guarantees that should not drift with scorecards or line counts, see
ARCHITECTURE_CONTRACT.md.
Project stewardship is documented in GOVERNANCE.md.
Before opening an issue or pull request, see SUPPORT.md,
CONTRIBUTING.md, the
CODE_OF_CONDUCT.md, and SECURITY.md.
TCA remains the stronger default when a team wants a broad application
architecture with integrated dependency management, navigation patterns,
testing conventions, and a large ecosystem. Choose InnoFlow when the framework
boundary should stay smaller: reducers own business transitions, dependencies
are constructor-injected bundles, navigation and transport stay at the app
boundary, and SwiftUI-specific conveniences live in the optional
InnoFlowSwiftUI product.
See docs/FRAMEWORK_COMPARISON.md for the
longer comparison against TCA, ReactorKit, ReSwift, and SwiftRex.
InnoFlow 5.1.0 requires a Swift 6.3 or newer toolchain and compiles all package targets in Swift 6 language mode. The canonical sample and DocC workflow use the same toolchain contract.
dependencies: [
.package(url: "https://github.com/InnoSquadCorp/InnoFlow.git", from: "5.1.0")
].target(
name: "YourDomain",
dependencies: ["InnoFlowCore"]
)
.target(
name: "YourSwiftUIApp",
dependencies: ["InnoFlow", "InnoFlowSwiftUI"]
)
.testTarget(
name: "YourAppTests",
dependencies: ["InnoFlowCore", "InnoFlowTesting"]
)Runtime-only feature and domain targets can depend on InnoFlowCore alone.
Use InnoFlow when a target needs the @InnoFlow macro; it reexports
InnoFlowCore and owns the macro declarations. SwiftUI app targets should also
depend on InnoFlowSwiftUI, which provides
Store.binding, ScopedStore.binding, Store.preview, and
EffectTask.animation(Animation?) without making the core runtime import
SwiftUI. InnoFlowSwiftUI and InnoFlowTesting reexport InnoFlowCore, but
macro users must still import InnoFlow directly.
For compiler-plugin trust, SwiftSyntax prebuilt fallback, sandbox policy, CI
flags, cache keys, and the intentional InnoFlowCore recovery path, see
docs/MACRO_OPERATIONS.md.
Store.deinit and TestStore.deinit are annotated with @_optimize(none) to
sidestep a Swift 6.3 release-mode SIL crash in EarlyPerfInliner
(swiftlang/swift#88173)
that triggers when the compiler tries to inline the generic R.Action-typed
deinit. The deinit path is not a hot loop — the lost optimization is
negligible — and @MainActor isolated deinit semantics are unchanged. If your
own consumer crashes during release builds with a similar SIL trace, see
docs/SWIFT_TOOLCHAIN_TRACKING.md for the
retest procedure and removal trigger. The principle gates also emit a
non-blocking warning on Swift 6.4+ so the workaround does not silently outlive
the upstream fix.
import InnoFlow
@InnoFlow
struct CounterFeature {
struct State: Equatable, Sendable, DefaultInitializable {
var count = 0
@BindableField var step = 1
}
enum Action: Equatable, Sendable {
case increment
case decrement
case setStep(Int)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .increment:
state.count += state.step
return .none
case .decrement:
state.count -= state.step
return .none
case .setStep(let step):
state.step = max(1, step)
return .none
}
}
}
}import InnoFlow
import InnoFlowSwiftUI
import SwiftUI
struct CounterView: View {
@State private var store: Store<CounterFeature>
init(store: Store<CounterFeature> = Store(reducer: CounterFeature())) {
_store = State(initialValue: store)
}
var body: some View {
VStack(spacing: 20) {
Text("Count: \(store.count)")
.font(.largeTitle)
HStack(spacing: 24) {
Button("−") { store.send(.decrement) }
Button("+") { store.send(.increment) }
}
Stepper(
"Step: \(store.step)",
value: store.binding(\.$step, to: CounterFeature.Action.setStep)
)
}
}
}binding(_:to:) is the canonical spelling. binding(_:send:) and existing trailing-closure calls such as store.binding(\.$step) { .setStep($0) } are semantically identical compatibility spellings that stay supported without deprecation; prefer to: in new code.
The InnoFlow 5.0 development line uses a small composition surface instead of multiple authoring styles.
Reduce is the closure-backed primitive reducer.
Reduce<State, Action> { state, action in
// mutate state
// return EffectTask<Action>
}CombineReducers runs reducers in declaration order and merges child effects.
var body: some Reducer<State, Action> {
CombineReducers {
Reduce { state, action in
// parent logic
.none
}
AnalyticsReducer()
}
}Scope lifts child state, child action, and child effects into a parent reducer space.
Scoped child state must conform to Equatable. The reducer primitive and runtime projection API
are separate: Store.scope(state:action:) returns a ScopedStore that caches the latest child
snapshot, refreshes during the parent store's action drain, and only invalidates observers when the
child snapshot actually changes.
Repeated Store.scope(state:action:) calls from the same source location reuse the same live
projection when their state key path, child types, and CasePath identity match. The cache is weak,
so discarding every external reference still releases the ScopedStore; constructing a new
CasePath explicitly creates a separate projection instead of reusing an outdated action transform.
Macro-generated paths keep a stable identity even when generic or extension contexts require a
computed static accessor.
Collection scoping retains one active ID-keyed row family per collection key path. It reuses that
family across source locations when the child types and CollectionActionPath identity match;
changing that signature replaces the whole family. Previously returned rows keep their original
action transform, while newly scoped rows route through the new path. Repeated macro-generated path
access preserves row identity; an explicitly reconstructed path is an intentional safe replacement.
Public scoping APIs use CasePath and CollectionActionPath exclusively. Closure-based action lifting is kept internal to the framework implementation.
var body: some Reducer<State, Action> {
CombineReducers {
Reduce { state, action in
switch action {
case .load:
state.isLoading = true
return .send(.child(.start))
case .child(.finished):
state.isLoading = false
return .none
}
}
Scope(
state: \.child,
action: .childCasePath,
reducer: ChildFeature()
)
}
}When @InnoFlow is attached, the matching Action case path is synthesized automatically:
@InnoFlow
struct ParentFeature {
enum Action: Equatable, Sendable {
case child(ChildFeature.Action)
}
}That declaration gives you ParentFeature.Action.childCasePath automatically. Likewise,
case todo(id: ID, action: ChildAction) synthesizes todoActionPath, and a single unlabeled
payload case such as case _loaded(Output) synthesizes loadedCasePath.
IfLet runs a child reducer only while optional child state is present. Child actions still use the
same lifted parent Action case path as Scope.
IfLet(
state: \.child,
action: .childCasePath,
reducer: ChildFeature()
)When the optional state is nil, child actions are ignored in release builds and asserted in debug builds.
IfCaseLet runs a child reducer only while enum parent state matches a specific case. State matching is
expressed with a CasePath and action lifting still uses the synthesized child action path.
static let detailState = CasePath<State, DetailFeature.State>(
embed: State.detail,
extract: { state in
guard case .detail(let childState) = state else { return nil }
return childState
}
)
IfCaseLet(
state: Self.detailState,
action: .childCasePath,
reducer: DetailFeature()
)ForEachReducer is the declarative collection companion to Scope. It routes row actions with a
CollectionActionPath while preserving the same row identity, stale-row contract, and revision
cache that power runtime collection scoping.
ForEachReducer(
state: \.todos,
action: .todoActionPath,
reducer: TodoRowFeature()
)SelectedStore is a read-only derived projection for expensive Equatable read models. Use it when
you want a view to refresh only when the selected value actually changes.
Dynamic-member reads are SwiftUI view-body conveniences: they diagnose stale
ownership in debug and use the last cached snapshot in optimized builds. Use
requireAlive() when liveness is a precondition, or optionalValue when a
released projection should be handled as absence.
let summary = store.select { state in
DashboardSummary(
title: state.profile.name,
isReady: state.permissions.isReady && state.profile.isReady
)
}
Text(summary.title)When the derived value depends on a single explicit slice of state, use the dependency-annotated overload so InnoFlow can keep the selection on a selective-refresh bucket:
let summary = store.select(dependingOn: \.profile) { profile in
ProfileSummary(name: profile.name, canEdit: profile.isAdmin)
}let badge = store.select(dependingOnAll: \.profile, \.permissions) { profile, permissions in
DashboardBadge(
title: profile.name,
isReady: profile.isReady && permissions.isReady
)
}For projections that depend on two or more slices, use
select(dependingOnAll:) with a comma-separated list of key paths. The
parameter-pack overload preserves explicit dependency tracking without falling
back to always-refresh selection regardless of arity:
let summary = store.select(
dependingOnAll: \.a, \.b, \.c, \.d, \.e, \.f, \.g
) { a, b, c, d, e, f, g in
Summary(a, b, c, d, e, f, g)
}select { state in ... } remains available as an always-refresh fallback because InnoFlow cannot
infer which fields a general closure reads. Use it when the dependency cannot be expressed as
typed key paths or when always-refresh behavior is intentional.
Use SelectedStore for read-only projections. Keep mutable child flows on ScopedStore.
Keep dependency ownership outside InnoFlow and pass constructor-time bundles into reducers.
See docs/DEPENDENCY_PATTERNS.md for the canonical single-service / composite-bundle / framework-provided-clock patterns, the three test-substitution scenarios, and the anti-patterns InnoFlow explicitly rejects.
import InnoFlow
@InnoFlow
struct ProfileFeature {
struct Dependencies: Sendable {
let apiClient: any APIClientProtocol
let logger: any LoggerProtocol
}
struct State: Equatable, Sendable, DefaultInitializable {
var name = ""
}
enum Action: Equatable, Sendable {
case load
case _loaded(String)
}
let dependencies: Dependencies
init(dependencies: Dependencies) {
self.dependencies = dependencies
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .load:
let apiClient = dependencies.apiClient
let logger = dependencies.logger
return .run { send, context in
do {
try await context.checkCancellation()
logger.log("Loading profile")
let name = try await apiClient.fetchName()
try await context.checkCancellation()
await send(._loaded(name))
} catch is CancellationError {
return
}
}
case ._loaded(let name):
state.name = name
return .none
}
}
}
}
let feature = ProfileFeature(
dependencies: .init(
apiClient: APIClient.live,
logger: Logger.live
)
)
let store = Store(reducer: feature)If a SwiftUI view owns environment-specific values, resolve them in the view layer and forward the derived bundle or action into the reducer.
This keeps reducer dependencies explicit:
- the app or coordinator owns the
AppContainer Dependencies(container:)snapshots the bundle at construction time- the reducer stores
let dependencies: Dependencies .runcaptures the dependencies it needs explicitly
Scope relies on EffectTask.map to lift child effects while preserving cancellation, debounce, throttle, and animation semantics.
EffectTask<Action> remains the only effect DSL:
.none.send(action).run { send, context in ... }.run { context in asyncSequence }.run(sequence:transform:).merge(...).concatenate(...).cancel(id).cancellable(id:cancelInFlight:).debounce(id:for:).throttle(id:for:leading:trailing:).animation(_:)
EffectID<RawValue> accepts any Hashable & Sendable raw value, so cancellation IDs can be
string literals, dynamic strings, UUIDs, or domain-specific key types. Use StaticEffectID for the
common string-literal case:
let refreshID: StaticEffectID = "refresh"
let sessionID = EffectID(session.uuid)EffectContext exposes the store clock inside .run, so time-sensitive effects can stay deterministic
in both runtime code and tests:
return .run { send, context in
do {
try await context.sleep(for: .milliseconds(300))
try await context.checkCancellation()
await send(.finished)
} catch is CancellationError {
return
}
}The older .run { send in ... } overload still works, but new code should prefer
context.sleep(for:) and context.checkCancellation() over Task.sleep(...) plus ad-hoc
cancellation checks. If an effect needs a non-throwing probe, call
await context.isCancellationRequested(); it uses the same store/runtime boundary as
checkCancellation().
Store deinit and explicit effect cancellation are still cooperative. InnoFlow guarantees
that late emissions are dropped immediately, but runtime teardown continues as best-effort
async cleanup. Long-running work should call checkCancellation() around suspension points
if it needs prompt shutdown.
Operational logging stays lightweight on purpose. Use StoreInstrumentation.osLog(...) for a
default log sink, StoreInstrumentation.signpost(...) for Instruments timelines, or fan out
vendor-specific counters and traces through StoreInstrumentation.sink(...) and combined(...).
That is the intended extension point for Datadog, Prometheus bridges, or swift-metrics wrappers:
let instrumentation: StoreInstrumentation<Feature.Action> = .combined(
.osLog(logger: logger),
.sink { event in
switch event {
case .runStarted:
metrics.increment("feature.effect.run_started")
case .runFinished:
metrics.increment("feature.effect.run_finished")
case .actionEmitted(let actionEvent):
metrics.increment("feature.effect.emitted", tags: ["action": "\(actionEvent.action)"])
case .actionDropped(let actionEvent):
metrics.increment("feature.effect.dropped", tags: ["reason": "\(actionEvent.reason)"])
case .actionQueueDrained(let queueEvent):
metrics.gauge("feature.action_queue.high_water", value: queueEvent.pendingActionHighWaterMark)
case .effectsCancelled:
metrics.increment("feature.effect.cancelled")
case .runFailed:
metrics.increment("feature.effect.failed")
}
}
)If a team standardizes on one backend later, prefer an optional ecosystem package such as
InnoFlowMetrics over adding vendor dependencies to the core package graph.
For concrete adapter examples, see docs/INSTRUMENTATION_COOKBOOK.md.
Store dispatch is queue-based.
.sendemits an immediate follow-up action, but it is queued rather than reducer-reentrant..runemits actions after its async boundary, and those actions re-enter the same queue..concatenatepreserves declared effect order..mergeobserves child completion order, not declaration order.- The queue never drops or collapses actions. It compacts consumed prefixes during long drains and retains at most 64 KiB of reusable queue storage after a drain; larger allocations are released.
StoreInstrumentation.ActionQueueEventreports per-drain pending/storage high-water marks and whether excess retained capacity was released. Use domain-level throttle, debounce, or batching when the pending high-water indicates producer back-pressure.
PhaseMap is the canonical way to declare domain phase transitions. It wraps a base reducer as a
post-reduce decorator, computes the next phase from the final reducer state plus the current action,
and exposes derivedGraph so the same contract remains available as a PhaseTransitionGraph.
For a full sample-backed walkthrough, see PhaseDrivenWalkthrough.md.
@InnoFlow(phaseManaged: true)
struct ProfileFeature {
struct State: Equatable, Sendable, DefaultInitializable {
enum Phase: Hashable, Sendable {
case idle
case loading
case loaded
case failed
}
var phase: Phase = .idle
var profile: UserProfile?
var errorMessage: String?
}
enum Action: Equatable, Sendable {
case load
case _loaded(UserProfile)
case _failed(String)
}
static var phaseMap: PhaseMap<State, Action, State.Phase> {
PhaseMap(\.phase) {
From(.idle) {
On(.load, to: .loading)
}
From(.loading) {
On(Action.loadedCasePath, to: .loaded)
On(Action.failedCasePath, to: .failed)
}
From(.failed) {
On(.load, to: .loading)
}
}
}
static var phaseGraph: PhaseTransitionGraph<State.Phase> {
phaseMap.derivedGraph
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .load:
state.errorMessage = nil
return .none
case ._loaded(let profile):
state.profile = profile
return .none
case ._failed(let message):
state.errorMessage = message
return .none
}
}
}
}Use these rules:
PhaseMapowns the declared phase key path; base reducers should not mutate it directly.- Matching transitions are evaluated against the previous phase after the base reducer finishes.
- The first matching
Onrule wins. Returningnilfrom a guard means “consume the action, keep the current phase”. PhaseMapremains partial by default. Unmatched phase/action pairs are legal no-ops unless a team opts into stricter validation in tests.PhaseTransitionGraphremains a topology validation tool, not a general state-machine runtime.validatePhaseTransitions(...)still exists for backward compatibility, but new examples should preferPhaseMap.- Generated action path members strip one leading underscore, so
_loadedCasePathbecomesloadedCasePath. - Prefer
On(CasePath, ...)when the action carries meaningful payload,On(.equatableAction, ...)for simple events, and keepOn(where:)as an escape hatch when the trigger cannot be expressed cleanly otherwise. - Adopt
PhaseMapwhen a feature already has a phase enum, legal transitions are part of the business contract, and imperativestate.phase = ...updates are spreading across multiple reducer branches.
Design rationale:
PhaseTransitionGraphstays focused on static topology checks such as reachability, unknown successors, and terminal validation.PhaseMapowns runtime phase movement and conditional resolution after the reducer has finished mutating non-phase state.- Guard-bearing graph metadata remains intentionally out of scope.
@InnoFlow(phaseManaged: true)is a compile-time authoring convenience. Its unreferenced-case warning is name-based and does not prove reachability or predicate exhaustiveness.
If you need the reasoning behind those boundaries, see ADR-phase-transition-guards, ADR-declarative-phase-map, and ADR-phase-map-totality-validation.
For static graph checks, build a report from phaseMap.derivedGraph:
let report = ProfileFeature.phaseGraph.validationReport(
allPhases: [.idle, .loading, .loaded, .failed],
root: .idle,
terminalPhases: [.loaded]
)
precondition(report.issues.isEmpty)If a team wants stricter trigger coverage without changing runtime behavior, add an opt-in validation pass in tests:
let totalityReport = assertPhaseMapCovers(
ProfileFeature.phaseMap,
expectedTriggersByPhase: [
.idle: [.action(.load)],
.loading: [
.casePath(ProfileFeature.Action.loadedCasePath, label: "loaded", sample: .fixture),
.casePath(ProfileFeature.Action.failedCasePath, label: "failed", sample: "boom")
]
]
)
#expect(totalityReport.isEmpty)This helper validates only the triggers you explicitly declare. It does not change PhaseMap
runtime semantics, and unmatched actions remain legal no-ops by default.
InnoFlowTesting provides TestStore for deterministic reducer tests.
TestStore.exhaustivity defaults to .on: every reducer state mutation must
appear in the matching send or receive assertion, and every effect-emitted
action must be consumed with receive. Omitting an assertion closure means
"the state does not change," not "skip state checking."
import InnoFlowTesting
@Test
@MainActor
func loadFlow() async {
let store = TestStore(reducer: ProfileFeature())
let phaseMap: PhaseMap<ProfileFeature.State, ProfileFeature.Action, ProfileFeature.State.Phase> =
ProfileFeature.phaseMap
await store.send(.load, through: phaseMap) {
$0.phase = .loading
}
await store.receive(._loaded(.fixture), through: phaseMap) {
$0.phase = .loaded
$0.profile = .fixture
}
await store.finish()
}finish() is the terminal test assertion. With exhaustive mode .on, it waits
for all framework-owned effects and fails when any emitted action was not
received. With .off, it reduces buffered, late, and follow-up actions until
the harness is idle. Its timeout uses wall time and never advances a
ManualTestClock, so advance manual time before finishing when a debounce or
active throttle window (including leading-only) remains, or cancel that work
before finishing. A scoped test store delegates finish() to the same parent
queue and effect lifecycle. Use
assertNoBufferedActions() only as an intermediate, immediate queue
checkpoint; assertNoMoreActions() is deprecated because it is neither a
complete terminal assertion nor an immediate checkpoint.
An AsyncSequence consumed by EffectTask.run may terminate with
CancellationError normally. Any other error from an active run is a hard
TestStore failure, reported once at the send or receive assertion that
created the effect even when debounce, throttle, animation, or composition
delays the run. This runtime-error contract also applies when exhaustivity is
.off. If the host accepts cancellation first, a late domain error from
uncooperative work is discarded instead of being reclassified as a run
failure.
If a TestStore leaves scope with valid buffered actions or active
framework-owned effects, deinitialization provides a synchronous safety net.
It records one failure in .on, one non-failing warning in
.off(showSkippedAssertions: true), and remains silent in .off. The safety
net cancels remaining work but neither waits for effects nor reduces buffered
actions, and it does not report an idle store merely because finish() was
omitted. A completed or failed finish() is not reported again during
deinitialization unless new work begins or arrives afterward.
For migration or intentionally partial tests, opt out explicitly:
store.exhaustivity = .off(showSkippedAssertions: true)In .off mode, assertion closures start from the actual post-reducer state and
therefore describe only the fields being checked. Unexpected effect actions
are reduced by send, receive, and finish, and
showSkippedAssertions: true reports non-failing warnings for skipped state,
action, or terminal assertions. Deinitialization itself never reduces actions.
Each receive assertion can override the harness timeout. Case-path and
predicate overloads also support actions that are not Equatable. In .on
mode, a valid mismatch is reduced once and then reported immediately. In
.off mode, mismatches are reduced while the receive continues searching for
the requested action under one total wall-clock deadline.
await store.receive(.finished, timeout: .milliseconds(250)) {
$0.phase = .finished
}
let payload = await store.receive(
Feature.Action.loadedCasePath,
caseName: "loaded",
timeout: .seconds(1)
) { state, payload in
state.value = payload
}
let action = await store.receive(
where: { $0.isSuccessfulResponse },
description: "successful response"
) { state, _ in
state.phase = .finished
}The timeout is one wall-clock budget even when cancelled effect actions are
discarded. Cancelling the waiting task removes its queue waiter without
consuming actions delivered afterward. For an optional case-path payload, the
return value is intentionally nested (Payload??) so a matched nil remains
distinct from mismatch, timeout, or cancellation.
State mismatch diagnostics now include a Diff: section before full expected/actual dumps. The renderer shows 12 lines by default, can be overridden per harness with TestStore(..., diffLineLimit: 24), and also respects INNOFLOW_TESTSTORE_DIFF_LINE_LIMIT.
For deeply composed reducers, project the parent TestStore instead of creating an independent child harness:
let store = TestStore(reducer: ParentFeature())
let child = store.scope(state: \.child, action: .childCasePath)
await child.send(.start) {
$0.phase = .loading
}
await child.receive(.finished) {
$0.phase = .loaded
}
await child.finish()That projection assumes ParentFeature.Action.childCasePath, which @InnoFlow now synthesizes for matching single-payload child action cases.
Exhaustive scoped assertions still compare the complete root state. When a
child action intentionally changes parent or sibling state, assert that action
through the parent TestStore.
Collection-scoped projections keep per-element ScopedStore identity stable by id, and row observers only invalidate when their own element snapshot changes.
If an element is removed, discard any old row-scoped handle and recreate projections from the parent store. ScopedStore.state and projection dynamic-member reads keep a cached snapshot fallback for SwiftUI observer races, stale scoped sends become no-ops when the projection is dead, and non-UI callers should use optionalState / optionalValue for graceful absence or requireAlive() when a dead projection is a programmer error. ScopedTestStore keeps the testing contract louder and traps stale direct access via preconditionFailure.
For store-level debounce and throttle tests, inject a StoreClock:
let clock = ManualTestClock()
let store = Store(
reducer: ProfileFeature(),
initialState: .init(),
clock: .manual(clock)
)Collection-scoped testing uses an element id and shares the same parent queue. Element removal is still asserted at the parent TestStore level.
let todo = store.scope(
collection: \.todos,
id: targetID,
action: ParentFeature.Action.todoActionPath
)
await todo.send(.setIsDone(true)) {
$0.isDone = true
}For expensive derived read-models, prefer SelectedStore over ad-hoc recomputation:
let status = store.select(\.phase)
#expect(status.requireAlive() == .idle)If the read model is derived from a single explicit Equatable slice, use the
dependency-annotated form; for two or more slices use the variadic
select(dependingOnAll:):
let title = store.select(dependingOn: \.child.title) { $0.uppercased() }
#expect(title.requireAlive() == "CHILD")The single reference app is Examples/InnoFlowSampleApp.
It contains ten demos:
BasicsOrchestrationPhase-Driven FSM(@InnoFlow(phaseManaged: true)+PhaseMap)App-Boundary NavigationAuthentication FlowList-Detail PaginationOffline-FirstRealtime StreamForm ValidationBidirectional WebSocket
This sample is the official integration reference for InnoFlow-only flows, explicit dependency
bundles, app-owned SwiftUI navigation state, and explicitly labeled cross-framework transport
integration.
The sample hub also defines the stable accessibility identifiers used by UI smoke tests:
sample.basicssample.orchestrationsample.phase-driven-fsmsample.router-compositionsample.authentication-flowsample.list-detail-paginationsample.offline-firstsample.realtime-streamsample.form-validationsample.bidirectional-websocket
Launch-environment direct demo mode (INNOFLOW_SAMPLE_DEMO) remains available for feature-focused UI regression tests.
- Resolve
@Environmentvalues in the view layer, then forward the derived action or dependency value into the reducer. - Prefer explicit nested
Dependenciesbundles over repeated ad-hoc captures in view code. - Prefer stable
accessibilityIdentifier(...)values on sample and test-targeted controls. - Add explicit VoiceOver labels and hints when button text or dense layouts do not fully describe the action.
- Prioritize explicit accessibility metadata on demo hub rows, modal dismiss actions, and long-running or destructive controls where context can be ambiguous in VoiceOver.
- Prefer system controls and Dynamic Type-friendly layouts over custom fixed-size controls.
- Use
SelectedStoreonly for expensive read-only derived values. Useselect(dependingOn:)for a single explicit state slice and the variadicselect(dependingOnAll:)for two or more slices; reserve plainselect { ... }for the always-refresh fallback. Keep mutable child flows onScopedStore. - Use
Store.preview(...)as the default path for preview and accessibility review passes so preview-only setup never changes production store wiring.
- Keep direct composition at the app/coordinator boundary.
- Do not duplicate concrete route stacks inside feature state.
- Reducers emit business intent; the app layer owns navigation state and dependency construction.
- Retry, reconnect, websocket, and transport/session lifecycle stay outside
PhaseTransitionGraph. - Use
docs/CROSS_FRAMEWORK.mdas the single ownership guide for navigation, transport, and DI boundaries. - Use
docs/DEPENDENCY_PATTERNS.mdwhen the question is specifically about reducer-facingDependenciesbundles.
The core architecture is stable. Remaining work is conditional roadmap material, not required redesign.
- PhaseMap strict totality enforcement —
PhaseMapis intentionally partial by default and unmatched actions remain legal no-ops. Stronger enforcement remains a future design decision, not a current runtime contract. - Opaque selector memoization — explicit key-path dependencies are covered by
select(dependingOn:)for a single slice and the variadicselect(dependingOnAll:)for two or more slices. Memoizing arbitrary closure selectors remains future work because general closures do not expose their read set. - Optional metrics ecosystem package —
StoreInstrumentation.sink(...),.osLog(...),.signpost(...), and.combined(...)are the supported extension points today. If a standard backend emerges, an optionalInnoFlowMetrics-style package can sit on top without changing the core graph. - PhaseMap adoption expansion —
PhaseMapis the canonical path for phase-heavy features, but further migration should follow real feature demand rather than sample-driven expansion. - Accessibility and documentation polish — sample/docs coverage can continue to improve, but this is product polish rather than a missing core capability.
- Scoped phase examples — the canonical sample now covers phase-managed authoring; additional payment, permission, or onboarding examples should be added when real product flows need them.
Use these commands locally:
swift test --package-path .
swift test --package-path Examples/InnoFlowSampleApp/InnoFlowSampleAppPackage --jobs 1
xcodebuild -jobs 1 -project Examples/InnoFlowSampleApp/InnoFlowSampleApp.xcodeproj -scheme InnoFlowSampleApp -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO buildPrinciples are enforced through macro diagnostics, architecture tests, and scripts/principle-gates.sh.
Use Store.preview(...) in #Preview blocks so preview setup stays explicit without changing
production store semantics.
#Preview("Counter") {
CounterView(
store: .preview(
reducer: CounterFeature(),
initialState: .init(count: 3, step: 2)
)
)
}Package support is declared for iOS 18, macOS 15, tvOS 18, watchOS 11, and visionOS 2 or newer. The 4.0.0 release raised the floor from iOS 17 / macOS 14 to take advantage of Swift 6.0 standard-library primitives (typed throws, sending parameters, ~Copyable generics) without availability branches.
The canonical sample still treats iOS as the primary interactive shell, while CI package builds
cover the package-level visionOS contract.
For visionOS-specific guidance, keep the same ownership split:
- reducers still own business and domain transitions
- the app layer owns window, volume, and immersive-space orchestration
PhaseTransitionGraphstays a topology validator rather than a spatial runtime
See VisionOSIntegration for the docs-only visionOS integration contract. Dedicated immersive samples are intentionally outside the current canonical sample.