Compare commits

...
Author SHA1 Message Date
dc ba4ddb06d3 test(editor): cover the resize observer wiring, not just the helper
Build / Build-and-ng-test (pull_request) Successful in 6m28s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m1s
Build / Build-and-test-development (pull_request) Successful in 31m39s
The helper-only spec passes even if the observer goes back to calling
updateSettings() - which is the regression the helper exists to prevent.
Move the observer into refreshGridAfterContainerResize.ts so a spec can
drive a real container resize through it, and assert that the resize
reaches refreshDimensions() without any settings call. Verified by
re-injecting the old wiring: the spec fails with 'Expected spy
updateSettings not to have been called', and passes once it is removed.

Also corrects the helper's claim about what refreshDimensions() measures:
it compares rootElement.getBoundingClientRect() with the last measured
size, and both grids take their height from a viewport-derived calc(), so
a resize that matters is a width or viewport change.
2026-11-01 23:27:41 +00:00
dc 5bd7b5d724 fix(editor): a container resize must not clear an active sort
Build / Build-and-ng-test (pull_request) Successful in 6m37s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 5m53s
The grid's container ResizeObserver (added in c7ba025 to replace the
afterRender window-resize dispatch of #246) refreshed by calling
updateSettingsSortSafe({ height }), and that wrapper's whole reason for
existing is that any updateSettings() while a multi-column sort is active
corrupts formula references into #REF!. It works around that by clearing the
sort, applying the settings and re-applying the sort - so every sidebar
toggle, modal open/close or window resize re-sorted the user's data, and an
open cell editor (anchored to visual coordinates) could end up writing to a
different row than the one being typed into.

refreshGridAfterContainerResize() replaces it with hot.refreshDimensions():
it compares the element's box with the last known size, re-measures the
overlays and redraws only when the size actually moved, touching no settings.
The decision lives in its own module so the spec can pin it - a real
Handsontable instance with an active sort and a formula column asserts that
the resize path calls refreshDimensions() and does not call updateSettings(),
clearSort() or sort(), that the sort config is unchanged, and that no cell
degrades to #REF!.

This is the narrow, independent piece of #246; the wider removal of the
remaining synthetic window resize dispatches (sidebar, viewboxes) is analysed
on the issue and is not part of this change.
2026-11-01 22:25:22 +00:00
3 changed files with 195 additions and 13 deletions

No files matched your search

