Skip to content
Open
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
12 changes: 12 additions & 0 deletions Shared/LifecycleKit/Sources/LifecycleRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ public final class LifecycleRunner<Launch: Sendable> {
state.reason
}

/// Changes only the observed launch reason, modeling a promotion whose
/// intermediate drive phases coalesced between SwiftUI renders.
@_spi(Testing)
public func promoteReasonToForegroundForTesting() {
switch state {
case .notStarted:
state = .notStarted(.userForeground)
case let .running(_, task):
state = .running(reason: .userForeground, task: task)
}
}

@ObservationIgnored private let launchNodes: [LaunchPlanNode]

/// The output of every node that ran to completion during the current
Expand Down
21 changes: 13 additions & 8 deletions Shared/LifecycleKitUI/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,19 @@ build system, formatting, and global conventions. Read that first.
- **Every splash-showing state resolves to one `LaunchOverlay.splash` case** —
never per-phase `switch` arms, which remount the splash at each boundary
and reset its animations and caption timers.
- **`minimumSplashDuration` only holds a splash that was actually shown** —
armed when the splash *appears*, so an already-`.ready` mount reveals
immediately. Guard: `minimumSplashDurationDoesNotHoldWhenNoSplashWasShown`
(the timing half is device-verified, not host-testable). Assert "revealed"
via the *absent splash*, not via `content` (content is built during a hold
too); `isShowingSplash` must read the runner's own surface, never
`displayedSurfaceIdentity`, which reports `.splash` for a held `.ready` and
would re-arm the hold from its own release.
- **`minimumSplashDuration` holds only an observed splash by default** —
`.phaseDriven` keeps an already-`.ready` mount immediate, while
`.splashBeforeFirstReveal` may establish the first hold from a visible
`.ready` when foreground promotion coalesced past the splash. Keep that hold
keyed on readiness *and* visibility, retain an observed splash's original
deadline, and never arm it headlessly. Guards:
`minimumSplashDurationDoesNotHoldWhenNoSplashWasShown`,
`splashBeforeFirstRevealKeepsBackgroundReadyHeadless`,
`promotedBackgroundReadyForcesTheFirstRevealSplash`. Assert
"revealed" via the *absent splash*, not via `content` (content is built
during a hold too); `isShowingSplash` must read the runner's own surface,
never `displayedSurfaceIdentity`, which reports `.splash` for a held
`.ready` and would re-arm the hold from its own release.
- **Gate views resolve only their own handle** — a superseded drive's handle
no-ops; don't route gate resolution through anything else.
- **One registration per gate type** (construction `precondition`); a parked
Expand Down
22 changes: 22 additions & 0 deletions Shared/LifecycleKitUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,28 @@ reveal animation starts. It stays gated on the launch's output either way
(that value is only readable from `.ready`), so nothing is built speculatively:
the hold just stops being a stall and starts being a warm-up.

By default, an already-`.ready` container reveals immediately because SwiftUI
never displayed a splash for the minimum to hold. An app that must always show
the splash before its first visible main-UI reveal can opt in:

```swift
LifecycleContainer(
runner,
minimumSplashDuration: .milliseconds(800),
readyRevealPolicy: .splashBeforeFirstReveal,
) { session in
MainTabs(session: session)
}
```

This covers the background-launch edge case where the runner is already ready,
foreground promotion starts and finishes, and SwiftUI observes only the final
`.ready` state. The first visible ready presentation establishes the hold in
that case. A rendered splash keeps its original deadline, so reaching `.ready`
does not start a second hold. Headless phases remain viewless, gate and failure
surfaces are unaffected, and once content is revealed an ordinary foreground
resume does not replay the forced splash.

## Reaching the runner from nested views

`LifecycleContainer` publishes a `LifecycleProxy` under
Expand Down
62 changes: 48 additions & 14 deletions Shared/LifecycleKitUI/Sources/LifecycleContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ public struct LifecycleContainer<
private let gates: [GateRegistration]
private let content: (Launch) -> Content

/// When the splash may be dismissed: the deadline the current appearance's
/// `minimumSplashDuration` set. `nil` means nothing is holding the reveal —
/// no minimum was requested, no splash was shown, or the hold has elapsed.
@State private var splashHoldUntil: ContinuousClock.Instant?
/// One value tracks whether the first visible ready presentation still
/// owes the user a splash, a rendered splash is being held, or content has
/// already been revealed. Kept scene-local by SwiftUI's state lifetime.
@State private var readyRevealState: LifecycleReadyRevealState

