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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ jobs:
timeout-minutes: 60
env:
TEST_WORKDIR: ${{ github.workspace }}/test-output-${{ matrix.shard }}
# Hosted snapshot rendering is substantially slower on these shared
# runners. Scale only maximum settle/hook ceilings; successful captures
# still use the same floor and pixel-stability proof as local runs.
SNAPSHOT_SETTLE_TIMEOUT_MULTIPLIER: "2"
steps:
# Reference images live in Git LFS (see .gitattributes), so fetch them —
# without this the images arrive as pointer files and every comparison fails.
Expand Down
3 changes: 3 additions & 0 deletions Shared/Flyover/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ conventions.
- Invoke variant builders through the serial deferred load coordinator, never
synchronously from a SwiftUI `body`; preview fixtures may open expensive
in-memory stores.
- Canvas preview readiness is the latest nonempty visible-load expectation;
variant/generation changes supersede stale completions, and cancelled waiters
must resume. `FlyoverSnapshotTests` awaits it before intrinsic measurement.
- Global traits are session-only and apply to registered content, not Flyover
chrome.
- Register forward push/modal routes only. Flyover derives Back/Dismiss cues
Expand Down
4 changes: 4 additions & 0 deletions Shared/Flyover/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ single pinned preview until it is paused. Opening the focused inspector unloads
the underlying canvas previews. Variant builders are deferred and serialized,
with a render opportunity between builds, so expensive preview-model
construction cannot accumulate in one SwiftUI update.
The canvas also tracks the latest nonempty set of visible variant/generation
loads. Snapshot capture awaits that production loading signal before intrinsic
measurement, so a slow runner cannot size or capture a partially loaded canvas;
viewport or variant changes supersede stale completions.

