perf(table): ключ сброса мемоизации ячеек и тест допущения table-core
Ключ из раскладки закреплённых колонок и фильтров (AURORA-1839, design.md D2), pickSettledSizing замораживает ширины, пока колонку тянут. Тесты table-core фиксируют фактическое поведение 8.20.5 (R-2): закрепление пересоздаёт только ячейку закрепляемой колонки, columnSizing ячейки не трогает. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a87cb57728
commit
3bd4b53657
26
web/src/components/RealtimeTable/utils/cellMemoReset.js
Normal file
26
web/src/components/RealtimeTable/utils/cellMemoReset.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// Ключ явного сброса мемоизации ячеек MRT (memoMode: 'cells') в таблице листа.
|
||||||
|
// Обоснование и контракт: openspec/changes/aurora-1839-cell-memo/ (design.md D1, D2; specs/sheet-table).
|
||||||
|
// Новое состояние, которое меняет определения колонок или стили закрепления, добавляется сюда и в cellMemoReset.test.js.
|
||||||
|
|
||||||
|
const getPinnedSide = (ids, columnVisibility, columnSizing) =>
|
||||||
|
(ids ?? []).filter((id) => columnVisibility?.[id] !== false).map((id) => [id, columnSizing?.[id] ?? null]);
|
||||||
|
|
||||||
|
// left закреплённой колонки — сумма ширин закреплённых левее, поэтому в ключ входят только
|
||||||
|
// видимые закреплённые колонки и их ширины. Незакреплённые колонки меняют ширину через CSS-переменные MRT.
|
||||||
|
export const getPinnedLayoutKey = ({ columnPinning, columnVisibility, columnSizing }) =>
|
||||||
|
JSON.stringify([
|
||||||
|
getPinnedSide(columnPinning?.left, columnVisibility, columnSizing),
|
||||||
|
getPinnedSide(columnPinning?.right, columnVisibility, columnSizing),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Порядок фильтров в массиве не важен: пары сортируются по id. Значение диапазона — массив [min, max].
|
||||||
|
export const getFilterResetKey = ({ globalFilter, columnFilters }) => {
|
||||||
|
const filters = (columnFilters ?? []).map(({ id, value }) => [String(id), value ?? null]).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
return JSON.stringify([globalFilter ?? '', filters]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCellMemoResetKey = (state) => JSON.stringify([getPinnedLayoutKey(state), getFilterResetKey(state)]);
|
||||||
|
|
||||||
|
// Пока ширину тянут, ключ раскладки строится по ширинам на момент нажатия мыши:
|
||||||
|
// сброс по ширине закреплённой колонки происходит один раз, по отпусканию (design.md, решение 3).
|
||||||
|
export const pickSettledSizing = ({ previous, current, isResizing }) => (isResizing ? previous : current);
|
||||||
175
web/src/components/RealtimeTable/utils/cellMemoReset.test.js
Normal file
175
web/src/components/RealtimeTable/utils/cellMemoReset.test.js
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createTable, getCoreRowModel } from '@tanstack/table-core';
|
||||||
|
import { getCellMemoResetKey, getPinnedLayoutKey, pickSettledSizing } from './cellMemoReset.js';
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
columnPinning: { left: ['mrt-row-expand', 'sort_order'], right: [] },
|
||||||
|
columnVisibility: {},
|
||||||
|
columnSizing: {},
|
||||||
|
globalFilter: '',
|
||||||
|
columnFilters: [],
|
||||||
|
};
|
||||||
|
const key = (patch = {}) => getCellMemoResetKey({ ...base, ...patch });
|
||||||
|
|
||||||
|
test('закрепление колонки слева и справа меняет ключ', () => {
|
||||||
|
const k0 = key();
|
||||||
|
assert.notEqual(key({ columnPinning: { left: [...base.columnPinning.left, 'data.q1'], right: [] } }), k0);
|
||||||
|
assert.notEqual(key({ columnPinning: { left: base.columnPinning.left, right: ['data.q4'] } }), k0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('открепление колонки меняет ключ', () => {
|
||||||
|
const pinned = { left: ['mrt-row-expand', 'sort_order', 'data.q1'], right: [] };
|
||||||
|
assert.notEqual(key({ columnPinning: pinned }), key({ columnPinning: base.columnPinning }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('скрытие и показ закреплённой колонки меняют ключ', () => {
|
||||||
|
const visible = key({ columnVisibility: { sort_order: true } });
|
||||||
|
const hidden = key({ columnVisibility: { sort_order: false } });
|
||||||
|
assert.notEqual(hidden, visible);
|
||||||
|
assert.equal(key({ columnVisibility: { sort_order: true } }), visible);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ширина закреплённой колонки меняет ключ', () => {
|
||||||
|
assert.notEqual(key({ columnSizing: { sort_order: 120 } }), key({ columnSizing: { sort_order: 160 } }));
|
||||||
|
assert.notEqual(key({ columnSizing: {} }), key({ columnSizing: { sort_order: 160 } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ширина, скрытие и показ незакреплённой колонки не меняют ключ', () => {
|
||||||
|
const k0 = key();
|
||||||
|
assert.equal(key({ columnSizing: { 'data.q1': 300 } }), k0);
|
||||||
|
assert.equal(key({ columnVisibility: { 'data.q1': false } }), k0);
|
||||||
|
assert.equal(key({ columnVisibility: { 'data.q1': true } }), k0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('id без записи в columnVisibility считается видимой колонкой', () => {
|
||||||
|
assert.equal(key({ columnVisibility: {} }), key({ columnVisibility: { sort_order: true, 'mrt-row-expand': true } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('данные не входят в ключ', () => {
|
||||||
|
assert.equal(key({ data: [{ id: 1 }] }), key({ data: [{ id: 2 }] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('globalFilter: "" -> "abc" -> "abd" -> ""', () => {
|
||||||
|
const [k0, k1, k2, k3] = ['', 'abc', 'abd', ''].map((globalFilter) => key({ globalFilter }));
|
||||||
|
assert.notEqual(k1, k0);
|
||||||
|
assert.notEqual(k2, k1);
|
||||||
|
assert.equal(k3, k0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('globalFilter null и undefined равны пустой строке', () => {
|
||||||
|
assert.equal(key({ globalFilter: null }), key());
|
||||||
|
assert.equal(key({ globalFilter: undefined }), key());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('фильтр колонки: значение, диапазон [min, max] и очистка меняют ключ', () => {
|
||||||
|
const none = key();
|
||||||
|
const keys = [
|
||||||
|
none,
|
||||||
|
key({ columnFilters: [{ id: 'name', value: 'ab' }] }),
|
||||||
|
key({ columnFilters: [{ id: 'name', value: 'abc' }] }),
|
||||||
|
key({ columnFilters: [{ id: 'data.q1', value: ['1', ''] }] }),
|
||||||
|
key({ columnFilters: [{ id: 'data.q1', value: ['1', '5'] }] }),
|
||||||
|
];
|
||||||
|
assert.equal(new Set(keys).size, keys.length);
|
||||||
|
assert.equal(key({ columnFilters: [] }), none);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('новый массив columnFilters с теми же значениями в другом порядке не меняет ключ', () => {
|
||||||
|
const a = { id: 'name', value: 'ab' };
|
||||||
|
const b = { id: 'data.q1', value: ['1', '5'] };
|
||||||
|
assert.equal(key({ columnFilters: [a, b] }), key({ columnFilters: [{ ...b }, { ...a }] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('неполный columnPinning из localStorage не ломает ключ', () => {
|
||||||
|
const layout = { columnVisibility: undefined, columnSizing: undefined };
|
||||||
|
assert.equal(
|
||||||
|
getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'] } }),
|
||||||
|
getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'], right: [] } }),
|
||||||
|
);
|
||||||
|
assert.doesNotThrow(() => getPinnedLayoutKey({ ...layout, columnPinning: undefined }));
|
||||||
|
assert.doesNotThrow(() => getCellMemoResetKey({}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('пока ширину тянут, ключ прежний; после отпускания — по новым ширинам', () => {
|
||||||
|
const s0 = { sort_order: 120 };
|
||||||
|
const s1 = { sort_order: 150 };
|
||||||
|
const s2 = { sort_order: 180 };
|
||||||
|
const settled = pickSettledSizing({ previous: s0, current: s0, isResizing: false });
|
||||||
|
const k0 = key({ columnSizing: settled });
|
||||||
|
|
||||||
|
const during = pickSettledSizing({ previous: settled, current: s1, isResizing: true });
|
||||||
|
assert.equal(during, s0);
|
||||||
|
assert.equal(key({ columnSizing: during }), k0);
|
||||||
|
|
||||||
|
const after = pickSettledSizing({ previous: during, current: s2, isResizing: false });
|
||||||
|
assert.equal(after, s2);
|
||||||
|
assert.notEqual(key({ columnSizing: after }), k0);
|
||||||
|
assert.equal(key({ columnSizing: after }), key({ columnSizing: s2 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Допущение @tanstack/table-core 8.20.5, на котором стоит явный сброс
|
||||||
|
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст», п. 3; D1).
|
||||||
|
// Первые два теста держат допущение: ячейки пересоздаются при смене массива columns и не пересоздаются при смене ширин.
|
||||||
|
const createFixture = () =>
|
||||||
|
createTable({
|
||||||
|
data: [{ a: 1, b: 2, c: 3 }],
|
||||||
|
columns: [
|
||||||
|
{ accessorKey: 'a', size: 50 },
|
||||||
|
{ accessorKey: 'b', size: 100 },
|
||||||
|
{ accessorKey: 'c', size: 150 },
|
||||||
|
],
|
||||||
|
state: { columnPinning: { left: [], right: [] }, columnSizing: {}, columnOrder: [], columnVisibility: {} },
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
const updateState = (table, state) => table.setOptions((options) => ({ ...options, state: { ...options.state, ...state } }));
|
||||||
|
const cellsById = (table) =>
|
||||||
|
new Map(
|
||||||
|
table
|
||||||
|
.getRowModel()
|
||||||
|
.rows[0].getVisibleCells()
|
||||||
|
.map((cell) => [cell.id, cell]),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('table-core: при прежних columns и data объекты ячеек те же', () => {
|
||||||
|
const table = createFixture();
|
||||||
|
const before = cellsById(table);
|
||||||
|
updateState(table, { columnSizing: { a: 80 } });
|
||||||
|
for (const [id, cell] of cellsById(table)) assert.equal(cell, before.get(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('table-core: новый массив columns с теми же определениями даёт новые ячейки', () => {
|
||||||
|
const table = createFixture();
|
||||||
|
const before = cellsById(table);
|
||||||
|
table.setOptions((options) => ({ ...options, columns: [...options.columns] }));
|
||||||
|
const after = cellsById(table);
|
||||||
|
assert.equal(after.size, before.size);
|
||||||
|
for (const [id, cell] of after) assert.notEqual(cell, before.get(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Допущение дизайна «ячейки пересоздаются только при смене columns/data» неверно для columnPinning:
|
||||||
|
// в table-core 8.20.5 getLeftVisibleCells/getRightVisibleCells (features/ColumnPinning.js) возвращают
|
||||||
|
// поверхностные копии `{...cell, position}`, поэтому при смене закрепления объект ячейки закрепляемой колонки
|
||||||
|
// новый при прежнем массиве columns. Ячейки остальных колонок (центр) остаются теми же объектами.
|
||||||
|
// Ключ сброса по закреплению остаётся как защита: он нужен для ячеек, которые table-core не пересоздаёт,
|
||||||
|
// и сработает как страховка, если поведение сменится. Тест фиксирует факт и упадёт при обновлении table-core.
|
||||||
|
test('table-core: смена columnPinning при прежнем массиве columns пересоздаёт ячейку закреплённой колонки, остальные те же', () => {
|
||||||
|
const table = createFixture();
|
||||||
|
const before = cellsById(table);
|
||||||
|
updateState(table, { columnPinning: { left: ['c'], right: [] } });
|
||||||
|
const after = cellsById(table);
|
||||||
|
assert.equal(after.size, before.size);
|
||||||
|
assert.notEqual(after.get('0_c'), before.get('0_c'));
|
||||||
|
for (const id of ['0_a', '0_b']) assert.equal(after.get(id), before.get(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Характеристика, а не ожидание дизайна: фиксирует поведение table-core 8.20.5 при смене columnSizing,
|
||||||
|
// чтобы обновление библиотеки показало смену поведения.
|
||||||
|
test('table-core: смена columnSizing при прежнем массиве columns оставляет те же ячейки', () => {
|
||||||
|
const table = createFixture();
|
||||||
|
const before = cellsById(table);
|
||||||
|
updateState(table, { columnSizing: { b: 120, c: 20 } });
|
||||||
|
const after = cellsById(table);
|
||||||
|
assert.equal(after.size, before.size);
|
||||||
|
for (const [id, cell] of after) assert.equal(cell, before.get(id));
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user