/// - Parameters:
/// - transition: how each surface enters/leaves. Defaults to a crossfade.
Expand All @@ -65,6 +65,9 @@ public struct LifecycleContainer<
/// as soon as the runner is ready. The hold isn't dead time: `content`
/// is already built beneath the splash, so the destination warms up
/// during it rather than in the frame the reveal starts.
/// - readyRevealPolicy: whether an already-ready first visible presentation
/// reveals immediately or synthesizes the splash hold when SwiftUI never
/// observed a preceding splash phase.
/// - splash: the waiting surface; receives the running step's context
/// (nil between steps) so it can show a caption/progress.
/// - failure: the (terminal) error surface, given the failure. There is
Expand All @@ -76,6 +79,7 @@ public struct LifecycleContainer<
transition: AnyTransition = .opacity,
animation: Animation? = .default,
minimumSplashDuration: Duration = .zero,
readyRevealPolicy: LifecycleReadyRevealPolicy = .phaseDriven,
@ViewBuilder splash: @escaping (LifecycleStepContext?) -> Splash,
@ViewBuilder failure: @escaping (LifecycleFailure) -> Failure,
@GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] },
Expand All @@ -89,6 +93,12 @@ public struct LifecycleContainer<
failureView = failure
self.gates = gates()
self.content = content
_readyRevealState = State(
initialValue: LifecycleReadyRevealState(
policy: readyRevealPolicy,
minimumSplashDuration: minimumSplashDuration,
),
)
Self.assertUniqueGateTypes(self.gates)
}

Expand Down Expand Up @@ -117,24 +127,37 @@ public struct LifecycleContainer<
// Arm the hold each time the splash appears, so every episode (a reset
// relaunch, the return from a gate) gets its own minimum.
.onChange(of: isShowingSplash, initial: true) { _, showing in
guard showing, minimumSplashDuration > .zero else { return }
splashHoldUntil = ContinuousClock.now.advanced(by: minimumSplashDuration)
guard showing else { return }
readyRevealState.splashAppeared(
at: .now,
minimumSplashDuration: minimumSplashDuration,
)
}
// Once the runner is ready, wait out whatever is left of the hold, then
// release the reveal.
.task(id: runner.phase.isReady) {
guard runner.phase.isReady, let deadline = splashHoldUntil else { return }
// Include visibility in the identity: a background runner can remain
// `.ready` across foreground promotion, and that false → true transition
// is what must start an opt-in first-reveal hold.
.task(id: isReadyAndVisible) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Posted by an AI agent on kve's behalf.

Review focus: visibility is deliberately part of this task identity. A background runner can remain .ready before and after foreground promotion, so readiness alone would not restart the task that establishes the first visible splash hold.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Posted by an AI agent on kve's behalf.

Addressed in 9587d79. The hosted regression test now mounts a ready runner headlessly, applies a coalesced foreground-only reason change while the phase stays ready, proves the splash appears and releases, and was mutation-checked to fail if the task identity is reduced to readiness alone.

guard isReadyAndVisible else { return }
readyRevealState.readyBecameVisible(
at: .now,
minimumSplashDuration: minimumSplashDuration,
)
guard let deadline = readyRevealState.splashHoldDeadline else { return }
do {
// Returns immediately once the deadline has passed, so a launch
// slower than the minimum reveals without waiting.
try await Task.sleep(until: deadline, clock: .continuous)
} catch {
return // Superseded — a new appearance re-armed the hold.
}
guard
Task.isCancelled == false,
readyRevealState.splashHoldDeadline == deadline
else { return }
// Drive the reveal in an explicit transaction: `.animation(_:value:)`
// doesn't reliably animate this async, `.task`-driven flip, so the
// splash would be removed without its reveal transition.
withAnimation(animation) { splashHoldUntil = nil }
withAnimation(animation) { readyRevealState = .revealed }
}
}

Expand All @@ -149,10 +172,17 @@ public struct LifecycleContainer<
!runner.reason.buildsNoViewTree && runner.phase.surfaceIdentity == .splash
}