The initial canvas zoom fits the graph to the available width so its cards are
immediately legible and the remaining groups can be reached by vertical
Expand Down
31 changes: 20 additions & 11 deletions Shared/Flyover/SnapshotTests/FlyoverSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,26 @@ import Testing
struct FlyoverSnapshotTests {
@Test func canvasAndList() async {
let catalog = Self.catalog()
await assertSnapshots(
of: FlyoverView(catalog: catalog),
named: "FlyoverCanvas",
configurations: SnapshotConfiguration.combinations(
devices: [.iPadFullContent],
colorSchemes: [.light, .dark],
),
// Canvas previews load serially, so a cold CI host can still be resolving
// the visible screen trees after the default settling budget.
settle: .settledAtLeast(minDuration: 1.5),
)
for configuration in SnapshotConfiguration.combinations(
devices: [.iPadFullContent],
colorSchemes: [.light, .dark],
) {
// A fresh model gives each appearance an independent readiness
// expectation and set of serial preview loads.
let model = FlyoverModel(catalog: catalog)
await assertSnapshots(
of: FlyoverView(catalog: catalog, model: model),
named: "FlyoverCanvas",
configurations: [configuration],
measurementReadiness: .settled,
onReadyToMeasure: {
await model.waitUntilVisiblePreviewsAreLoaded()
},
// The deterministic hook covers preview loading. Keep the floor
// for the genuinely time-based glass material adaptation.
settle: .settledAtLeast(minDuration: 1.5),
)
}

await assertSnapshots(
of: FlyoverView(catalog: catalog),
Expand Down
12 changes: 12 additions & 0 deletions Shared/Flyover/Sources/FlyoverCanvasView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ struct FlyoverCanvasView<ScreenID: Hashable>: View {
} else {
renderPlan.liveScreenIDs
}
let expectedPreviewLoads: Set<FlyoverPreviewReadiness<ScreenID>.LoadKey> = if model
.hasAppliedInitialCanvasZoom
{
Set(catalog.screens.compactMap { screen in
liveScreenIDs.contains(screen.id) ? model.previewLoadKey(for: screen) : nil
})
} else {
[]
}

GeometryReader { proxy in
ScrollView([.horizontal, .vertical]) {
Expand Down Expand Up @@ -81,6 +90,9 @@ struct FlyoverCanvasView<ScreenID: Hashable>: View {
.task {
applyInitialWidthFit(layout: layout, in: proxy.size)
}
.task(id: expectedPreviewLoads) {
model.previewReadiness.expect(expectedPreviewLoads)
}
}
}

Expand Down
18 changes: 17 additions & 1 deletion Shared/Flyover/Sources/FlyoverModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ final class FlyoverModel<ScreenID: Hashable> {
private(set) var previewedScreenID: ScreenID?

let contentLoadCoordinator = FlyoverContentLoadCoordinator()
let previewReadiness = FlyoverPreviewReadiness<ScreenID>()
private let frameStates: [ScreenID: FlyoverFrameState]
private var hasAppliedInitialCanvasZoom = false
private(set) var hasAppliedInitialCanvasZoom = false

init(catalog: FlyoverCatalog<ScreenID>) {
frameStates = catalog.screens.reduce(into: [:]) { states, screen in
Expand Down Expand Up @@ -53,6 +54,21 @@ final class FlyoverModel<ScreenID: Hashable> {
return screen.variants.first { $0.id == selectedID } ?? screen.variants[0]
}

func previewLoadKey(for screen: FlyoverScreen<ScreenID>) -> FlyoverPreviewReadiness<ScreenID>
.LoadKey
{
let state = state(for: screen)
return FlyoverPreviewReadiness<ScreenID>.LoadKey(
screenID: screen.id,
variantID: variant(for: screen).id,
generation: state.generation,
)
}

func waitUntilVisiblePreviewsAreLoaded() async {
await previewReadiness.waitUntilReady()
}

func focus(_ screen: FlyoverScreen<ScreenID>) {
focusedSelection = FlyoverSelection(id: screen.id)
}
Expand Down
101 changes: 101 additions & 0 deletions Shared/Flyover/Sources/FlyoverPreviewReadiness.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Foundation

/// Tracks the latest nonempty set of canvas previews whose real content must
/// finish loading before an intrinsic snapshot may measure Flyover.
@MainActor
final class FlyoverPreviewReadiness<ScreenID: Hashable> {
struct LoadKey: Hashable {
let screenID: ScreenID
let variantID: FlyoverVariantID
let generation: Int
}

private(set) var expectedKeys: Set<LoadKey>?
private(set) var expectationGeneration = 0
private(set) var waiterCount = 0
private var activeKeys: Set<LoadKey> = []
private var completedKeys: Set<LoadKey> = []
private var waiters: [UUID: CheckedContinuation<Void, Never>] = [:]

var isReadyForLatestExpectation: Bool {
guard let expectedKeys else {
return false
}
return expectedKeys.isSubset(of: completedKeys)
}

/// Supersedes the previous viewport expectation. Empty sets are ignored so
/// an intrinsic capture cannot declare readiness before scroll geometry has
/// published its first visible region.
func expect(_ keys: Set<LoadKey>) {
guard keys.isEmpty == false, keys != expectedKeys else {
return
}
expectedKeys = keys
expectationGeneration += 1
completedKeys.formIntersection(keys)
resumeWaitersIfReady()
}

func beganLoading(_ key: LoadKey) {
activeKeys.insert(key)
completedKeys.remove(key)
}

func finishedLoading(_ key: LoadKey) {
guard activeKeys.contains(key) else {
return
}
completedKeys.insert(key)
resumeWaitersIfReady()
}

func unloaded(_ key: LoadKey) {
activeKeys.remove(key)
completedKeys.remove(key)
}

/// Waits for whichever nonempty expectation is current when readiness is
/// reached. If viewport or variant state changes while suspended, the new
/// expectation replaces the old one instead of allowing stale completions
/// to release the waiter.
func waitUntilReady() async {
guard isReadyForLatestExpectation == false else {
return
}

let waiterID = UUID()
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
guard Task.isCancelled == false else {
continuation.resume()
return
}
waiters[waiterID] = continuation
waiterCount = waiters.count
resumeWaitersIfReady()
}
} onCancel: {
Task { @MainActor in
self.cancelWaiter(waiterID)
}
}
}

private func resumeWaitersIfReady() {
guard isReadyForLatestExpectation else {
return
}
let readyWaiters = Array(waiters.values)
waiters.removeAll()
waiterCount = 0
for waiter in readyWaiters {
waiter.resume()
}
}

private func cancelWaiter(_ id: UUID) {
waiters.removeValue(forKey: id)?.resume()
waiterCount = waiters.count
}
}
42 changes: 35 additions & 7 deletions Shared/Flyover/Sources/FlyoverScreenContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ struct FlyoverScreenContent<ScreenID: Hashable>: View {
let screen: FlyoverScreen<ScreenID>
let model: FlyoverModel<ScreenID>
let isOverview: Bool
@State private var content: AnyView?
@State private var loadedContent: LoadedContent?
@Environment(\.colorScheme) private var systemColorScheme
@Environment(\.flyoverStylesheet) private var stylesheet

Expand All @@ -20,16 +20,17 @@ struct FlyoverScreenContent<ScreenID: Hashable>: View {
generation: state.generation,
isOverview: isOverview,
)
let previewLoadKey = model.previewLoadKey(for: screen)

Group {
if let content {
if let loadedContent, loadedContent.id == contentID {
switch screen.navigationContainer {
case .stack:
NavigationStack {
content
loadedContent.content
}
case .none:
content
loadedContent.content
}
} else {
ProgressView()
Expand All @@ -56,7 +57,23 @@ struct FlyoverScreenContent<ScreenID: Hashable>: View {
)
.allowsHitTesting(isOverview == false)
.task(id: contentID) {
content = nil
if loadedContent?.id == contentID {
if isOverview {
model.previewReadiness.beganLoading(previewLoadKey)
model.previewReadiness.finishedLoading(previewLoadKey)
}
return
}
loadedContent = nil
if isOverview {
model.previewReadiness.beganLoading(previewLoadKey)
}
var didFinishLoading = false
defer {
if isOverview, didFinishLoading == false {
model.previewReadiness.unloaded(previewLoadKey)
}
}
await model.contentLoadCoordinator.perform {
guard Task.isCancelled == false else {
return
Expand All @@ -69,11 +86,17 @@ struct FlyoverScreenContent<ScreenID: Hashable>: View {
guard Task.isCancelled == false else {
return
}
content = loadedContent
self.loadedContent = LoadedContent(id: contentID, content: loadedContent)
didFinishLoading = true
if isOverview {
model.previewReadiness.finishedLoading(previewLoadKey)
}
}
}
.onDisappear {
content = nil
if isOverview {
model.previewReadiness.unloaded(previewLoadKey)
}
}
}

Expand All @@ -90,4 +113,9 @@ struct FlyoverScreenContent<ScreenID: Hashable>: View {
let generation: Int
let isOverview: Bool
}

private struct LoadedContent {
let id: ContentID
let content: AnyView
}
}
5 changes: 5 additions & 0 deletions Shared/Flyover/Sources/FlyoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ public struct FlyoverView<ScreenID: Hashable>: View {
_model = State(initialValue: FlyoverModel(catalog: catalog))
}

init(catalog: FlyoverCatalog<ScreenID>, model: FlyoverModel<ScreenID>) {
self.catalog = catalog
_model = State(initialValue: model)
}

public var body: some View {
FlyoverRootView(catalog: catalog, model: model)
.broadwayRoot()
Expand Down
Loading
Loading