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: 2 additions & 2 deletions app/application.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions apple-tests/native-list/JournalListSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
75 changes: 62 additions & 13 deletions swift/JournalChrome.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -468,6 +493,11 @@ struct JournalSheetDismissButton: SwiftUI.View {
index<context.childIDs.count ? (owner,context.childIDs[index]) : nil
}
}
private var initialCover:Bool {
initialPresentation.covers(page:properties.pageID,visible:properties.visible==true,
loading:(properties.loadingPageIDs ?? []).contains(properties.pageID ?? ""),
cold:coldMotion.ticket != nil)
}
private func emit(_ action:String,source:String?=nil,target:String?=nil) {
guard let source=source ?? properties.pageID else {return}
var value=["action":action,"source":source]
Expand Down Expand Up @@ -558,8 +588,10 @@ struct JournalSheetDismissButton: SwiftUI.View {
Pane(context:context,owner:slot.owner,node:slot.node,
records:current && properties.visible==true && !(settling && committing) && !covered,
shown:shown,
interactive:current && properties.visible==true && !(settling && committing) && !covered,
coldMotion:coldMotion,coldNonce:coldNonce,loading:(properties.loadingPageIDs ?? []).contains(slot.owner))
interactive:current && properties.visible==true && !(settling && committing) && !covered && !initialCover,
coldMotion:coldMotion,coldNonce:coldNonce,
initialNonce:(current && initialCover) || covered ? initialPresentation.nonce : nil,
initialReady:{page,nonce in initialPresentation.ready(page:page,nonce:nonce)},loading:(properties.loadingPageIDs ?? []).contains(slot.owner))
.frame(width:bounds.size.width,height:bounds.size.height)
.offset(x:current ? (ownsMotion ? translation : 0) : incoming ? translation-bounds.size.width : -bounds.size.width)
.opacity(shown ? 1 : 0)
Expand All @@ -569,6 +601,20 @@ struct JournalSheetDismissButton: SwiftUI.View {
.background(Color(uiColor:.systemBackground))
.clipped()
.overlay{JournalGraphColdSurface(owner:coldMotion).allowsHitTesting(false).accessibilityHidden(true)}
.overlay {
if initialCover {
ZStack(alignment:.topLeading) {
Color(uiColor:.systemBackground)
JournalStartupLoadingSurface(loading:false,initial:true,active:true)
.frame(width:bounds.size.width,
height:startupContainerFrame?.height ?? bounds.size.height)
.offset(y:startupContainerFrame.map {
$0.minY - bounds.frame(in:.global).minY
} ?? 0)
}
.accessibilityIdentifier("journal-initial-layout-cover")
}
}
.overlay(alignment:.top) {
if let name = graphName.presentation?.name {
nameCapsule(name)
Expand All @@ -579,7 +625,7 @@ struct JournalSheetDismissButton: SwiftUI.View {
.allowsHitTesting(false)
}
}
.gesture(RightGraphPan(enabled:properties.enabled==true && !settling && coldMotion.ticket==nil) {handle($0,width:bounds.size.width)})
.gesture(RightGraphPan(enabled:properties.enabled==true && !settling && coldMotion.ticket==nil && !initialCover) {handle($0,width:bounds.size.width)})
.onChange(of:bounds.size) { _,_ in reset(notify:true) }
}
// Each native hosting root owns the same window-sized canvas as the
Expand All @@ -594,7 +640,10 @@ struct JournalSheetDismissButton: SwiftUI.View {
graphScrollOffsets?.retain(ids ?? [])
if let source,let target,!(ids ?? []).contains(source) || !(ids ?? []).contains(target) {reset(notify:false)}
}
.onChange(of:properties.pageID) { _,page in
.onChange(of:properties.pageID) { previous,page in
// Initial nil -> 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
Expand All @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion swift/JournalList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
29 changes: 29 additions & 0 deletions test/application_view_test.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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 ())
Expand Down
Loading