diff --git a/common/changes/@visactor/vtable/test-issue-4111-transpose-frozen-columns_2026-09-23-11-00.json b/common/changes/@visactor/vtable/test-issue-4111-transpose-frozen-columns_2026-09-23-11-00.json new file mode 100644 index 0000000000..d281c25426 --- /dev/null +++ b/common/changes/@visactor/vtable/test-issue-4111-transpose-frozen-columns_2026-09-23-11-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix(vtable): release frozen-column observers when a table is destroyed", + "type": "patch" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} diff --git a/packages/vtable/__tests__/options/listTable-frozen.test.ts b/packages/vtable/__tests__/options/listTable-frozen.test.ts index 47ed49543c..b692f748ba 100644 --- a/packages/vtable/__tests__/options/listTable-frozen.test.ts +++ b/packages/vtable/__tests__/options/listTable-frozen.test.ts @@ -92,3 +92,137 @@ describe('listTable-frozen init test', () => { listTable.release(); }); }); + +describe('listTable frozen columns in transpose mode', () => { + function mockResizeObserver() { + const originalResizeObserver = global.ResizeObserver; + const observer = { + observe: jest.fn(), + disconnect: jest.fn() + }; + let callback: ResizeObserverCallback; + + global.ResizeObserver = jest.fn(function (resizeObserverCallback: ResizeObserverCallback) { + callback = resizeObserverCallback; + return observer; + }); + + return { + callback: () => callback, + observer, + restore: () => { + global.ResizeObserver = originalResizeObserver; + } + }; + } + + function mockTimers() { + const originalSetTimeout = global.setTimeout; + const originalClearTimeout = global.clearTimeout; + const timers = new Map void>(); + let timerId = 0; + + global.setTimeout = jest.fn((callback: () => void) => { + timerId += 1; + timers.set(timerId, callback); + return timerId as unknown as ReturnType; + }); + global.clearTimeout = jest.fn((id: number) => { + timers.delete(id); + }); + + return { + runAll: () => { + for (const [id, callback] of timers) { + timers.delete(id); + callback(); + } + }, + restore: () => { + global.setTimeout = originalSetTimeout; + global.clearTimeout = originalClearTimeout; + } + }; + } + + function createZeroWidthTransposeTable() { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '0px'; + containerDom.style.height = '300px'; + + return { + containerDom, + transposeTable: new ListTable(containerDom, { + columns: [ + { field: 'name', title: 'Name', width: 120 }, + { field: 'value', title: 'Value', width: 120 } + ], + records: Array.from({ length: 10 }, (_, index) => ({ + name: `name-${index}`, + value: `value-${index}` + })), + defaultColWidth: 120, + frozenColCount: 2, + transpose: true + }) + }; + } + + test('keeps configured columns frozen after a zero-width mount and scrolling', () => { + const resizeObserver = mockResizeObserver(); + const { containerDom, transposeTable } = createZeroWidthTransposeTable(); + const timers = mockTimers(); + + try { + expect(resizeObserver.observer.observe).toHaveBeenCalledWith(containerDom); + const resize = jest.spyOn(transposeTable, 'resize'); + containerDom.style.width = '400px'; + resizeObserver.callback()([{ contentRect: { width: 400 } } as ResizeObserverEntry], resizeObserver.observer); + + expect(resize).toHaveBeenCalledTimes(1); + timers.runAll(); + + expect(transposeTable.frozenColCount).toBe(2); + expect(transposeTable.scenegraph.getColGroup(0).parent).toBe(transposeTable.scenegraph.rowHeaderGroup); + expect(transposeTable.scenegraph.getColGroup(1).parent).toBe(transposeTable.scenegraph.rowHeaderGroup); + expect(transposeTable.scenegraph.getColGroup(2).parent).toBe(transposeTable.scenegraph.bodyGroup); + + transposeTable.setScrollLeft(240); + + expect(transposeTable.scrollLeft).toBeGreaterThan(0); + expect(transposeTable.scenegraph.getColGroup(0).parent).toBe(transposeTable.scenegraph.rowHeaderGroup); + expect(transposeTable.scenegraph.getColGroup(1).parent).toBe(transposeTable.scenegraph.rowHeaderGroup); + expect(transposeTable.scenegraph.getColGroup(2).parent).toBe(transposeTable.scenegraph.bodyGroup); + expect(resizeObserver.observer.disconnect).toHaveBeenCalledTimes(1); + } finally { + transposeTable.release(); + resizeObserver.restore(); + timers.restore(); + } + }); + + test('cancels the queued frozen-column check when released', () => { + const resizeObserver = mockResizeObserver(); + const { containerDom, transposeTable } = createZeroWidthTransposeTable(); + const timers = mockTimers(); + + try { + const checkFrozen = jest.spyOn(transposeTable.stateManager, 'checkFrozen'); + containerDom.style.width = '400px'; + resizeObserver.callback()([{ contentRect: { width: 400 } } as ResizeObserverEntry], resizeObserver.observer); + + const checksBeforeRelease = checkFrozen.mock.calls.length; + transposeTable.release(); + timers.runAll(); + + expect(checkFrozen).toHaveBeenCalledTimes(checksBeforeRelease); + } finally { + if (!transposeTable.isReleased) { + transposeTable.release(); + } + resizeObserver.restore(); + timers.restore(); + } + }); +}); diff --git a/packages/vtable/examples/debug/issue-4111-transpose-frozen-observer.ts b/packages/vtable/examples/debug/issue-4111-transpose-frozen-observer.ts new file mode 100644 index 0000000000..214b6ea52f --- /dev/null +++ b/packages/vtable/examples/debug/issue-4111-transpose-frozen-observer.ts @@ -0,0 +1,154 @@ +import * as VTable from '../../src'; + +interface Issue4111Window extends Window { + BUGSERVER_SCREENSHOT?: () => void; + __issue_4111_error__?: ErrorEvent | PromiseRejectionEvent; + __issue_4111_table__?: VTable.ListTable; + __issue_4111_race_table__?: VTable.ListTable; +} + +const CONTAINER_ID = 'vTable'; +const STATUS_ID = 'issue4111Status'; + +const columns = [ + { field: 'name', title: 'Name', width: 120 }, + { field: 'value', title: 'Value', width: 120 } +]; +const records = Array.from({ length: 10 }, (_, index) => ({ + name: `name-${index}`, + value: `value-${index}` +})); + +function createTable(container: HTMLElement) { + return new VTable.ListTable(container, { + columns, + records, + defaultColWidth: 120, + frozenColCount: 2, + transpose: true + }); +} + +export function createTable() { + const container = document.getElementById(CONTAINER_ID); + if (!container) { + throw new Error(`Missing #${CONTAINER_ID} container`); + } + + const issueWindow = window as Issue4111Window; + delete issueWindow.__issue_4111_error__; + + const onError = (event: ErrorEvent | PromiseRejectionEvent) => { + issueWindow.__issue_4111_error__ = event; + }; + window.addEventListener('error', onError); + window.addEventListener('unhandledrejection', onError); + + document.getElementById(STATUS_ID)?.remove(); + const status = document.createElement('div'); + status.id = STATUS_ID; + status.style.cssText = 'margin: 0 0 12px; font: 600 14px/1.5 sans-serif;'; + status.textContent = 'RUNNING'; + container.parentElement?.insertBefore(status, container); + + container.style.width = '0px'; + container.style.height = '320px'; + const tableInstance = createTable(container); + issueWindow.__issue_4111_table__ = tableInstance; + + const raceContainer = document.createElement('div'); + raceContainer.style.cssText = 'position: absolute; width: 0; height: 1px; overflow: hidden;'; + container.parentElement?.appendChild(raceContainer); + const raceTable = createTable(raceContainer); + issueWindow.__issue_4111_race_table__ = raceTable; + + const originalSetTimeout = window.setTimeout; + const originalClearTimeout = window.clearTimeout; + const frozenCheckTimers = new Map, () => void>(); + let captureFrozenCheck = false; + let timerId = 0; + let checkFrozenRanAfterRelease = false; + const restoreTimers = () => { + window.setTimeout = originalSetTimeout; + window.clearTimeout = originalClearTimeout; + }; + window.setTimeout = ((callback: TimerHandler, timeout?: number) => { + if (captureFrozenCheck && timeout === 0) { + const id = (timerId += 1) as unknown as ReturnType; + frozenCheckTimers.set(id, () => { + if (typeof callback === 'function') { + callback(); + } + }); + return id; + } + return originalSetTimeout(callback, timeout); + }) as typeof window.setTimeout; + window.clearTimeout = ((id?: ReturnType) => { + if (id && frozenCheckTimers.delete(id)) { + return; + } + originalClearTimeout(id); + }) as typeof window.clearTimeout; + + const release = tableInstance.release.bind(tableInstance); + tableInstance.release = () => { + window.removeEventListener('error', onError); + window.removeEventListener('unhandledrejection', onError); + restoreTimers(); + if (!raceTable.isReleased) { + raceTable.release(); + } + raceContainer.remove(); + delete issueWindow.__issue_4111_error__; + delete issueWindow.__issue_4111_table__; + delete issueWindow.__issue_4111_race_table__; + document.getElementById(STATUS_ID)?.remove(); + release(); + }; + + requestAnimationFrame(() => { + container.style.width = '640px'; + captureFrozenCheck = true; + raceContainer.style.width = '640px'; + + requestAnimationFrame(() => { + const raceObserverRecovered = !raceTable.stateManager._frozenObserver; + raceTable.stateManager.checkFrozen = () => { + checkFrozenRanAfterRelease = true; + }; + const queuedFrozenCheckBeforeRelease = frozenCheckTimers.size; + raceTable.release(); + const remainingTimerCallbacks = Array.from(frozenCheckTimers.values()); + restoreTimers(); + remainingTimerCallbacks.forEach(callback => callback()); + + requestAnimationFrame(() => { + tableInstance.setScrollLeft(240); + const frozenColumnsRemainFixed = + tableInstance.frozenColCount === 2 && + tableInstance.scenegraph.getColGroup(0).parent === tableInstance.scenegraph.rowHeaderGroup && + tableInstance.scenegraph.getColGroup(1).parent === tableInstance.scenegraph.rowHeaderGroup && + tableInstance.scrollLeft > 0; + const visibleObserverRecovered = !tableInstance.stateManager._frozenObserver; + const pass = + visibleObserverRecovered && + raceObserverRecovered && + queuedFrozenCheckBeforeRelease > 0 && + !checkFrozenRanAfterRelease && + frozenColumnsRemainFixed && + !issueWindow.__issue_4111_error__; + + const observerRecovery = visibleObserverRecovered && raceObserverRecovered ? 'yes' : 'no'; + status.textContent = + `${pass ? 'PASS' : 'FAIL'} | frozen=${tableInstance.frozenColCount} | ` + + `scrollLeft=${tableInstance.scrollLeft} | observer recovery=${observerRecovery} | ` + + `queued check cancelled=${queuedFrozenCheckBeforeRelease > 0 && !checkFrozenRanAfterRelease ? 'yes' : 'no'}`; + status.style.color = pass ? '#237804' : '#a8071a'; + status.style.borderLeft = `4px solid ${pass ? '#52c41a' : '#ff4d4f'}`; + status.style.paddingLeft = '8px'; + issueWindow.BUGSERVER_SCREENSHOT?.(); + }); + }); + }); +} diff --git a/packages/vtable/examples/frozen/issue-4111-transpose-frozen-columns.ts b/packages/vtable/examples/frozen/issue-4111-transpose-frozen-columns.ts new file mode 100644 index 0000000000..8ab920e069 --- /dev/null +++ b/packages/vtable/examples/frozen/issue-4111-transpose-frozen-columns.ts @@ -0,0 +1,39 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; + +function generateRecords(count: number) { + return Array.from({ length: count }, (_, index) => ({ + name: `Person ${index + 1}`, + age: 20 + (index % 30), + gender: index % 2 === 0 ? 'Male' : 'Female', + hobby: ['Basketball', 'Reading', 'Travel', 'Music'][index % 4] + })); +} + +export function createTable() { + const container = document.getElementById(CONTAINER_ID); + if (!container) { + return; + } + + container.style.width = '0px'; + const tableInstance = new VTable.ListTable(container, { + columns: [ + { field: 'name', title: 'Name', width: 120 }, + { field: 'age', title: 'Age', width: 120 }, + { field: 'gender', title: 'Gender', width: 120 }, + { field: 'hobby', title: 'Hobby', width: 120 } + ], + records: generateRecords(100), + defaultColWidth: 120, + frozenColCount: 2, + transpose: true + }); + + window.tableInstance = tableInstance; + + requestAnimationFrame(() => { + container.style.width = ''; + }); +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index a3b679f1e5..a6ad9e99ac 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -94,6 +94,10 @@ export const menus = [ path: 'debug', name: 'issue-4186-nested-field-edit' }, + { + path: 'debug', + name: 'issue-4111-transpose-frozen-observer' + }, { path: 'debug', name: 'issue-5036-master-detail-search' @@ -1227,6 +1231,10 @@ export const menus = [ { menu: 'frozen', children: [ + { + path: 'frozen', + name: 'issue-4111-transpose-frozen-columns' + }, { path: 'frozen', name: 'list-table-bottom-frozen' diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index c43a2b34cc..8f880d7481 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -2734,6 +2734,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { internalProps.handler?.release?.(); // internalProps.scrollable?.release?.(); this.eventManager.release(); + this.stateManager.release(); internalProps.focusControl?.release?.(); internalProps.legends?.forEach(legend => { legend?.release(); diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index f5f9060f8f..b6af718f19 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -205,6 +205,7 @@ export class StateManager { _clearVerticalScrollBar: any; _clearHorizontalScrollBar: any; _frozenObserver?: ResizeObserver; + _frozenObserverCheckTimer?: ReturnType; fastScrolling: boolean = false; @@ -983,6 +984,9 @@ export class StateManager { // 使用 ResizeObserver 监听容器尺寸变化 this._frozenObserver = new ResizeObserver(entries => { + if (this.table.isReleased) { + return; + } for (const entry of entries) { // 检查容器宽度是否变为可见 if (entry.contentRect.width > 0) { @@ -991,8 +995,11 @@ export class StateManager { this.table.resize(); - setTimeout(() => { - this.checkFrozen(); + this._frozenObserverCheckTimer = setTimeout(() => { + this._frozenObserverCheckTimer = undefined; + if (!this.table.isReleased) { + this.checkFrozen(); + } }, 0); return; } @@ -1105,6 +1112,12 @@ export class StateManager { } } + release() { + this.clearFrozenObserver(); + clearTimeout(this._frozenObserverCheckTimer); + this._frozenObserverCheckTimer = undefined; + } + setFrozenCol(col: number) { if (col !== this.frozen.col) { // const oldFrozenCol = this.frozen.col;