From 0c2795ab06a608463deba57402a39ada750c526c Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 12 Oct 2026 22:58:44 -0700 Subject: [PATCH] fix(ios): refresh page listings off the main thread A section opens with its rows read on the background thread that opens it, and a poll reporting `listed` reads the rows again off the main thread before telling the views; the rest of the poll (save status, remote changes, rejected edits) is posted at once. Deleting pages releases the arrangement only once the rows are read again. New Page, arranging and moving pages still read the rows synchronously, so Undo, drags and Make Subpage never start from rows that miss the move. A reload or a typed title invalidates a read in flight, which reads again rather than undo it. Refs #144 Assisted-by: gpt-6.1-sol Assisted-by: claude-opus-5.5 --- apps/ios/Snowbound/Arranging.swift | 14 ++---- apps/ios/Snowbound/Notebooks.swift | 72 ++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 29 deletions(-) diff --git a/apps/ios/Snowbound/Arranging.swift b/apps/ios/Snowbound/Arranging.swift index 123ad7ae4bd9bae9d1c4760b594c849431722c8c..1f2014b2773c8d45fa73d4c742d5fc4a0ada5f84 100644 --- a/apps/ios/Snowbound/Arranging.swift +++ b/apps/ios/Snowbound/Arranging.swift @@ -152,7 +152,7 @@ enum Arranging { reported = moved release() for section in Section.all where section.notebook === notebook && [from, to].contains(section.tab.path) { - section.listed() + section.reloadRows() } if moved == nil { failed("Can’t Move Page") } } @@ -242,8 +242,8 @@ enum Arranging { } if !reciprocal && !reported.isEmpty { done("Move Pages") { undo() } } release() - source.listed() - target.listed() + source.reloadRows() + target.reloadRows() if !succeeded { failed("Can’t Move All Pages", reported.isEmpty ? "Check both sections, then try moving the remaining pages again." @@ -306,14 +306,8 @@ extension Section { /// Puts page `id` where `order` places it, as one edit; false where it isn't listed. func arrange(_ order: [Placed], moving id: String) -> Bool { guard sb_section_arrange(handle, json(order), json([id])) else { return false } - listed() - return true - } - - /// Lists the pages again and tells the views. - func listed() { reloadRows() - NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) + return true } } diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift index f5d394b5f306c0d32790f173910c08f2bf7cd158..fa63d3af34b7e6ba431727d9b010340d4dff81a1 100644 --- a/apps/ios/Snowbound/Notebooks.swift +++ b/apps/ios/Snowbound/Notebooks.swift @@ -629,7 +629,7 @@ private struct Weak { weak var value: T? } /// A section open for editing: pages listed and saved through its replica. final class Section { - /// Sent on the main thread with `flags` from `sb_section_poll` after each poll. + /// Sent on the main thread with `flags` from `sb_section_poll`, `listed` once the rows are read again. static let changed = Notification.Name("SectionChanged") static let listed: UInt32 = 1 static let remote: UInt32 = 2 @@ -642,17 +642,18 @@ final class Section { let tab: Tab let handle: OpaquePointer private let token: Int - private(set) var rows: [Row] = [] + private(set) var rows: [Row] private(set) var status = SaveStatus.unknown + private var listing: (invalidated: Bool, completions: [() -> Void])? static var all: [Section] { open.values.compactMap(\.value) } - private init(notebook: Notebook, tab: Tab, handle: OpaquePointer, token: Int) { + private init(notebook: Notebook, tab: Tab, handle: OpaquePointer, token: Int, rows: [Row]) { self.notebook = notebook self.tab = tab self.handle = handle self.token = token - reloadRows() + self.rows = rows } deinit { @@ -671,21 +672,53 @@ final class Section { let token = tokens let pointer = Int(bitPattern: library) let author = Author.name ?? "" - background({ () -> (Int, String?) in + background({ () -> (Int, String?, [Row]) in var error: UnsafeMutablePointer? let handle = sb_section_open( OpaquePointer(bitPattern: pointer), tab.path, author, wakeSection, UInt(token), &error) - return (Int(bitPattern: handle), take(error)) - }) { handle, error in + let rows = handle.flatMap { decode([Row].self, sb_section_pages($0)) } ?? [] + return (Int(bitPattern: handle), take(error), rows) + }) { handle, error, rows in guard let handle = OpaquePointer(bitPattern: handle) else { return done(nil, error) } - let section = Section(notebook: notebook, tab: tab, handle: handle, token: token) + let section = Section(notebook: notebook, tab: tab, handle: handle, token: token, rows: rows) open[token] = Weak(value: section) done(section, nil) + // Wakes before `open[token]` was set went unheard. + section.poll() } } + /// Lists the pages again and tells the views. func reloadRows() { + listing?.invalidated = true rows = decode([Row].self, sb_section_pages(handle)) ?? rows + NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) + } + + /// Lists the pages again off the main thread, tells the views, then runs `then`. + private func refreshRows(then: @escaping () -> Void = {}) { + guard listing == nil else { + listing?.invalidated = true + listing?.completions.append(then) + return + } + listing = (false, [then]) + readRows() + } + + private func readRows() { + background({ [self] in decode([Row].self, sb_section_pages(handle)) }) { [self] rows in + // A read begun before a reload or retitle would undo it. + if listing!.invalidated, rows != nil { + listing?.invalidated = false + return readRows() + } + self.rows = rows ?? self.rows + let completions = listing!.completions + listing = nil + NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) + for completion in completions { completion() } + } } /// The row listing page or conflict page `id`, and the version when it is one. @@ -701,17 +734,19 @@ final class Section { var status: UInt8 = 0 let flags = sb_section_poll(handle, &status) let changed = SaveStatus(rawValue: status) ?? .unknown - let unchanged = flags == 0 && changed == self.status + if flags & Self.listed != 0 { refreshRows() } + let unlisted = flags & ~Self.listed + let unchanged = unlisted == 0 && changed == self.status self.status = changed - if flags & Self.listed != 0 { reloadRows() } NotificationCenter.default.post( name: Self.changed, object: self, - userInfo: unchanged ? ["flags": flags, "unchanged": true] : ["flags": flags]) + userInfo: unchanged ? ["flags": unlisted, "unchanged": true] : ["flags": unlisted]) } /// A page's title as typed, shown in the list before the section reports it. func retitle(_ id: String, _ title: String) { guard let index = rows.firstIndex(where: { $0.id == id }), rows[index].title != title else { return } + listing?.invalidated = true rows[index].title = title NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": UInt32(0)]) } @@ -721,7 +756,6 @@ final class Section { let (date, time) = titleDate() let id = take(sb_section_new_page(handle, parent, date, time)) reloadRows() - NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) return id } @@ -735,13 +769,13 @@ final class Section { } return true }) { [self] deleted in - release() - reloadRows() - NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) - if deleted { done() } - else { - let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first - window?.rootViewController?.alert("Can’t Delete", "Some pages couldn’t be deleted. Try again.") + refreshRows { + release() + if deleted { done() } + else { + let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first + window?.rootViewController?.alert("Can’t Delete", "Some pages couldn’t be deleted. Try again.") + } } } } -- 2.54.0