From 8eda4a85405273b2ec7c4e67437079d427d359bd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 26 Oct 2026 19:01:10 +0700 Subject: [PATCH 1/2] perf(sidebar): put the first catalog's outline up a committed frame later --- .../Extensions/NSWindow+FirstFrame.swift | 12 +++++++++ .../SidebarObjectListPresentation.swift | 6 +++++ .../TablePro/Views/Sidebar/SidebarView.swift | 26 +++++++++++++++++-- .../Extensions/NSWindowNextFrameTests.swift | 11 ++++++++ .../SidebarObjectListPresentationTests.swift | 8 ++++++ 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/apple/TablePro/Extensions/NSWindow+FirstFrame.swift b/apple/TablePro/Extensions/NSWindow+FirstFrame.swift index 925156f3eb..eb851bc4ed 100644 --- a/apple/TablePro/Extensions/NSWindow+FirstFrame.swift +++ b/apple/TablePro/Extensions/NSWindow+FirstFrame.swift @@ -58,6 +58,18 @@ internal extension NSWindow { } } +/// Work that must not share a run loop turn with the frame this turn builds. +@MainActor +internal enum CommittedTurn { + /// Runs `body` after this turn's frame is committed, in a later turn. A main-queue hop alone can run + /// before the run loop waits, so the hang would still be one: both pieces between two waits. + static func after(_ body: @escaping @MainActor () -> Void) { + CATransaction.setCompletionBlock { + DispatchQueue.main.async { MainActor.assumeIsolated(body) } + } + } +} + @MainActor private final class FrameGate { var continuation: CheckedContinuation? diff --git a/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift b/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift index 018b3f3f6e..5d6741568a 100644 --- a/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift +++ b/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift @@ -32,6 +32,12 @@ internal enum SidebarObjectListPresentation: Equatable { /// Reaching for the state enum directly instead is what left the tree with a spinner on no gate /// at all, flashing it on every engine that groups by schema while the two flat shapes beside /// it held theirs back. + /// What the list stands on while a loaded catalog waits for its turn: the load still reads as in flight. + internal static func presentedState(_ state: SchemaState, showsLoaded: Bool) -> SchemaState { + if case .loaded = state, !showsLoaded { return .loading } + return state + } + internal static func resolveDeferringEmptyStates( state: SchemaState, hasOutlastedGrace: Bool = true diff --git a/apple/TablePro/Views/Sidebar/SidebarView.swift b/apple/TablePro/Views/Sidebar/SidebarView.swift index 08a7436198..1d9b0e002f 100644 --- a/apple/TablePro/Views/Sidebar/SidebarView.swift +++ b/apple/TablePro/Views/Sidebar/SidebarView.swift @@ -15,6 +15,8 @@ struct SidebarView: View { @StateObject private var viewModel: SidebarViewModel @ObservedObject private var settingsManager = AppSettingsManager.shared @State private var showsSchemaProgress = false + /// See `presentedSchemaState`. + @State private var showsLoadedCatalog = false @ObservedObject private var schemaService = SchemaService.shared @ObservedObject private var treeMetadata = DatabaseTreeMetadataService.shared @@ -200,6 +202,15 @@ struct SidebarView: View { } .onAppear { coordinator?.sidebarViewModel = viewModel + // A pane put back on screen shows the list it already had. + showsLoadedCatalog = isCatalogLoaded + } + .onChange(of: isCatalogLoaded) { isLoaded in + guard isLoaded else { + showsLoadedCatalog = false + return + } + CommittedTurn.after { showsLoadedCatalog = true } } .onChange(of: viewModel.showOperationDialog) { isPresented in guard isPresented else { return } @@ -305,7 +316,7 @@ struct SidebarView: View { @ViewBuilder private var hierarchicalContent: some View { let presentation = SidebarObjectListPresentation.resolveDeferringEmptyStates( - state: schemaService.state(for: connectionId), + state: presentedSchemaState, hasOutlastedGrace: showsSchemaProgress ) Group { @@ -336,9 +347,20 @@ struct SidebarView: View { ) } + private var isCatalogLoaded: Bool { + if case .loaded = schemaService.state(for: connectionId) { return true } + return false + } + + // A connection's first catalog can land in the turn that commits the restored tab's frame, and building the + // outline there made one stall of both. The outline goes up in a later turn, the loading state held till then. + private var presentedSchemaState: SchemaState { + SidebarObjectListPresentation.presentedState(schemaService.state(for: connectionId), showsLoaded: showsLoadedCatalog) + } + private var objectListPresentation: SidebarObjectListPresentation { SidebarObjectListPresentation.resolve( - state: schemaService.state(for: connectionId), + state: presentedSchemaState, hasActiveFilter: !viewModel.filterQuery.isEmpty, hasAnyMatch: hasAnyMatch, hasOutlastedGrace: showsSchemaProgress, diff --git a/apple/TableProTests/Extensions/NSWindowNextFrameTests.swift b/apple/TableProTests/Extensions/NSWindowNextFrameTests.swift index 24652779f4..ae48456d27 100644 --- a/apple/TableProTests/Extensions/NSWindowNextFrameTests.swift +++ b/apple/TableProTests/Extensions/NSWindowNextFrameTests.swift @@ -5,6 +5,7 @@ import AppKit @testable import TablePro +import TableProTestSupport import Testing @MainActor @@ -49,4 +50,14 @@ struct NSWindowNextFrameTests { #expect(FirstFrameObserver.liveCount == before) } + + @Test("Work after the committed turn never runs in the turn that asked for it") + func committedTurnRunsLater() async throws { + var ran = false + + CommittedTurn.after { ran = true } + + #expect(!ran) + try await waitUntil { ran } + } } diff --git a/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift b/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift index 8fd0d08fa6..830de95668 100644 --- a/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift +++ b/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift @@ -167,4 +167,12 @@ struct SidebarObjectListPresentationTests { hasOutlastedGrace: false ) == .failed("boom")) } + @Test("A loaded catalog waiting for its turn still reads as loading, and every other state passes through") + func heldCatalogReadsAsLoading() { + let loaded = SchemaState.loaded([table("users")]) + #expect(SidebarObjectListPresentation.presentedState(loaded, showsLoaded: false) == .loading) + #expect(SidebarObjectListPresentation.presentedState(loaded, showsLoaded: true) == loaded) + #expect(SidebarObjectListPresentation.presentedState(.failed("boom"), showsLoaded: false) == .failed("boom")) + #expect(SidebarObjectListPresentation.presentedState(.idle, showsLoaded: true) == .idle) + } } From b2ee2b348ec7c271484209075f0a0d401eb0b722 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 26 Oct 2026 23:17:50 +0700 Subject: [PATCH 2/2] docs(changelog): add the #3849 fragment --- apple/changelog.d/3849.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3849.fixed.md diff --git a/apple/changelog.d/3849.fixed.md b/apple/changelog.d/3849.fixed.md new file mode 100644 index 0000000000..51afd3407b --- /dev/null +++ b/apple/changelog.d/3849.fixed.md @@ -0,0 +1 @@ +The sidebar no longer freezes the window for a moment when a very large table list first loads.