fix(datagrid): resolve a row view's index from the table instead of a stale copy - #2460
Merged
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found while investigating #2449, and independent of the fix that shipped there (#2455).
The bug
Delete one of several rows added in the same session, then double-click a cell: the editor opens on a different row, or on a row past the end where nothing happens. With VoiceOver attached, cells below the deletion read one row's value under another row's number.
Root cause
DataGridRowView.rowIndexandDataGridCellAccessibilityView.roware copies of a display-row index taken once, when AppKit first builds the view intableView(_:rowViewForRow:)/tableView(_:viewFor:row:).NSTableView.insertRows(at:)andremoveRows(at:)move an already-built view to its new slot without asking the delegate for it again, so the copy goes stale the moment a row is inserted or removed above it.reloadData(forRowIndexes:columnIndexes:)does not heal it either; only a fullreloadData()does, andapplyRemovedRowscallsupdateCache()first, soupdateNSViewseesstructureChanged == falseand skips it.Measured, with the stored index beside AppKit's own answer:
Every read of the stale copy then names the wrong row, and there are about thirty of them in
DataGridRowViewalone: the double-click handler, the cell drawing, the selection wash, the whole context menu and its actions, copy-as-INSERT/UPDATE/JSON/CSV, set-NULL/empty/default, and foreign-key navigation.The fix
AppKit already tracks the mapping, so it is asked for it rather than mirrored.
rowIndexbecomes a computed property overNSTableView.row(for:), keeping the assigned value as a seed that answers only for a row view that is in no table, which is the shapeDataGridRowViewCopyTestsbuilds. Every existing read site is correct with no further change, andStructureRowViewWithMenuinherits it.DataGridCellAccessibilityViewgets the same treatment, and publishes its row index throughaccessibilityRowIndexRange()rather than stamping it intosetAccessibilityRowIndexRangeat build time, so a client reading the tree after a row moved is told where the cell is rather than where it was built.Rejected: rewriting the stored indices from
applyInsertedRows/applyRemovedRows. It duplicates bookkeeping AppKit already does, reaches only the available row views, and needs a matching accessibility remount call of its own. Also rejected: forcing a fullreloadData()on every incremental mutation, which throws away the view reuse those calls exist for.The row number, found by review
Codex's review of the first commit caught the half this left behind. The row-number column is the one column that still mounts a real cell view, and
makeRowNumberCellstamps its text, its colour and its accessibility label once. AppKit slides that cell to its new slot without asking the delegate to configure it again, so after removing row 1 the row now at 1 kept reading "3" and announcing "Row 3" while the drawn values beside it had already moved on. Making the data correct without this would have left the number and the values in open disagreement, which is worse than both being wrong together.That cell is now a
DataGridRowNumberCellViewthat owns its own presentation, andapplyInsertedRows/applyRemovedRowsrenumber the viewport after the mutation. Only the viewport is walked, because only the viewport holds a cell.Performance
row(for:)is documented as O(visible rows), and it lands on a draw path. Measured on a 5,000-row table with 28 mounted row views: 26 ns per call, 13.2 ms for 500,000 calls.drawCellsstill hoists it into one local before its per-column loop, next to theonEmphasizedSelectionlocal that is already hoisted there, so a 500-column row resolves it once rather than 500 times.Verification
testPASS, 57 cases, 0 failed, across the 7 suites that own these types (84 across 11 on the first commit).lint0 SwiftLint violations. (The wrapper'sagent docsstep reports one pre-existing stale symbol,CLAUDE.md:216 AXCell, in a file this branch does not touch.)DataGridRowIdentityTests, 6 cases: every mounted row reports its own display row; a removal above a mounted row moves its index down; an insert above it moves it up; a detached row view still answers with its seed; an accessibility cell moved by a removal reports the new row's index and speaks the new row's value; and the row number follows a removal, in its text and in its spoken label.rowIndexandrowreturning the stored copy again, 3 of the 5 then-existing cases fail, and the other 2 are correct in both worlds by design, one of them deliberately so since it pins the detached-view fallback. With the renumbering removed,rowNumberFollowsTheRemovalfails on its own.No UI automation: reproducing this needs an editable connection, three added rows and a delete, which does not run deterministically in
TableProUITests. The unit suite drives the same AppKit calls the coordinator makes.