fix(editor): clear sort before reading cells to preserve on cancel
Build / Build-and-ng-test (pull_request) Successful in 5m16s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m2s
Build / Build-and-test-development (pull_request) Successful in 24m42s

cancelEdit()'s getFormulaCellsToPreserveOnCancel callbacks passed a
physical rowIndex straight into visual-row-expecting Handsontable APIs
(getCommentAtCell, getDataAtRowProp) while the grid could still be
sorted, since clearSort() only ran later, right before updateSettings().
On a sorted grid this read the wrong row's comment/value, corrupting
which formula cells got preserved across cancel. Move clearSort() (via
getCurrentSortConfigs()) ahead of those callbacks instead.
This commit is contained in:
YuryShkoda
2026-09-03 11:47:24 +03:00
parent 3380150d1c
commit 4137ebdf6b
2 changed files with 115 additions and 16 deletions
+14 -16
View File
@@ -1228,12 +1228,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
* not immediately after their own updateSettings() call the way this * not immediately after their own updateSettings() call the way this
* wrapper restores it. Don't "simplify" those two to use this instead * wrapper restores it. Don't "simplify" those two to use this instead
* without preserving that later restore point. * without preserving that later restore point.
*
* This bug (and the visual/physical row translations elsewhere in this
* file) was verified specifically against handsontable@18.0.0 - that's
* why package.json pins it exactly rather than allowing ^18.0.0 to
* float. Bumping the version should come with re-verifying this class of
* bug still needs a workaround (or no longer does).
*/ */
private updateSettingsSortSafe( private updateSettingsSortSafe(
settings: Handsontable.GridSettings, settings: Handsontable.GridSettings,
@@ -1353,10 +1347,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
const hot = this.hotInstance const hot = this.hotInstance
const columnSorting = hot.getPlugin('multiColumnSorting') const columnSorting = hot.getPlugin('multiColumnSorting')
const columnSortConfig = columnSorting.getSortConfig() const sortConfigs = this.getCurrentSortConfigs()
const sortConfigs = Array.isArray(columnSortConfig)
? columnSortConfig // Cleared here, before anything below reads a row by index - both the
: [columnSortConfig] // getFormulaCellsToPreserveOnCancel callbacks just below (physical
// rowIndex passed straight to visual-row-expecting Handsontable APIs)
// and the later updateSettings() call (see editTable()'s own comment -
// corrupts formula cell references while a sort is active) need the
// grid unsorted. Restored below, after the re-render, from the
// sortConfigs captured here.
if (sortConfigs.length > 0) columnSorting.clearSort()
if (this.dataSourceUnchanged) { if (this.dataSourceUnchanged) {
// dataSourceUnchanged deliberately holds the RAW pre-formula value for // dataSourceUnchanged deliberately holds the RAW pre-formula value for
@@ -1392,12 +1392,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.hotTable.data = this.dataSource this.hotTable.data = this.dataSource
this.hotTable.readOnly = true this.hotTable.readOnly = true
// See editTable()'s own comment - updateSettings() while a sort is // Sort was already cleared above (before the getFormulaCellsToPreserveOnCancel
// still active corrupts formula cell references ('#REF!'), so clear it // callbacks); still cleared here going into updateSettings() - see
// first and restore sortConfigs afterward (below) instead of leaving // editTable()'s own comment: it corrupts formula cell references
// the sort applied across the call. // ('#REF!') while a sort is active. Restored below via sortConfigs.
if (sortConfigs.length > 0) columnSorting.clearSort()
hot.updateSettings( hot.updateSettings(
{ {
readOnly: this.hotTable.readOnly, readOnly: this.hotTable.readOnly,
@@ -4,6 +4,7 @@ import { classifyRow } from './classifyRow'
import { normalizeSortConfig } from './normalizeSortConfig' import { normalizeSortConfig } from './normalizeSortConfig'
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule' import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
import { parseFormulaRule } from '../../shared/dc-validator/utils/parseFormulaRule' import { parseFormulaRule } from '../../shared/dc-validator/utils/parseFormulaRule'
import { getFormulaCellsToPreserveOnCancel } from '../../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
/** /**
* afterChange delivers changes as [visualRow, prop, oldValue, newValue] - * afterChange delivers changes as [visualRow, prop, oldValue, newValue] -
@@ -366,3 +367,103 @@ describe('updateSettings must not run while a sort is active - it corrupts formu
hot.destroy() hot.destroy()
}) })
}) })
/**
* getFormulaCellsToPreserveOnCancel's rowIndex parameter is established as
* PHYSICAL (it iterates 0..dataSource.length, matching dataSource's own
* order - see the file's own docblock). cancelEdit() passes that rowIndex
* straight into commentsPlugin.getCommentAtCell and hot.getDataAtRowProp,
* both of which - same as updateEditStatusForRow/syncOverwrittenCommentForCell
* above - document a VISUAL row. On a sorted grid this both checks and
* reads the wrong row's cell: a row that actually has a comment can go
* unflagged while a different row gets wrongly flagged, carrying that other
* row's live value onto its own prop - corrupting cancelEdit()'s restore
* rather than just skipping it. The fix is to clear the sort before this
* runs at all (cancelEdit() now does this - see its own comment), rather
* than translate physical->visual per call, since dataSourceUnchanged's
* restore right after also needs an unsorted physical dataSource to index
* into.
*/
describe('getFormulaCellsToPreserveOnCancel callbacks resolve the correct cell on a sorted grid', () => {
const formulaBaseCols = ['FORMULA_HARD_COL']
const setup = (): { hot: Handsontable } => {
const dataSource = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'b', FORMULA_HARD_COL: 10 },
{ PRIMARY_KEY_FIELD: 2, SOME_CHAR: 'a', FORMULA_HARD_COL: 20 }
]
const hot = new Handsontable(document.createElement('div'), {
data: dataSource,
columns: [
{ data: 'PRIMARY_KEY_FIELD' },
{ data: 'SOME_CHAR' },
{ data: 'FORMULA_HARD_COL' }
],
multiColumnSorting: true,
comments: true,
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
// Physical row 0 (PK=1) has the comment/live value that must be
// preserved. Ascending by SOME_CHAR flips it to visual row 1; physical
// row 1 (PK=2, no comment) becomes visual row 0.
const commentsPlugin: any = hot.getPlugin('comments')
commentsPlugin.setCommentAtCell(0, 2, 'Original value: 5')
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
sortPlugin.sort({ column: 1, sortOrder: 'asc' })
hot.render()
return { hot }
}
it('demonstrates the failure mode: physical rowIndex passed straight to visual-row APIs resolves the wrong cell', () => {
const { hot } = setup()
const commentsPlugin: any = hot.getPlugin('comments')
const toPreserve = getFormulaCellsToPreserveOnCancel(
2,
formulaBaseCols,
(rowIndex, baseCol) =>
!!commentsPlugin.getCommentAtCell(rowIndex, hot.propToCol(baseCol)),
(rowIndex, prop) => hot.getDataAtRowProp(rowIndex, prop)
)
// Physical row 0's comment lives at visual row 1 post-sort, but the
// buggy call checked physical rowIndex 0 straight as a visual row -
// landing on physical row 1's cell (no comment, value 20) instead.
// rowIndex 1 then wrongly reads AS visual row 1, which is physical row
// 0's actual commented cell (value 10) - so the wrong row (1, not 0)
// gets flagged, carrying the wrong row's value.
expect(toPreserve).toEqual([
{ rowIndex: 1, prop: 'FORMULA_HARD_COL', value: 10 }
])
hot.destroy()
})
it('resolves the correct cell once the sort is cleared before reading', () => {
const { hot } = setup()
const commentsPlugin: any = hot.getPlugin('comments')
const sortPlugin: any = hot.getPlugin('multiColumnSorting')
const sortConfigs = normalizeSortConfig(sortPlugin.getSortConfig())
sortPlugin.clearSort()
const toPreserve = getFormulaCellsToPreserveOnCancel(
2,
formulaBaseCols,
(rowIndex, baseCol) =>
!!commentsPlugin.getCommentAtCell(rowIndex, hot.propToCol(baseCol)),
(rowIndex, prop) => hot.getDataAtRowProp(rowIndex, prop)
)
expect(toPreserve).toEqual([
{ rowIndex: 0, prop: 'FORMULA_HARD_COL', value: 10 }
])
for (const sc of sortConfigs) sortPlugin.sort(sc)
hot.destroy()
})
})