+8 -13
View File
@@ -108,6 +108,7 @@ import { VaFilterService } from '../services/va-filter.service'
import { UploadFileResponse } from '../models/UploadFile'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { ParseResult } from '../models/ParseResult.interface'
import { observeGridContainerResize } from './utils/refreshGridAfterContainerResize'
@Component({
selector: 'app-editor',
@@ -3801,22 +3802,16 @@ export class EditorComponent
}
/**
* Re-run height+render when the grid container settles.
* Re-measure the grid whenever its container settles. The observer itself
* lives in editor/utils/refreshGridAfterContainerResize.ts so the wiring is
* covered by a spec.
*/
private observeGridResize() {
const el = this.hotInstance?.rootElement?.parentElement
if (!el) return
this.gridResizeObserver?.disconnect()
this.gridResizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
const hot = this.hotInstance
if (!hot || hot.isDestroyed) return
this.updateSettingsSortSafe({ height: this.hotTable.height }, false)
hot.render()
})
})
this.gridResizeObserver.observe(el)
this.gridResizeObserver = observeGridContainerResize(
this.hotInstance,
this.hotInstance?.rootElement?.parentElement
)
}
/**
@@ -0,0 +1,134 @@
import Handsontable from 'handsontable'
import { HyperFormula } from 'hyperformula'
import {
observeGridContainerResize,
refreshGridAfterContainerResize
} from './refreshGridAfterContainerResize'
/**
* The editor refreshes its grid when the container changes size (sidebar
* toggle, modal open/close). That path must not touch grid settings: any
* `updateSettings()` while a multi-column sort is active corrupts formula
* references into `#REF!` (the bug `updateSettingsSortSafe()` works around),
* and its workaround - clear the sort, apply, re-apply - re-sorts the data
* underneath an open cell editor.
*
* This pins that contract against a real Handsontable instance, using the same
* technique as the other editor integration specs (editor.component.ts has no
* TestBed harness).
*/
describe('refreshGridAfterContainerResize', () => {
let container: HTMLDivElement
let hot: Handsontable
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
hot = new Handsontable(container, {
data: [
[3, 10, '=A1*B1'],
[1, 20, '=A2*B2'],
[2, 30, '=A3*B3']
],
columns: [{ data: 0 }, { data: 1 }, { data: 2, readOnly: true }],
formulas: { engine: HyperFormula, licenseKey: 'gpl-v3' },
multiColumnSorting: true,
licenseKey: 'non-commercial-and-evaluation'
})
})
afterEach(() => {
if (hot && !hot.isDestroyed) hot.destroy()
hot = undefined as any
container.remove()
})
it('re-measures without changing settings, so an active sort survives', () => {
const sorting = hot.getPlugin('multiColumnSorting') as any
sorting.sort([{ column: 0, sortOrder: 'asc' }])
const sortBefore = JSON.parse(JSON.stringify(sorting.getSortConfig()))
const updateSettings = spyOn(hot, 'updateSettings').and.callThrough()
const clearSort = spyOn(sorting, 'clearSort').and.callThrough()
const sort = spyOn(sorting, 'sort').and.callThrough()
const refreshDimensions = spyOn(hot, 'refreshDimensions').and.callThrough()
refreshGridAfterContainerResize(hot)
expect(refreshDimensions).toHaveBeenCalledTimes(1)
expect(updateSettings).not.toHaveBeenCalled()
expect(clearSort).not.toHaveBeenCalled()
expect(sort).not.toHaveBeenCalled()
expect(JSON.parse(JSON.stringify(sorting.getSortConfig()))).toEqual(
sortBefore
)
})
it('leaves formula values intact across a resize', () => {
const valuesBefore = hot.getData()
refreshGridAfterContainerResize(hot)
expect(hot.getData()).toEqual(valuesBefore)
const flat = (hot.getData() as any[][]).flat()
expect(flat.some((value) => String(value).includes('#REF'))).toBeFalse()
})
it('ignores a missing or destroyed instance', () => {
expect(() => refreshGridAfterContainerResize(null)).not.toThrow()
expect(() => refreshGridAfterContainerResize(undefined)).not.toThrow()
hot.destroy()
expect(() => refreshGridAfterContainerResize(hot)).not.toThrow()
})
})
/**
* The observer is what makes the helper fire, and it is the wiring - not the
* helper body - that regressed into `updateSettings()` in the first place. A
* helper-only spec passes even if the observer reapplies settings, so this one
* drives a real container resize through the observer and fails on any
* settings call.
*/
describe('observeGridContainerResize', () => {
it('re-measures on a container resize without touching grid settings', async () => {
const container = document.createElement('div')
container.style.width = '500px'
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [
[3, 10, '=A1*B1'],
[1, 20, '=A2*B2']
],
colHeaders: true,
width: '100%',
height: 300,
licenseKey: undefined
})
const settingsSpy = spyOn(hot, 'updateSettings').and.callThrough()
const refreshSpy = spyOn(hot, 'refreshDimensions').and.callThrough()
const observer = observeGridContainerResize(hot, container)
expect(observer).toBeDefined()
container.style.width = '320px'
await new Promise((resolve) => setTimeout(resolve, 400))
expect(refreshSpy).toHaveBeenCalled()
expect(settingsSpy).not.toHaveBeenCalled()
observer?.disconnect()
hot.destroy()
container.remove()
})
it('returns nothing when the grid or container is missing', () => {
const container = document.createElement('div')
expect(observeGridContainerResize(null, container)).toBeUndefined()
expect(observeGridContainerResize(undefined, undefined)).toBeUndefined()
})
})
@@ -0,0 +1,53 @@
import Handsontable from 'handsontable'
/**
* Re-measures a grid after its container changed size (sidebar toggle, modal
* open/close, window resize).
*
* Deliberately does NOT call `updateSettings()`. Any settings call while a
* multi-column sort is active corrupts formula cell references into `#REF!` -
* that is the bug `updateSettingsSortSafe()` exists for - and the workaround
* (clear the sort, apply, re-apply the sort) re-sorts the data underneath an
* open cell editor, so typing can land on a different row than the one being
* edited. A container resize has no business changing settings at all:
* `refreshDimensions()` measures `rootElement.getBoundingClientRect()` and
* compares it with the last measured size, re-running `adjustElementsSize()`
* and a render only when the box actually moved. Both grids take their height
* from a viewport-derived `calc()` setting (editor.component.ts:203), so a
* container resize that matters to the grid is a width change or a viewport
* change - both of which move that box. It also fires
* `beforeRefreshDimensions` / `afterRefreshDimensions` for anything that needs
* to react.
*/
export function refreshGridAfterContainerResize(
hot: Handsontable | null | undefined
): void {
if (!hot || hot.isDestroyed) return
hot.refreshDimensions()
}
/**
* Watches a grid's container and re-measures the grid whenever its box changes
* (sidebar toggle, modal open/close, window resize).
*
* The observer lives here rather than in the component so the wiring is
* covered by a spec: what matters is not that `refreshDimensions()` is called
* but that a resize never reaches `updateSettings()`.
*
* Returns the observer so the caller can disconnect it on destroy.
*/
export function observeGridContainerResize(
hot: Handsontable | null | undefined,
container: HTMLElement | null | undefined
): ResizeObserver | undefined {
if (!hot || !container || typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(() => {
requestAnimationFrame(() => refreshGridAfterContainerResize(hot))
})
observer.observe(container)
return observer
}