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:
Andrey Epifancev 2026-10-28 21:15:00 +04:00
parent a87cb57728
commit 3bd4b53657
2 changed files with 201 additions and 0 deletions

View 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);

View 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));
});