Skip to content

fix(datagrid): keep the cell selection Select All builds, and take a mount's observers off with it - #2681

Merged
datlechin merged 1 commit into
mainfrom
fix/2667-grid-selectall-and-observer-leak
Sep 8, 2026
Merged

fix(datagrid): keep the cell selection Select All builds, and take a mount's observers off with it#2681
datlechin merged 1 commit into
mainfrom
fix/2667-grid-selectall-and-observer-leak

Conversation

@datlechin

Copy link
Copy Markdown
Member

Two verified defects found by the collateral hunt during #2667, both in the data grid's mount and selection lifecycle. Independent of each other and of the selection fix in #2679; grouped because they are the same file and the same review.

Select All destroyed the cell selection it had just built

KeyHandlingTableView.selectAll(_:) builds the full-grid cell rectangle and then selects every row. The row write was a bare selectRowIndexes, so it read back as a gesture, and tableViewSelectionDidChange answers a gesture arriving over a live cell selection by clearing it. The rectangle was gone before Cmd+A returned.

What the user saw: Cmd+C took the whole-row path instead of the cell path, Escape had nothing to cancel and fell through to super, and the header's column indicators went dark.

The fix is the helper already in the same file, three functions away: selectRowsIntersectingSelection() does the identical build-then-write and wraps its write in withProgrammaticRowSelection. Cmd+A now does the same. publishRowSelection still runs unconditionally, so the row set still reaches the tab.

Reordering the two writes would also dodge the guard today, but only because GridSelectionController.selectAll happens not to touch selectedRowIndexes. Marking the write says what is meant and matches every other correct call site.

A grid mount left its observers registered

makeNSView registers three NotificationCenter block observers through attachScrollObservers (live-scroll start and end, and the clip view's boundsDidChange), and the coordinator's init registers a fourth for accessibility activation. dismantleNSView removed neither.

releaseData() does remove them, but it only runs on session teardown, and dismantleNSView runs on every editor-tab switch and every result-mode toggle. NotificationCenter retains a block observer's closure and a coordinator is built fresh per mount, so each mount left four registrations that are never fired again and never reclaimed. Switching among ten tabs fifty times leaves about 2,000.

dismantleNSView now calls detachScrollObservers() and a new detachAccessibilityActivationObserver(), extracted from releaseData() so both paths share it. Both are idempotent, so a later releaseData() on the same coordinator is still fine.

This is a registration leak, not a coordinator leak: every one of those closures captures self weakly, so the coordinator itself was always free to deallocate. No deinit was added, because TableViewCoordinator is @MainActor and a nonisolated deinit cannot call these methods; dismantleNSView is the hook that matches the mount.

Tests

SelectAllCellSelectionTests (new) and DataGridMountTeardownTests (new), plus the existing selection and gutter suites: 59 cases, all passing.

The Select All suite carries a guard case of its own, harnessPresentsDataColumns. selectAll falls through to AppKit's own implementation when the grid presents no data columns, and that path selects every row too, so a harness that never reconciled the column pool would have passed the row assertion while never exercising the cell selection at all. That is not hypothetical: the first draft of this suite did exactly that, and the guard is what caught it.

Confirmed as a real regression guard: with the withProgrammaticRowSelection wrapper reverted, selectAllKeepsTheCellSelection and escapeAfterSelectAllClearsTheCellSelection both fail; with it, all five pass.

Verification

  • build PASS
  • test PASS, 59 cases over SelectAllCellSelectionTests, DataGridMountTeardownTests, GridSelectionControllerTests, DataGridSelectionTests, PublishedRowSelectionTests, KeyHandlingTableViewCopyTests, DataGridRowGutterTests, TableViewCoordinatorRowCountCacheTests, FocusedColumnResolutionTests
  • swiftlint --strict clean for this change. Two legacy_swiftui_aspect_ratio errors remain in ImportFromAppSourcePicker.swift and SupportView.swift; both are pre-existing on main and untouched here.

Noted, not fixed

focusCell(row:column:) has the same unmarked-write shape and can drop a multi-cell range while tabbing inside it. applyFindMatch and beginEditing also write unmarked, but there it is arguably intended: jumping to a match or starting an edit plausibly should replace a stale range. Left alone rather than folded in, since neither is this defect.

https://claude.ai/code/session_01STT2h6Y53XJah8xPXAH42L

@datlechin
datlechin merged commit 8210325 into main Sep 8, 2026
8 checks passed
@datlechin
datlechin deleted the fix/2667-grid-selectall-and-observer-leak branch September 8, 2026 18:14
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