Skip to content

Commit 9eca620

Browse files
committed
fix(datagrid): undo replay paths now mark affected rows as changed
Phase B regression: undoRowDeletion / undoRowInsertion / undoBatchRowInsertion and the replay helpers (reapplyRowDeletion, reapplyCellChange, revertUpdateCell, updateInsertedCellDirectly, reinsertRow, reinsertBatch) lost the changedRowIndices.insert that the original DataChangeManager called at the end of every undo path. Without it, consumeChangedRowIndices returned empty after undo. The data grid's reloadAndSyncSelection then fell through to the !hasChanges fallback, doing a full reloadData over all rows. With 1000 rows this was 33ms per undo. With larger datasets it gets worse. Trace before this fix (cellEdit undo to clean state): applyDataUndo END mutate=0.08ms callback=0.06ms total=0.19ms reloadAndSync VERSION_CHANGED no changes -> full reload reloadAndSync total=33.2ms updateNSView reloadVersion=8 elapsed=33.5ms After: changedRows is non-empty, partial reload of just the affected row fires. Six new regression tests cover each replay method.
1 parent 86dfe47 commit 9eca620

2 files changed

Lines changed: 75 additions & 0 deletions

File tree

TablePro/Core/ChangeTracking/PendingChanges.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ struct PendingChanges: Equatable {
123123
guard deletedRowIndices.contains(rowIndex) else { return false }
124124
removeChange(rowIndex: rowIndex, type: .delete)
125125
deletedRowIndices.remove(rowIndex)
126+
changedRowIndices.insert(rowIndex)
126127
return true
127128
}
128129

@@ -134,6 +135,7 @@ struct PendingChanges: Equatable {
134135
insertedRowData.removeValue(forKey: rowIndex)
135136

136137
shiftRowIndicesDown(at: rowIndex)
138+
changedRowIndices.insert(rowIndex)
137139
return true
138140
}
139141

@@ -157,6 +159,7 @@ struct PendingChanges: Equatable {
157159
removeChange(rowIndex: rowIndex, type: .insert)
158160
insertedRowIndices.remove(rowIndex)
159161
insertedRowData.removeValue(forKey: rowIndex)
162+
changedRowIndices.insert(rowIndex)
160163
}
161164

162165
let sortedRemoved = validRows.sorted()
@@ -185,6 +188,7 @@ struct PendingChanges: Equatable {
185188
modifiedCells.removeValue(forKey: rowIndex)
186189
appendChange(RowChange(rowIndex: rowIndex, type: .delete, originalRow: originalRow))
187190
deletedRowIndices.insert(rowIndex)
191+
changedRowIndices.insert(rowIndex)
188192
}
189193

190194
/// Re-apply a cell edit during undo replay (skips undo registration).
@@ -206,6 +210,7 @@ struct PendingChanges: Equatable {
206210
if let insertIdx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] {
207211
updateInsertedCell(at: insertIdx, columnIndex: columnIndex,
208212
columnName: columnName, newValue: newValue)
213+
changedRowIndices.insert(rowIndex)
209214
return
210215
}
211216

@@ -221,6 +226,7 @@ struct PendingChanges: Equatable {
221226
changeIndex[updateKey] = changes.count - 1
222227
modifiedCells[rowIndex, default: []].insert(columnIndex)
223228
}
229+
changedRowIndices.insert(rowIndex)
224230
}
225231

226232
/// Replace an inserted row's cell value during undo replay (no shift, no undo).
@@ -232,6 +238,7 @@ struct PendingChanges: Equatable {
232238
) {
233239
guard let insertIdx = changeIndex[RowChangeKey(rowIndex: rowIndex, type: .insert)] else { return }
234240
updateInsertedCell(at: insertIdx, columnIndex: columnIndex, columnName: columnName, newValue: newValue)
241+
changedRowIndices.insert(rowIndex)
235242
}
236243

237244
/// Restore a cell's value during undo replay when an existing change matches.
@@ -264,6 +271,7 @@ struct PendingChanges: Equatable {
264271
newValue: previousValue
265272
)
266273
}
274+
changedRowIndices.insert(rowIndex)
267275
}
268276

