From 3bd4b5365758e7f2675f6720599d288df23311a1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:15:00 +0400 Subject: [PATCH] =?UTF-8?q?perf(table):=20=D0=BA=D0=BB=D1=8E=D1=87=20?= =?UTF-8?q?=D1=81=D0=B1=D1=80=D0=BE=D1=81=D0=B0=20=D0=BC=D0=B5=D0=BC=D0=BE?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D1=8F=D1=87=D0=B5=D0=B5?= =?UTF-8?q?=D0=BA=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=20=D0=B4=D0=BE=D0=BF?= =?UTF-8?q?=D1=83=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20table-core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ключ из раскладки закреплённых колонок и фильтров (AURORA-1839, design.md D2), pickSettledSizing замораживает ширины, пока колонку тянут. Тесты table-core фиксируют фактическое поведение 8.20.5 (R-2): закрепление пересоздаёт только ячейку закрепляемой колонки, columnSizing ячейки не трогает. Co-Authored-By: Claude Sonnet 5.5 --- .../RealtimeTable/utils/cellMemoReset.js | 26 +++ .../RealtimeTable/utils/cellMemoReset.test.js | 175 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 web/src/components/RealtimeTable/utils/cellMemoReset.js create mode 100644 web/src/components/RealtimeTable/utils/cellMemoReset.test.js diff --git a/web/src/components/RealtimeTable/utils/cellMemoReset.js b/web/src/components/RealtimeTable/utils/cellMemoReset.js new file mode 100644 index 0000000..137b690 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellMemoReset.js @@ -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); diff --git a/web/src/components/RealtimeTable/utils/cellMemoReset.test.js b/web/src/components/RealtimeTable/utils/cellMemoReset.test.js new file mode 100644 index 0000000..764635f --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellMemoReset.test.js @@ -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)); +});