From c755baeee7e170c656c25da261a464f244e2b523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 11 May 2026 15:33:23 +0700 Subject: [PATCH] perf(datagrid): six scroll-path improvements cut max main-thread stall by 62% --- CHANGELOG.md | 1 + TablePro/Core/DataGrid/RowDisplayBox.swift | 73 +++++++++++++++---- .../Formatting/DateFormattingService.swift | 15 +++- .../Results/Cells/DataGridCellView.swift | 41 +++++------ .../Views/Results/DataGridCoordinator.swift | 38 ++++------ .../Extensions/DataGridView+Columns.swift | 4 + 6 files changed, 110 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cfc803fb..b671cb7f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Data grid: max main-thread stall during wide-table scroll drops from 3.5s to about 1.3s. Cell `configure` now skips `needsDisplay = true` when the visible state is unchanged; the display cache is a Swift `Dictionary` with O(1) head-index eviction instead of `NSCache` with a per-call `RowIDKey` wrapper allocation; the date parser memoizes the most recent successful index so consecutive cells in the same column hit the right format first try; `viewFor:row:` is wrapped in `autoreleasepool` to drain transient NSString/NSAttributedString allocations per cell; and `DataGridCellView` no longer takes its own backing layer, letting the row view's layer absorb cell drawing - Sidebar: selected row icon and label tint to white so kind colors (indigo, teal, purple) stay readable on the blue selection background - Sidebar: drop the per-section item count; empty optional-kind sections are already hidden, so the count was visual noise that also jittered next to the hover-revealed disclosure chevron - Internal: sidebar rows use `Label` instead of hand-rolled `HStack`, and the selection-aware tint lives in a single `sidebarTint(_:)` modifier shared by table and routine rows diff --git a/TablePro/Core/DataGrid/RowDisplayBox.swift b/TablePro/Core/DataGrid/RowDisplayBox.swift index 9b571d14d..cf9f0107c 100644 --- a/TablePro/Core/DataGrid/RowDisplayBox.swift +++ b/TablePro/Core/DataGrid/RowDisplayBox.swift @@ -5,27 +5,70 @@ import Foundation -final class RowIDKey: NSObject { - let id: RowID +final class RowDisplayBox { + var values: ContiguousArray - init(_ id: RowID) { - self.id = id - super.init() + init(_ values: ContiguousArray) { + self.values = values } +} - override func isEqual(_ object: Any?) -> Bool { - guard let other = object as? RowIDKey else { return false } - return other.id == id +@MainActor +final class RowDisplayCache { + private var storage: [RowID: RowDisplayBox] = [:] + private var insertionOrder: [RowID] = [] + private var insertionHead: Int = 0 + private var totalCost: Int = 0 + private let countLimit: Int + private let costLimit: Int + + init(countLimit: Int = 50_000, costLimit: Int = 64 * 1_024 * 1_024) { + self.countLimit = countLimit + self.costLimit = costLimit } - override var hash: Int { id.hashValue } -} + func box(forID id: RowID) -> RowDisplayBox? { + storage[id] + } -final class RowDisplayBox: NSObject { - var values: ContiguousArray + func setBox(_ box: RowDisplayBox, forID id: RowID, cost: Int) { + if let existing = storage[id] { + totalCost -= rowCost(existing.values) + } else { + insertionOrder.append(id) + } + storage[id] = box + totalCost += cost + evictIfNeeded() + } - init(_ values: ContiguousArray) { - self.values = values - super.init() + func removeAll() { + storage.removeAll(keepingCapacity: true) + insertionOrder.removeAll(keepingCapacity: true) + insertionHead = 0 + totalCost = 0 + } + + private func evictIfNeeded() { + while storage.count > countLimit || totalCost > costLimit { + guard insertionHead < insertionOrder.count else { break } + let oldest = insertionOrder[insertionHead] + insertionHead += 1 + if let removed = storage.removeValue(forKey: oldest) { + totalCost -= rowCost(removed.values) + } + } + if insertionHead > 10_000 { + insertionOrder.removeFirst(insertionHead) + insertionHead = 0 + } + } + + private func rowCost(_ values: ContiguousArray) -> Int { + var total = 0 + for value in values { + if let s = value { total &+= s.utf8.count } + } + return total } } diff --git a/TablePro/Core/Services/Formatting/DateFormattingService.swift b/TablePro/Core/Services/Formatting/DateFormattingService.swift index ed89ca8b9..291b3c450 100644 --- a/TablePro/Core/Services/Formatting/DateFormattingService.swift +++ b/TablePro/Core/Services/Formatting/DateFormattingService.swift @@ -24,6 +24,10 @@ final class DateFormattingService { /// Parsers for common database date formats (ISO 8601, MySQL, PostgreSQL, SQLite) private let parsers: [DateFormatter] + /// Index of the parser that succeeded most recently. Tried first on the next parse + /// because consecutive cells in the same column share the same wire format. + private var lastSuccessfulParserIndex: Int = 0 + /// Cache for formatted date strings to avoid repeated parsing private let formatCache = NSCache() @@ -67,9 +71,14 @@ final class DateFormattingService { return cached.length == 0 ? nil : cached as String } - // Try parsing with each parser - for parser in parsers { - if let date = parser.date(from: dateString) { + if let date = parsers[lastSuccessfulParserIndex].date(from: dateString) { + let result = format(date) + formatCache.setObject(result as NSString, forKey: cacheKey) + return result + } + for index in parsers.indices where index != lastSuccessfulParserIndex { + if let date = parsers[index].date(from: dateString) { + lastSuccessfulParserIndex = index let result = format(date) formatCache.setObject(result as NSString, forKey: cacheKey) return result diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index 8f874402c..5099c0c70 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -61,9 +61,6 @@ final class DataGridCellView: NSView { } private func commonInit() { - wantsLayer = true - layerContentsRedrawPolicy = .onSetNeedsDisplay - canDrawSubviewsIntoLayer = true setAccessibilityElement(true) setAccessibilityRole(.cell) } @@ -71,27 +68,18 @@ final class DataGridCellView: NSView { override var allowsVibrancy: Bool { false } override var isFlipped: Bool { true } - override func makeBackingLayer() -> CALayer { - let layer = super.makeBackingLayer() - layer.actions = Self.disabledLayerActions - return layer - } - - private static let disabledLayerActions: [String: any CAAction] = [ - "position": NSNull(), - "bounds": NSNull(), - "frame": NSNull(), - "contents": NSNull(), - "hidden": NSNull(), - ] - func configure( kind: DataGridCellKind, content: DataGridCellContent, state: DataGridCellState, palette: DataGridCellPalette ) { - self.kind = kind + var needsRedraw = false + + if self.kind != kind { + self.kind = kind + needsRedraw = true + } cellRow = state.row cellColumnIndex = state.columnIndex @@ -126,9 +114,13 @@ final class DataGridCellView: NSView { textFont = nextFont textColor = nextColor cachedLine = nil + needsRedraw = true } - rawValue = content.rawValue + if rawValue != content.rawValue { + rawValue = content.rawValue + needsRedraw = true + } placeholder = content.placeholder isLargeDataset = state.isLargeDataset isEditableCell = state.isEditable @@ -143,18 +135,25 @@ final class DataGridCellView: NSView { } if !colorsEqual(modifiedColumnTint, nextTint) { modifiedColumnTint = nextTint + needsRedraw = true } - visualState = state.visualState + if visualState != state.visualState { + visualState = state.visualState + needsRedraw = true + } if isFocusedCell != state.isFocused { isFocusedCell = state.isFocused updateFocusPresentation() + needsRedraw = true } setAccessibilityRowIndexRange(NSRange(location: state.row, length: 1)) setAccessibilityColumnIndexRange(NSRange(location: state.columnIndex, length: 1)) - needsDisplay = true + if needsRedraw { + needsDisplay = true + } } override func accessibilityLabel() -> String? { diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 64752d1bc..48103d554 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -15,13 +15,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var isEditable: Bool var sortedIDs: [RowID]? private(set) var columnDisplayFormats: [ValueDisplayFormat?] = [] - private let displayCache: NSCache = { - let cache = NSCache() - cache.countLimit = 50_000 - cache.totalCostLimit = 64 * 1_024 * 1_024 - cache.name = "TablePro.DataGrid.displayCache" - return cache - }() + private let displayCache = RowDisplayCache() weak var delegate: (any DataGridViewDelegate)? weak var activeFKPreviewPopover: NSPopover? var dropdownColumns: Set? @@ -204,7 +198,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData themeCancellable?.cancel() themeCancellable = nil visualIndex.clear() - displayCache.removeAllObjects() + displayCache.removeAll() columnDisplayFormats = [] cachedRowCount = 0 cachedColumnCount = 0 @@ -275,8 +269,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func displayValue(forID id: RowID, column: Int, rawValue: PluginCellValue, columnType: ColumnType?) -> String? { - let key = RowIDKey(id) - if let box = displayCache.object(forKey: key), + if let box = displayCache.box(forID: id), column >= 0, column < box.values.count, let cached = box.values[column] { return cached @@ -286,7 +279,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData let neededCount = max(column + 1, columnDisplayFormats.count, cachedColumnCount) let box: RowDisplayBox - if let existing = displayCache.object(forKey: key) { + if let existing = displayCache.box(forID: id) { box = existing if box.values.count < neededCount { box.values.reserveCapacity(neededCount) @@ -301,28 +294,28 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData if column >= 0, column < box.values.count { box.values[column] = formatted } - displayCache.setObject(box, forKey: key, cost: displayCacheCost(box.values)) + displayCache.setBox(box, forID: id, cost: displayCacheCost(box.values)) return formatted } func invalidateDisplayCache() { - displayCache.removeAllObjects() + displayCache.removeAll() } func invalidateAllDisplayCaches() { - displayCache.removeAllObjects() + displayCache.removeAll() visualIndex.rebuild(from: changeManager, sortedIDs: sortedIDs) } func updateDisplayFormats(_ formats: [ValueDisplayFormat?]) { columnDisplayFormats = formats - displayCache.removeAllObjects() + displayCache.removeAll() } func syncDisplayFormats(_ formats: [ValueDisplayFormat?]) { guard formats != columnDisplayFormats else { return } columnDisplayFormats = formats - displayCache.removeAllObjects() + displayCache.removeAll() } func preWarmDisplayCache(upTo rowCount: Int) { @@ -412,8 +405,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData private func cacheDisplayRow(at displayIndex: Int, in tableRows: TableRows) { guard let row = displayRow(at: displayIndex, in: tableRows) else { return } - let key = RowIDKey(row.id) - guard displayCache.object(forKey: key) == nil else { return } + guard displayCache.box(forID: row.id) == nil else { return } let columnCount = tableRows.columns.count var values = ContiguousArray() @@ -429,7 +421,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData ) ?? row.values[col].asText } let box = RowDisplayBox(values) - displayCache.setObject(box, forKey: key, cost: displayCacheCost(values)) + displayCache.setBox(box, forID: row.id, cost: displayCacheCost(values)) } private func displayCacheCost(_ values: ContiguousArray) -> Int { @@ -442,10 +434,10 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData private func invalidateDisplayCache(forDisplayRow displayIndex: Int, column: Int) { guard let row = displayRow(at: displayIndex) else { return } - let key = RowIDKey(row.id) - guard let box = displayCache.object(forKey: key), column >= 0, column < box.values.count else { return } + guard let box = displayCache.box(forID: row.id), + column >= 0, column < box.values.count else { return } box.values[column] = nil - displayCache.setObject(box, forKey: key, cost: displayCacheCost(box.values)) + displayCache.setBox(box, forID: row.id, cost: displayCacheCost(box.values)) } func applyDelta(_ delta: Delta) { @@ -622,7 +614,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData guard schemaChanged else { return false } identitySchema = nextSchema - displayCache.removeAllObjects() + displayCache.removeAll() return true } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 58b811dfc..95b38f091 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -9,6 +9,10 @@ import TableProPluginKit extension TableViewCoordinator { func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + autoreleasepool { viewForCell(in: tableView, column: tableColumn, row: row) } + } + + private func viewForCell(in tableView: NSTableView, column tableColumn: NSTableColumn?, row: Int) -> NSView? { guard let column = tableColumn else { return nil } let tableRows = tableRowsProvider()