269277
/// Insert a synthetic .insert RowChange for undo replay (e.g., after redoing a deletion's undo).
@@ -280,6 +288,7 @@ struct PendingChanges: Equatable {
280288
if let savedValues {
281289
insertedRowData[rowIndex] = savedValues
282290
}
291+
changedRowIndices.insert(rowIndex)
283292
}
284293

285294
/// Insert a batch of rows (for undo replay of a batch deletion's undo).
@@ -302,6 +311,7 @@ struct PendingChanges: Equatable {
302311
changes.append(RowChange(rowIndex: rowIndex, type: .insert, cellChanges: cellChanges))
303312
insertedRowIndices.insert(rowIndex)
304313
insertedRowData[rowIndex] = values
314+
changedRowIndices.insert(rowIndex)
305315
}
306316
rebuildChangeIndex()
307317
}

TableProTests/Core/ChangeTracking/PendingChangesTests.swift

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,71 @@ struct PendingChangesSnapshotTests {
207207
}
208208
}
209209

210+
@Suite("PendingChanges - changedRowIndices tracking")
211+
struct PendingChangesChangedRowIndicesTests {
212+
@Test("revertUpdateCell records the row as changed")
213+
func revertUpdateCellMarksChanged() {
214+
var pending = PendingChanges()
215+
pending.recordCellChange(
216+
rowIndex: 4, columnIndex: 1, columnName: "name",
217+
oldValue: "A", newValue: "B"
218+
)
219+
_ = pending.consumeChangedRowIndices()
220+
221+
pending.revertUpdateCell(
222+
rowIndex: 4, columnIndex: 1, columnName: "name", previousValue: "A"
223+
)
224+
#expect(pending.consumeChangedRowIndices().contains(4))
225+
}
226+
227+
@Test("undoRowDeletion records the row as changed")
228+
func undoRowDeletionMarksChanged() {
229+
var pending = PendingChanges()
230+
pending.recordRowDeletion(rowIndex: 7, originalRow: ["a"])
231+
_ = pending.consumeChangedRowIndices()
232+
233+
_ = pending.undoRowDeletion(rowIndex: 7)
234+
#expect(pending.consumeChangedRowIndices().contains(7))
235+
}
236+
237+
@Test("undoRowInsertion records the row as changed")
238+
func undoRowInsertionMarksChanged() {
239+
var pending = PendingChanges()
240+
pending.recordRowInsertion(rowIndex: 2, values: ["x"])
241+
_ = pending.consumeChangedRowIndices()
242+
243+
_ = pending.undoRowInsertion(rowIndex: 2)
244+
#expect(pending.consumeChangedRowIndices().contains(2))
245+
}
246+
247+
@Test("reapplyRowDeletion records the row as changed")
248+
func reapplyRowDeletionMarksChanged() {
249+
var pending = PendingChanges()
250+
_ = pending.consumeChangedRowIndices()
251+
pending.reapplyRowDeletion(rowIndex: 3, originalRow: ["a"])
252+
#expect(pending.consumeChangedRowIndices().contains(3))
253+
}
254+
255+
@Test("reapplyCellChange records the row as changed")
256+
func reapplyCellChangeMarksChanged() {
257+
var pending = PendingChanges()
258+
_ = pending.consumeChangedRowIndices()
259+
pending.reapplyCellChange(
260+
rowIndex: 5, columnIndex: 1, columnName: "name",
261+
newValue: "X", originalRow: nil
262+
)
263+
#expect(pending.consumeChangedRowIndices().contains(5))
264+
}
265+
266+
@Test("reinsertRow records the row as changed")
267+
func reinsertRowMarksChanged() {
268+
var pending = PendingChanges()
269+
_ = pending.consumeChangedRowIndices()
270+
pending.reinsertRow(rowIndex: 1, columns: ["a"], savedValues: ["v"])
271+
#expect(pending.consumeChangedRowIndices().contains(1))
272+
}
273+
}
274+
210275
@Suite("PendingChanges - clear and consume")
211276
struct PendingChangesLifecycleTests {
212277
@Test("Clear empties all internal state")

0 commit comments

Comments
 (0)