diff --git a/app/application.ml b/app/application.ml index 6a696d6..6e4136a 100644 --- a/app/application.ml +++ b/app/application.ml @@ -1381,7 +1381,7 @@ let rec apply_worker_response ?(settling=false) ~discard state (response : Journ in { state with timeline - ; feed_loaded = true + ; feed_loaded = state.feed_loaded || complete ; presented_feed_context = Some refresh.context ; feed_refresh = (if complete then None else state.feed_refresh) }) @@ -1410,7 +1410,7 @@ let rec apply_worker_response ?(settling=false) ~discard state (response : Journ in { state with timeline - ; feed_loaded = state.feed_loaded || accepted_initial_feed + ; feed_loaded = state.feed_loaded || (accepted_initial_feed && complete) ; presented_feed_context = (if accepted_initial_feed then Option.map feed_projection_context state.calendar diff --git a/apple-tests/native-list/JournalListSnapshotTests.swift b/apple-tests/native-list/JournalListSnapshotTests.swift index 4968851..7fb4b18 100644 --- a/apple-tests/native-list/JournalListSnapshotTests.swift +++ b/apple-tests/native-list/JournalListSnapshotTests.swift @@ -38,6 +38,25 @@ import UIKit } static func settle() async { for _ in 0..<10 { await Task.yield() } } static func main() async throws { + // Initial presentation owner: native rendering, not an OCaml reducer boundary. + var initialPresentation = JournalGraphInitialPresentation() + let nonce = initialPresentation.nonce + check(initialPresentation.covers(page: "A", visible: true, loading: false, cold: false), "final-data first mount remains covered until layout") + check(!initialPresentation.covers(page: "A", visible: true, loading: true, cold: false), "loading and recovery content remain accessible") + check(!initialPresentation.covers(page: "A", visible: false, loading: false, cold: false), "hidden manager never gets a cover") + check(!initialPresentation.covers(page: "A", visible: true, loading: false, cold: true), "cold swipe retains its existing cover owner") + initialPresentation.ready(page: "A", nonce: UUID()) + check(initialPresentation.covers(page: "A", visible: true, loading: false, cold: false), "stale layout completion cannot reveal content") + initialPresentation.ready(page: "A", nonce: nonce) + check(!initialPresentation.covers(page: "A", visible: true, loading: false, cold: false), "native layout reveals initial page including empty feed") + check(!initialPresentation.covers(page: "A", visible: true, loading: false, cold: false), "resident return does not cover a presented page") + check(!initialPresentation.covers(page: "B", visible: true, loading: false, cold: false), "new graph after first presentation never gets a startup cover") + check(!initialPresentation.covers(page: "C", visible: true, loading: false, cold: false), "Favorites generation replacement never gets a startup cover") + var interruptedPresentation = JournalGraphInitialPresentation() + interruptedPresentation.cancel(page: "A") + check(!interruptedPresentation.covers(page: "B", visible: true, loading: false, cold: false), "interruption retires startup gate for all subsequent pages") + interruptedPresentation.ready(page: "A", nonce: interruptedPresentation.nonce) + check(!interruptedPresentation.covers(page: "B", visible: true, loading: false, cold: false), "late old-owner layout cannot rearm gate after initial focus replacement") #if os(iOS) let handoff = JournalGraphHandoff() let cancelledGesture = handoff.startGesture() diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index e163ac1..06421c2 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -4,6 +4,23 @@ import SwiftUI import UIKit #endif +/// One initial native layout handoff for this GraphPages lifetime, not per graph. +/// Later routes may have no Timeline list; downloads never participate. +struct JournalGraphInitialPresentation { + let nonce = UUID() + private var finished = false + func covers(page: String?, visible: Bool, loading: Bool, cold: Bool) -> Bool { + return page != nil && visible && !loading && !cold && !finished + } + mutating func ready(page: String, nonce: UUID) { + guard nonce == self.nonce else { return } + finished = true + } + mutating func cancel(page: String?) { + finished = true + } +} + #if os(iOS) /// Fences one gesture and a committed focus request by its original owners. /// The target page stays mounted while Save may defer the focus publication. @@ -298,9 +315,11 @@ struct JournalSheetDismissButton: SwiftUI.View { @State private var target:String? @State private var graphName = JournalGraphNameFeedback() @Environment(\.journalContainerTopInset) private var containerTopInset + @Environment(\.journalStartupContainerFrame) private var startupContainerFrame @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @StateObject private var handoff=JournalGraphHandoff() @StateObject private var coldMotion=JournalGraphColdMotion() + @State private var initialPresentation=JournalGraphInitialPresentation() @Environment(\.scenePhase) private var scenePhase @Environment(\.journalGraphScrollOffsets) private var graphScrollOffsets @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -316,10 +335,15 @@ struct JournalSheetDismissButton: SwiftUI.View { let interactive:Bool let coldMotion:JournalGraphColdMotion let coldNonce:UUID? + let initialNonce:UUID? + let initialReady:@MainActor @Sendable (String,UUID)->Void let loading:Bool private var presentationReady:(@MainActor @Sendable (String)->Void)? { - guard let coldNonce else{return nil} - return {page in coldMotion.ready(page:page,nonce:coldNonce)} + guard coldNonce != nil || initialNonce != nil else{return nil} + return {page in + if let coldNonce {coldMotion.ready(page:page,nonce:coldNonce)} + if let initialNonce {initialReady(page,initialNonce)} + } } struct Style:Equatable { @@ -369,6 +393,7 @@ struct JournalSheetDismissButton: SwiftUI.View { var node = -1 var style:Style? var coldNonce:UUID? + var initialNonce:UUID? var loading=false var owner:String? weak var coldMotion:JournalGraphColdMotion? @@ -426,7 +451,7 @@ struct JournalSheetDismissButton: SwiftUI.View { .environment(\.journalStartupClock,environment.journalStartupClock) .environment(\.journalGraphScrollOffsets,environment.journalGraphScrollOffsets) .environment(\.journalGraphScrollScope,JournalGraphScrollScope(pageID:owner,records:records)) - .environment(\.journalGraphPresentationNonce,coldNonce) + .environment(\.journalGraphPresentationNonce,coldNonce ?? initialNonce) .environment(\.journalGraphPresentationReady,presentationReady) .accessibilityHidden(!interactive) .background { @@ -439,17 +464,17 @@ struct JournalSheetDismissButton: SwiftUI.View { let gate=LoadingGate();gate.enabled=loading;gate.owner=owner;gate.nonce=coldNonce;gate.motion=coldMotion let controller=Controller(rootView:content(context.environment,gate:gate),loadingGate:gate) controller.records=records;controller.interactive=interactive;controller.node=node;controller.style=Style(context.environment) - controller.coldNonce=coldNonce;controller.loading=loading;controller.owner=owner;controller.coldMotion=coldMotion + controller.coldNonce=coldNonce;controller.initialNonce=initialNonce;controller.loading=loading;controller.owner=owner;controller.coldMotion=coldMotion controller.setRole(shown:shown,interactive:interactive) coldMotion.register(owner,view:controller.view) return controller } func updateUIViewController(_ controller:Controller,context:Context) { let style=Style(context.environment) - if controller.node != node || controller.records != records || controller.interactive != interactive || controller.style != style || controller.coldNonce != coldNonce || controller.loading != loading { + if controller.node != node || controller.records != records || controller.interactive != interactive || controller.style != style || controller.coldNonce != coldNonce || controller.initialNonce != initialNonce || controller.loading != loading { controller.loadingGate.token=UUID();controller.loadingGate.enabled=loading controller.loadingGate.owner=owner;controller.loadingGate.nonce=coldNonce - controller.node=node;controller.records=records;controller.interactive=interactive;controller.style=style;controller.coldNonce=coldNonce;controller.loading=loading + controller.node=node;controller.records=records;controller.interactive=interactive;controller.style=style;controller.coldNonce=coldNonce;controller.initialNonce=initialNonce;controller.loading=loading withTransaction(Transaction(animation:nil)) {controller.host.rootView=content(context.environment,gate:controller.loadingGate)} } controller.setRole(shown:shown,interactive:interactive) @@ -468,6 +493,11 @@ struct JournalSheetDismissButton: SwiftUI.View { index owner is admission. Replacing that owner interrupts + // startup; a retained Favorites/Detail route need not own a List handshake. + if previous != nil {initialPresentation.cancel(page:previous)} graphName.focusChanged(page) let keepName = graphName.presentation?.phase == .confirmed let keepCold=committing && coldMotion.ticket?.target==page @@ -620,10 +669,10 @@ struct JournalSheetDismissButton: SwiftUI.View { if prepared == nil && source==properties.pageID && (dragging || settling) {reset(notify:false)} } .onChange(of:properties.visible) { _,visible in - if visible != true {reset(notify:true)} else {acknowledge()} + if visible != true {initialPresentation.cancel(page:properties.pageID);reset(notify:true)} else {acknowledge()} } - .onChange(of:scenePhase) { _,phase in if phase != .active {reset(notify:true)} } - .onDisappear {reset(notify:false)} + .onChange(of:scenePhase) { _,phase in if phase != .active {initialPresentation.cancel(page:properties.pageID);reset(notify:true)} } + .onDisappear {initialPresentation.cancel(page:properties.pageID);reset(notify:false)} .task(id:graphName.presentation?.id) { guard let name = graphName.presentation,name.phase == .confirmed else { return } if UIAccessibility.isVoiceOverRunning { diff --git a/swift/JournalList.swift b/swift/JournalList.swift index 6894d61..cf970ac 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -445,9 +445,11 @@ extension EnvironmentValues { request.complete(y) } func reportPresentationLayout() { + // Empty timelines also have a completed native viewport layout. Requiring + // positive content height would strand their initial loading cover. guard active, presentationLayout != nil, restoredOffset == nil, !reportingLayout, let scrollView, scrollView.window != nil, scrollView.bounds.height > 0, - scrollView.contentSize.height > 0 else { return } + scrollView.bounds.width > 0 else { return } reportingLayout = true DispatchQueue.main.async { [weak self] in guard let self else { return } diff --git a/test/application_view_test.ml b/test/application_view_test.ml index 64c0d54..c354723 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -600,6 +600,7 @@ let run_favorites_native_visibility ?(check_covered_update = false) ?calendar_change ?(timeline_rows = 3) + ?(check_initial_feed = false) ?(check_generation = false) ?(check_reselect = false) ?(check_chrome = false) @@ -696,6 +697,8 @@ let run_favorites_native_visibility let reported_graph_generation = Atomic.make None in let queried = Atomic.make [] in let feed_queried = Atomic.make false in + let initial_feed_continuation = Atomic.make false in + let initial_feed_release = Atomic.make (not check_initial_feed) in let calendar_feed_reads = Atomic.make 0 in let calendar_feed_release = Atomic.make false in let calendar_refresh_pending = Atomic.make false in @@ -1177,6 +1180,12 @@ let run_favorites_native_visibility | Read.Tree _ -> let count = Option.value media_rows ~default:timeline_rows in let offset = match cursor with None -> 0 | Some value -> int_of_string (G.Cursor.to_string value) in + if check_initial_feed && offset > 0 then ( + Atomic.set initial_feed_continuation true; + while not (Atomic.get initial_feed_release) do + Eio.Time.Mono.sleep (W.Request_context.clock context) 0.001 + done); + let limit = if check_initial_feed && offset = 0 then 1 else limit in let length = max 0 (min limit (count - offset)) in (Ok(F.slice ~items:(List.init length (fun index -> { Worker_pure_data.value = timeline_block (offset + index) @@ -1955,6 +1964,7 @@ let run_favorites_native_visibility in Fun.protect ~finally:(fun () -> + Atomic.set initial_feed_release true; Atomic.set failed_media_recovery_release true; Atomic.set coordination_release_allowed true; Atomic.set changed_media_fact_release true; @@ -2007,6 +2017,22 @@ let run_favorites_native_visibility hooks.pump () |> consume; Unix.sleepf 0.001 done; + if check_initial_feed then ( + (* Application owns the feed-completion gate but does not expose a + public pure reducer. Exercise its mounted service boundary with a + real bounded first feed and hold only its continuation read. *) + wait "initial feed publishes a prefix before its final read" (fun () -> + Atomic.get initial_feed_continuation); + let deadline = Unix.gettimeofday () +. 0.15 in + while Unix.gettimeofday () < deadline do + hooks.pump () |> consume; + Unix.sleepf 0.001 + done; + Alcotest.(check bool) "partial initial feed stays behind startup loading" true + (Option.is_none (find "text" "Timeline fixture 0")); + Alcotest.(check bool) "loading surface survives partial initial feed" true + (Option.is_some (find "_extension" "journal-chrome")); + Atomic.set initial_feed_release true); wait "Journals mounted" (fun () -> Option.is_some (find "accessibility-label" "Favorites") && Option.is_some (find "text" "Timeline fixture 2")); @@ -8850,6 +8876,9 @@ let () = `Quick (fun () -> test_reference_outer_terminals true) ] ) + ; ( "initial timeline presentation" + , [ Alcotest.test_case "waits for the complete bounded feed" `Quick + (fun () -> run_favorites_native_visibility ~check_initial_feed:true ()) ] ) ; ( "root navigation" , [ Alcotest.test_case "Timeline reselect ownership" `Quick (fun () -> run_favorites_native_visibility ~check_reselect:true ())