/// Whether the app content may be revealed: nothing is holding it — no
/// minimum was requested, no splash was shown, or the hold has elapsed.
/// Ready and eligible to render a tree. Unlike readiness alone, this flips
/// when a headless-ready runner is promoted without publishing a different
/// terminal phase.
private var isReadyAndVisible: Bool {
runner.phase.isReady && !runner.reason.buildsNoViewTree
}

/// Whether presentation history permits the ready content to show: the
/// policy owes no first splash and no rendered-splash hold remains.
private var canRevealReady: Bool {
splashHoldUntil == nil
readyRevealState.canRevealReady
}

/// The surface actually on screen, for `.animation(_:value:)`. While the
Expand Down Expand Up @@ -316,12 +346,14 @@ extension LifecycleContainer where Splash == LifecycleSplash, Failure == Lifecyc
public init(
_ runner: LifecycleRunner<Launch>,
minimumSplashDuration: Duration = .zero,
readyRevealPolicy: LifecycleReadyRevealPolicy = .phaseDriven,
@GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] },
@ViewBuilder content: @escaping (Launch) -> Content,
) {
self.init(
runner,
minimumSplashDuration: minimumSplashDuration,
readyRevealPolicy: readyRevealPolicy,
splash: { _ in LifecycleSplash() },
failure: { LifecycleFailureView(failure: $0) },
gates: gates,
Expand All @@ -336,13 +368,15 @@ extension LifecycleContainer where Failure == LifecycleFailureView {
public init(
_ runner: LifecycleRunner<Launch>,
minimumSplashDuration: Duration = .zero,
readyRevealPolicy: LifecycleReadyRevealPolicy = .phaseDriven,
@ViewBuilder splash: @escaping (LifecycleStepContext?) -> Splash,
@GateRegistrationsBuilder gates: () -> [GateRegistration] = { [] },
@ViewBuilder content: @escaping (Launch) -> Content,
) {
self.init(
runner,
minimumSplashDuration: minimumSplashDuration,
readyRevealPolicy: readyRevealPolicy,
splash: splash,
failure: { LifecycleFailureView(failure: $0) },
gates: gates,
Expand Down
13 changes: 13 additions & 0 deletions Shared/LifecycleKitUI/Sources/LifecycleReadyRevealPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/// Controls how `LifecycleContainer` presents its first foreground-visible
/// `.ready` value.
public enum LifecycleReadyRevealPolicy: Sendable, Hashable {
/// Let the runner's rendered phases drive presentation. An already-ready
/// container reveals immediately when no splash appearance established a
/// minimum-duration hold.
case phaseDriven

/// Show the splash before the first visible ready-content reveal, even when
/// a headless-to-foreground drive completes between SwiftUI render passes.
/// The splash uses the container's `minimumSplashDuration`.
case splashBeforeFirstReveal
}
54 changes: 54 additions & 0 deletions Shared/LifecycleKitUI/Sources/LifecycleReadyRevealState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/// The presentation history that decides whether ready content is covered by
/// the splash. The deadline belongs to a rendered splash appearance—or, for
/// the opt-in policy, the first visible ready presentation when SwiftUI never
/// observed the runner's intervening splash phase.
enum LifecycleReadyRevealState: Equatable {
case awaitingFirstVisibleReady
case holdingSplash(until: ContinuousClock.Instant)
case revealed

init(
policy: LifecycleReadyRevealPolicy,
minimumSplashDuration: Duration,
) {
switch policy {
case .phaseDriven:
self = .revealed
case .splashBeforeFirstReveal:
self = minimumSplashDuration > .zero ? .awaitingFirstVisibleReady : .revealed
}
}

var canRevealReady: Bool {
switch self {
case .awaitingFirstVisibleReady, .holdingSplash: false
case .revealed: true
}
}

var splashHoldDeadline: ContinuousClock.Instant? {
switch self {
case let .holdingSplash(until: deadline): deadline
case .awaitingFirstVisibleReady, .revealed: nil
}
}

mutating func splashAppeared(
at instant: ContinuousClock.Instant,
minimumSplashDuration: Duration,
) {
guard minimumSplashDuration > .zero else {
self = .revealed
return
}
self = .holdingSplash(until: instant.advanced(by: minimumSplashDuration))
}

mutating func readyBecameVisible(
at instant: ContinuousClock.Instant,
minimumSplashDuration: Duration,
) {
guard case .awaitingFirstVisibleReady = self else { return }
splashAppeared(at: instant, minimumSplashDuration: minimumSplashDuration)
}
}
Loading
Loading