Skip to content

perf(datagrid): draw data cells instead of building a view for each one (#2381) - #2385

Merged
datlechin merged 2 commits into
fix/datagrid-wide-result-windowing-2381from
refactor/datagrid-drawn-cells
Aug 22, 2026
Merged

perf(datagrid): draw data cells instead of building a view for each one (#2381)#2385
datlechin merged 2 commits into
fix/datagrid-wide-result-windowing-2381from
refactor/datagrid-drawn-cells

Conversation

@datlechin

Copy link
Copy Markdown
Member

Stacked on #2382, which fixes the column window's arithmetic. This removes the window instead.

Root cause

NSTableColumn.isHidden costs O(attached columns) per write. AppKit walks every available row view and re-sorts its subviews to rebuild the key view loop, which the profile shows directly:

DataGridView.updateNSView -> applyStructuralUpdate -> DataGridColumnPool.reconcile   67% of the main thread
  -[NSTableColumn setHidden:]                                                        66.9%
    -[NSTableRowData enumerateAvailableRowViewsIncludingOrphanRows:]                  66.9%
      -[NSTableRowData _updateKeyViewLoopForRowView:]                                 27.1%
        -[NSView _setDefaultKeyViewLoop] -> CFSortIndexes -> __CFSimpleMergeSort

The column window used that write as its virtualization primitive, so windowing a wide result was quadratic by construction. Measured in isolation, hiding the columns outside a 30-column band:

columns cost
100 233ms
250 1,831ms
500 8,151ms
1000 34,190ms

The memory went the same way: the relayout debris of those writes is never reclaimed, which is where a 500-column table's 837MB came from. The cell views themselves are 5.8 KB each and the model side is 4.7 MB.

No AppKit knob helps. All measured, all on the same 500-column table: autorecalculatesKeyViewLoop = false 7,744ms, beginUpdates/endUpdates 7,980ms, hiding before any row exists 9,621ms, detaching from the view hierarchy 3,345ms.

The change

Every column stays attached and visible, and the grid pays nothing for the ones off screen by not building a view for them. tableView(_:viewFor:row:) returns nil for every data column, and DataGridRowView draws the cells the viewport touches with the CoreText path that was already inside the cell view.

Keeping the columns is what makes this small. rect(ofColumn:), frameOfCell(atColumn:row:), column(at:) and columnIndexes(in:) all keep answering, so the overlays, the popovers, drag selection and the header need no new geometry model, and AppKit keeps running the header's resize, reorder and tooltips.

Deleted: ColumnWindowResolver, the two spacer columns, DataGridColumnPool's whole windowing half, and DataGridCellView. 2,013 lines out, 450 in.

Measured, in the app

A Debug build in an isolated sandbox, opened on a 500-column SQLite table:

before after
CPU while opening 99 to 100% for about 18s never pegged, settles at once
resident memory 158MB to 2.15GB 158MB to 236MB
live views in the table 12,526 26

And in a standalone harness at the same workload, so the shape is visible:

columns open before open after slide before slide after memory before memory after
100 520ms 3ms 53ms 3.6ms 100MB 25.5MB
500 12,399ms 26ms 57ms 2.8ms 837MB 3.9MB
1000 55,999ms 94ms 64ms 2.7ms 3,211MB 5.0MB
2000 352ms 2.8ms 7.1MB

Scrolling the full width of the 500-column table, frame by frame, leaves at worst 93px of one column gutter unpainted across 132 captured frames.

Two things this had to rebuild

Per-cell accessibility. AppKit synthesised an AXCell per cell view, and there are no cell views now. DataGridRowView vends one NSAccessibilityElement per data column as its accessibility children, with the role, label, value and row and column index ranges the cell views carried, and KeyHandlingTableView answers accessibilityCell(forColumn:row:) and the selected-cells list from those. They are built when something asks and never on the draw path, because building them there cost a formatted value per column per repaint.

Partial reloads. reloadData(forRowIndexes:columnIndexes:) rebuilt a cell view per pair, which is how a mounted cell was refreshed. It reaches nothing now, so the cell cursor and a committed edit never repainted. The three call sites ask the row views to repaint those cells instead.

Verified

  • verify.sh build PASS
  • verify.sh test PASS, 116 cases across the 15 affected suites
  • verify.sh lint TablePro TableProTests PASS, 0 violations
  • Profiled a real double click, posted as CGEvents so the events actually reach the app: the whole path is mouseDown 60 samples, handleCellClick 11, showOverlayEditor 11 across six double clicks, and rebuildAccessibilityCells does not appear. The first editor of a session costs about 40ms more, inside NSTextView.sizeToFit running TextKit 2's NSTextSelectionNavigation class initialiser, which pulls in NSUserDefaults and KVO delivery. That is AppKit's own lazy setup, once per process.

Not verified by me

VoiceOver. The element tree is built to what NSAccessibilityElement.h and NSAccessibilityProtocols.h document, but confirming that VoiceOver announces "cell, row N, column M: value" and navigates by coordinate needs a live VoiceOver session or Accessibility Inspector.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin
datlechin merged commit 62bbc44 into fix/datagrid-wide-result-windowing-2381 Aug 22, 2026
8 checks passed
@datlechin
datlechin deleted the refactor/datagrid-drawn-cells branch August 22, 2026 18:31
datlechin added a commit that referenced this pull request Aug 23, 2026
…w per column (#2381) (#2388)

* fix(datagrid): keep the column window with the viewport on a wide result (#2381)

* fix(datagrid): classify a clicked column by what the result presents

* docs(claude-md): record the data grid column window invariants

* perf(datagrid): draw data cells instead of building a view for each one (#2381) (#2385)

* refactor(datagrid): extract cell geometry, appearance and drawing from the cell view

* perf(datagrid): draw data cells instead of building a view for each one (#2381)

* perf(datagrid): draw the column separators instead of keeping one view per column (#2381)

* fix(datagrid): bound the rows a repaint reloads and pin the separator geometry (#2381)

* docs(data-grid): move the cell editor shortcuts to the page that owns them (#2381)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant