Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
134 changes: 134 additions & 0 deletions packages/vtable/__tests__/options/listTable-frozen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, () => void>();
let timerId = 0;

global.setTimeout = jest.fn((callback: () => void) => {
timerId += 1;
timers.set(timerId, callback);
return timerId as unknown as ReturnType<typeof setTimeout>;
});
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();
}
});
});
154 changes: 154 additions & 0 deletions packages/vtable/examples/debug/issue-4111-transpose-frozen-observer.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout>, () => 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<typeof setTimeout>;
frozenCheckTimers.set(id, () => {
if (typeof callback === 'function') {
callback();
}
});
return id;
}
return originalSetTimeout(callback, timeout);
}) as typeof window.setTimeout;
window.clearTimeout = ((id?: ReturnType<typeof setTimeout>) => {
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?.();
});
});
});
}
Original file line number Diff line number Diff line change
@@ -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 = '';
});
}
8 changes: 8 additions & 0 deletions packages/vtable/examples/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions packages/vtable/src/core/BaseTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading