66 KiB
Design: мемоизация ячеек таблицы листа (AURORA-1839, Gitea #234)
Мотивация и состав изменения описаны в proposal.md, требования — в specs/sheet-table/spec.md. Здесь описано, как это сделать и почему именно так.
Контекст
Что уже есть в базе (ветка AURORA-1837 поверх AURORA-1836)
- Высота строки фиксирована в rem.
TABLE_ROW_HEIGHT_REM = 3(constants/tableConfig.js:7-9).htmlfont-size .83vw, поэтому rem зависит от ширины окна. Виртуализатор строк считает высоту функцией отwindow.innerWidthи не читает layout (utils/virtualizerOptions.js:10-27,RealtimeTable.jsx:39-43).
- Статичные стили ячеек вынесены в CSS контейнера
TABLE_CONTAINER_SX(constants/tableConfig.js:45-70):- z-index закреплённых по
td[data-pinned="true"]; - затемнение выделенной строки по
tr[data-selected="true"].
- z-index закреплённых по
- Подсветка текущего совпадения поиска — правило контейнера
getActiveCellSx(activeSearchMatch)поtr[data-row-id]иtd[data-col-id](utils/activeCellCss.js:43-51,RealtimeTable.jsx:406-409). Атрибуты расставляют:data-row-id—muiTableBodyRowProps(RealtimeTable.jsx:362-367);data-col-id—muiTableBodyCellProps(:389).
- Overscan: строк 2 (
RealtimeTable.jsx:39-43), колонок 3 (utils/columnLayoutUtils.js:14). memoModeне задан (RealtimeTable.jsx:325-414).
Как устроена мемоизация в material-react-table 3.2.1
Версия зафиксирована в web/package-lock.json. Ссылки ниже даны на web/node_modules/material-react-table/dist/index.esm.js.
-
Какие ячейки мемоизируются.
MRT_TableBodyRowрендеритMemo_MRT_TableBodyCellтолько при всех условиях сразу (:2439-2444):memoMode === 'cells';- колонка типа
data; - нет перетаскивания колонки или строки;
- ячейка не редактируется.
Компаратор — только
prev.cell === next.cell(:2312). Служебная колонка раскрытия строк (mrt-row-expand, типdisplay) не мемоизируется никогда. -
Стили закрепления считаются внутри ячейки.
getCommonMRTCellStyles(:843-885) задаётposition: stickyиleft: column.getStart('left'), атрибутdata-pinnedставит сама ячейка (:2271). Ширина ячейки задана через CSS-переменную--col-<id>-size, сами переменные выставлены на<table>(:3764-3780). Поэтому ширина подхватывается мемоизированной ячейкой без перерисовки, аleft— нет. -
Объекты ячеек пересоздаются на каждый рендер владельца таблицы.
useMRT_TableInstance(:1693-1725) на каждом рендере компонента, вызвавшегоuseMaterialReactTable(у нас этоRealtimeTable), строитcolumnsзаново черезprepareColumns, а этоcolumnDefs.map(...)— новый массив. Дальше по цепочке:- в
@tanstack/table-core8.20.5getAllColumnsмемоизирован поoptions.columns(build/lib/index.mjs:2879); row.getAllCellsмемоизирован поgetAllLeafColumns()(:554);- значит, любой рендер
RealtimeTableдаёт новые объектыcell, и все видимые ячейки перерисовываются.
Исключение: пока
columnSizingInfo.isResizingColumn,draggingColumnилиdraggingRowистинны, MRT подставляет прежний массив изcolumnDefsRefи колонки не пересоздаёт (:1695-1700). Проверено node-скриптом наtable-core:- новый массив с теми же определениями даёт новые ячейки;
- прежний массив — те же ячейки;
- смена
columnPinningпри прежнем массиве — новая ячейка только у закрепляемой или открепляемой колонки, ячейки остальных колонок те же. Причина:getLeftVisibleCells/getRightVisibleCells(ColumnPinning.js:86-98) возвращают копии{...cell, position}, а центральные ячейки берутся из тех же объектовallCells. Допущение черновика «ячейки пересоздаются только при сменеcolumns/data» верно для ширины и видимости, но неверно для закрепляемой колонки (O-1, решение 18); - смена
columnSizingпри прежнем массиве — те же ячейки (тест-характеристика).
- в
-
Пока
isResizingColumnистинно, тело таблицы заморожено.MRT_TableрендеритMemo_MRT_TableBody(:3780), а тот сравнивает толькоtable.options.data(:2526). Так MRT ведёт себя при собственном перетаскивании ширины. -
Прокрутка перерисовывает только внутренние компоненты MRT. Виртуализаторы созданы в
MRT_TableBody(строки) иMRT_Table(колонки),@tanstack/react-virtualперерисовывает свой компонент черезuseReducer.RealtimeTableпри прокрутке не рендерится: в нём нет состояния, меняющегося поscroll.ColumnSelectionOverlayдержит своё состояние,useHeaderPortalсостояния не держит.
Следствие для этой задачи.
- При прокрутке объекты
cellстабильны, иmemoMode: 'cells'убирает перерисовку оставшихся на экране ячеек. Это ровно то, что меряет критерий. - Любой рендер
RealtimeTableв MRT 3.2.1 сбрасывает мемоизацию сам, побочным эффектомprepareColumns. - Явный сброс по решению человека нужен как гарантия. Он не зависит от этого побочного эффекта, который исчезнет, если MRT начнёт мемоизировать
prepareColumns. Кроме того, он работает в окне перетаскивания ширины, где MRT намеренно замораживает колонки.
Как устроены ширина и фильтры в таблице листа
- Ширину колонки тянут не через MRT. Используется собственный
ColumnResizerв собственном заголовке (TableHead/ColumnResizer.jsx:3-59,TableHead/TableHead.jsx:122-126,:177) →handleSetColumnWidth(hooks/useColumnSettings.js:51-61) →setColumnSizingна каждое движение мыши. Обработчики ширины MRT не используются, поэтомуcolumnSizingInfo.isResizingColumnсейчас не выставляется никогда. Сейчас каждое движение мыши — это рендерRealtimeTable, новые ячейки и перерисовка всего видимого тела. columnFilters— неуправляемое состояние TanStack. Значение ставитcolumn.setFilterValueвTableHead.jsx:218,:256,:267.RealtimeTableчитает его черезtable.getState().columnFilters(:812), то есть уже после создания таблицы, а массив колонок нужен раньше.globalFilter— управляемое состояниеRealtimeTable(:89), с debounce 300 мс (:125-133).- Ячейка читает фильтры в момент рендера.
getCachedCellPropsберётglobalFilterиcolumnFiltersизtable.getState()(tableColumns.jsx:139-183), иCellстроит по ним<mark>(Cell/Cell/Cell.jsx:165-168).
Что уже соответствует контракту
- Блокировки соавторов — хранилище с подпиской:
useCellLock→useSyncExternalStore(contexts/RealtimeContext.js:72-78,Cell.jsx:142). - Список ВСП — контекст (
tableColumns.jsx:108-111). - Выделение строки —
data-selectedна строке. Строки не мемоизируются, оформление задано CSS контейнера. - Текущее совпадение поиска — CSS контейнера (AURORA-1837).
- Выбранная колонка — оверлей и
meta.getSelectedColumnIdчерезref(RealtimeTable.jsx:297-306). onCellNumberClickстабилен (useCallback([]),:224-226).- Ошибки валидации:
isCellInvalidменяется вместе сdataи уже стоит в зависимостяхcolumns(:255-273). - Разрядность, этап и роль:
denomination,columnsCurStage,userRoleIdтоже в зависимостяхcolumns. - Редактируемую ячейку MRT не мемоизирует.
Цели / Не цели
Цели
memoMode: 'cells'в таблице листа с явным сбросом, не зависящим от внутренностей MRT.- Дешёвое перетаскивание ширины: тело таблицы во время перетаскивания не перерисовывается.
- Контракт для будущих правок, тесты и сценарий замера.
Не цели
- Мемоизация строк (
memoMode: 'rows') и тела таблицы. - Своды 1 и 2.
- Цена рендера при входящей правке по WebSocket (AURORA-1838).
- Правки
Cell.jsx. - lint-правило для контракта: решение человека, достаточно документа и теста.
Решения (согласовано 28.10)
-
memoMode: 'cells'вRealtimeTable. Задаётся вcreateTableConfig(RealtimeTable.jsx:344-345) рядом с...BASE_TABLE_CONFIG.BASE_TABLE_CONFIGиспользует толькоRealtimeTable, но опция относится к поведению этого компонента, поэтому стоит рядом с остальными опциями рендера. -
Явный сброс мемоизации пересозданием
columns. Срабатывает:- при изменении раскладки закреплённых колонок: закрепление, открепление, скрытие закреплённой, показ закреплённой, ширина закреплённой;
- при изменении
globalFilterиcolumnFilters.
Сброс явный и не полагается на побочный эффект
filterFromLeafRows. От побочного эффектаprepareColumnsв MRT 3.2.1 он тоже не зависит. -
Ширина закреплённой колонки сбрасывает мемоизацию один раз, по отпусканию мыши (
columnSizingInfo.isResizingColumn === false). Пока колонку тянут, соседние закреплённые колонки могут отставать. Ширина незакреплённых колонок меняется через CSS-переменные MRT, без сброса. -
Скрытие и показ незакреплённой колонки — без сброса.
-
Инвариант для всех будущих правок таблицы. Записан требованием в spec и разделом «Контракт» ниже. Задачи AURORA-1889, 1890, 1891, 1893, 1910 делаем сами. Достаточно документа и небольшого теста на сброс, lint-правило не нужно.
-
Критерий:
- шаг прокрутки FORM_2/AHR ≤ 25 мс (стенд, масштаб 1, CPU 1×);
- сценарий «закрепить колонку, потянуть ширину закреплённой — время кадра». В
perf/e2eего нет, что добавить — описано ниже.
-
Связь с AURORA-1838 не блокирующая. В Jira она заменена на «связанные».
-
Пороги производительности не являются условием слияния.
- PR вливается по зелёным тестам и ручному регрессу. Цифры замеров приводятся в PR.
- Недобор до порога (например, шаг > 25 мс) — это follow-up issue с разбором, что съедает остаток, а не блокировка PR.
- Корректность — жёсткое условие слияния: контракт, закреплённые колонки, подсветки и состояние ячеек (требования spec без замеров).
Решения дизайна
D1. Сброс — новый массив поверх тех же определений колонок
RealtimeTable разделяет два массива:
- базовые определения
columns— нынешнийuseMemoсgetTableColumns,RealtimeTable.jsx:255-273, без изменений; - массив, который уходит в таблицу:
const tableColumns = useMemo(() => [...columns], [columns, cellMemoResetKey]);
В createTableConfig передаётся tableColumns. SettingsPanel и TableHead продолжают получать columns.
Почему это работает: TanStack мемоизирует колонки по идентичности массива options.columns (Контекст, п. 3). Новый массив с теми же определениями даёт новые Column, а за ними и новые cell. Это фиксирует тест-допущение.
Уточнение (O-1, решение 18): закрепление или открепление колонки само пересоздаёт только ячейку этой колонки, а left у остальных закреплённых и сдвиг центральных мемоизированная ячейка не пересчитает. Поэтому ключ сброса по раскладке закреплённых нужен и на table-core 8.20.5. Поведение зафиксировано тестами table-core: в cellMemoReset.test.js, при обновлении table-core они покажут смену.
Отвергнутые альтернативы:
- Добавить ключ в зависимости
getTableColumns. ТогдаstructuredCloneконфигурации листа (tableColumns.jsx:126) иprocessColumnsвыполнялись бы на каждое изменение поиска и на каждый символ в фильтре колонки. Работа лишняя: определения от этих состояний не зависят. - React
keyна<MaterialReactTable>. Перемонтирует всю таблицу: теряются позиция прокрутки, измерения виртуализаторов и фокус. - Версия в контексте. Не меняет идентичность
cell, а компаратор MRT пропускает ячейку, не доходя до контекста. Контекст увидел бы только собственныйCell, аsticky/leftсчитает обёртка MRT.
D2. Ключ сброса — чистая функция в utils/cellMemoReset.js
getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing, globalFilter, columnFilters }) // -> string
Функция собирается из двух частей. Их можно экспортировать отдельно для тестов: getPinnedLayoutKey и getFilterResetKey.
- Раскладка закреплённых. Для
leftиrightпо порядкуcolumnPinning[side]берутся только id сcolumnVisibility[id] !== false, к каждому —columnSizing[id]. Если ширины нет, берётся пустая строка: значит, действует размер из определения, а он не меняется. Ширины и видимость незакреплённых колонок в ключ не входят.- Если в
columnPinningпопадёт id групповой колонки (handlePinColumnберётselectedColumnIdиз строки букв, а это листовые id; групповой id TanStack всё равно проигнорирует вgetLeftLeafColumns), он войдёт в ключ без вреда. Худший случай — лишний сброс.
- Если в
- Фильтры.
globalFilter ?? ''иcolumnFilters, нормализованные в парыid=значение. Значение —JSON.stringify, потому что у числовых фильтров это массив[min, max](TableHead.jsx:256,:267). Пары сортируются по id, чтобы порядок в массиве не давал лишних сбросов.
Пока ширину тянут, ключ не меняется. RealtimeTable строит ключ раскладки по «устоявшимся» ширинам:
const isResizingColumn = Boolean(columnSizingInfo.isResizingColumn);
const pinnedLayoutKey = useMemo(
() => getPinnedLayoutKey({ columnPinning, columnVisibility, columnSizing }),
// пока тянут ширину, ширины не пересчитываются: ключ меняется один раз — по отпусканию мыши
[columnPinning, columnVisibility, isResizingColumn ? null : columnSizing],
);
Как это работает:
- В момент нажатия мыши ширины ещё прежние, и ключ получается тем же.
- Пока тянут, зависимость
nullне меняется. - По отпусканию зависимость снова
columnSizing, ключ пересчитывается с итоговыми ширинами.
Если React когда-нибудь выбросит закэшированное значение useMemo, вреда не будет. MRT во время перетаскивания сам игнорирует новые колонки (Контекст, п. 3), поэтому получился бы лишний [...columns] без перерисовки.
Тестируемое ядро — функция «ключ при перетаскивании». Тест проверяет, что при isResizing = true ключ равен прежнему. Её форму выбирает исполнитель: отдельная pickSettledSizing({ previous, current, isResizing }) или параметр. Но условие «не меняется, пока тянут» MUST быть покрыто тестом (spec, требование «Явный сброс…»).
Отвергнуто — один ключ без заморозки ширин. Тогда на каждое движение мыши пересобирался бы tableColumns. В 3.2.1 MRT его проигнорировал бы, но поведение перестало бы соответствовать решению «сброс один раз по отпусканию» при любой будущей версии MRT.
D3. Начало и конец перетаскивания ширины сообщаются MRT через columnSizingInfo
-
columnSizingInfoстановится управляемым состояниемRealtimeTable:useStateс начальным значением по умолчанию TanStack:{ startOffset: null, startSize: null, deltaOffset: null, deltaPercentage: null, isResizingColumn: false, columnSizingStart: [] };- в
state.columnSizingInfoиonColumnSizingInfoChangeтаблицы.
Это нужно, чтобы ключ (D2) был доступен до вызова
useMaterialReactTable. -
ColumnResizerполучает пропсыonDragStartиonDragEnd:onDragStart— вhandleMouseDown;onDragEnd— вhandleMouseUp, а также в очистке эффекта, если компонент размонтирован посреди перетаскивания, и поblurокна (см. «Риски»).
-
HeaderCell(TableHead.jsx:105-127) передаёт:- на начало —
table.setColumnSizingInfo((old) => ({ ...old, isResizingColumn: header.column.id })); - на конец —
table.setColumnSizingInfo((old) => ({ ...old, isResizingColumn: false })).
- на начало —
-
На корневой
divресайзера ставится атрибутdata-col-resize-id={header.column.id}. Он нужен для сценария замера и ни на что больше не влияет.
Что это даёт (Контекст, п. 3–4). Пока тянут любую колонку:
- MRT не пересоздаёт колонки, тело таблицы заморожено (
Memo_MRT_TableBody); - ширины меняются через CSS-переменные на
<table>, собственныйTableHeadперерисовывается с новыми размерами.
По отпусканию isResizingColumn === false:
- MRT строит колонки заново;
- ключ D2 меняется, если тянули закреплённую колонку;
- ячейки получают свежие
left.
Так выполняется решение 3, и решение «незакреплённые — через CSS-переменные без сброса» соблюдается и во время перетаскивания. Без флага каждое движение мыши перерисовывало бы всё видимое тело: так сейчас, и так было бы и с memoMode, потому что рендер владельца таблицы в MRT 3.2.1 даёт новые ячейки.
Отвергнутые альтернативы:
- Собственный флаг
resizingColumnIdвRealtimeTableбез MRT. Ключ заморозить можно, но MRT пересоздаёт ячейки на каждый рендер владельца, так что тело перерисовывалось бы на каждом движении мыши. - Перейти на обработчики ширины MRT. Пришлось бы переписать собственный заголовок (
TableHead), это вне рамок задачи.
D4. columnFilters становится управляемым состоянием RealtimeTable
const [columnFilters, setColumnFilters] = useState([])подключается вstate.columnFiltersиonColumnFiltersChange.TableHeadполучаетcolumnFilters={columnFilters}вместоtable.getState().columnFilters(RealtimeTable.jsx:812).
Поведение фильтров не меняется: TanStack с управляемым состоянием работает так же. Значения фильтров не сохраняются между сессиями, как и сейчас.
D5. getCachedCellProps продолжает читать фильтры из table.getState()
Это не нарушает контракт, потому что фильтры входят в ключ сброса. Новые ячейки перерисовываются с актуальными значениями, а кэш getCachedCellProps сравнивает фильтры при каждом обращении (tableColumns.jsx:150-163).
Отвергнуто — передавать фильтры через контекст. Это вторая параллельная доставка того же состояния. И <mark> всё равно должен смениться во всех видимых ячейках разом, а это и есть сброс.
D6. Сценарий замера перетаскивания добавляется в этом же PR
Сценарий — perf/e2e/scenarios/pinned-resize.spec.ts. Его добавляют отдельным коммитом, только в perf/, и только после завершения ночного прогона стенда. Содержание — в разделе «Замер до и после».
Контракт для правок таблицы листа
Требование в spec: «Контракт для правок таблицы листа: рендер ячейки зависит только от ячейки». Ниже — как его соблюдать и что проверять на ревью.
Правила
-
Содержимое и оформление ячейки вычисляются только из
cell:cell.row.originalи определение колонки, плюс значения, от которых зависят определения колонок (этап, роль, разрядность, правила валидации). Всё, от чего зависят определения колонок, стоит в зависимостяхcolumns(RealtimeTable.jsx:255-273). -
Состояние вне данных строки доставляется одним из трёх способов:
- CSS-правилом контейнера по атрибутам
data-row-id,data-col-id,data-selected,data-pinnedили новым атрибутом — образецgetActiveCellSx; - React-контекстом, который читает собственный компонент ячейки;
- хранилищем с подпиской (
useSyncExternalStore) — образецuseCellLock.
Изменяемое состояние не замыкается в
muiTableBodyCellProps,col.Cell,col.Edit. Стабильные колбэки (useCallbackбез изменяемых зависимостей),tableиrefзамыкать можно. - CSS-правилом контейнера по атрибутам
-
Обработчики в
muiTableBodyCellProps/col.Cellчитают изменяемое состояние в момент события:table.getState()(editingCell,rowSelection, фильтры),ref(rowSelectionRef,selectedColumnIdRef) или хранилище (lockedCellsStore). Замыкание наeditingCellиз рендера RealtimeTable запрещено. -
Новое состояние, которое меняет определения колонок или стили закрепления, добавляется в ключ сброса (
utils/cellMemoReset.js), и к нему пишется строка теста. -
Прокрутка не вызывает
setStateвRealtimeTableи в его хуках. Любой рендер владельца таблицы в MRT 3.2.1 пересоздаёт все ячейки, и мемоизация при прокрутке перестаёт работать. Состояние, зависящее от прокрутки, держится в отдельном компоненте, как вColumnSelectionOverlay.
Что это значит для конкретных задач
- AURORA-1891 (редактирование одним кликом и с клавиатуры):
onClick/onKeyDownвmuiTableBodyCellProps.- Условие «сейчас ничего не редактируется» проверяется через
table.getState().editingCellв момент события, «ячейка не заблокирована» — черезlockedCellsStore.hasCell(...). - «Начальный ввод» и «отложенная цель» —
ref/состояниеRealtimeTable. В ячейку они попадают черезEditCell(редактируемая ячейка MRT не мемоизируется), а не через замыкание вmuiTableBodyCellProps. - Фокус в
tdпосле закрытия редактора — через DOM поdata-row-id/data-col-id.
- Условие «сейчас ничего не редактируется» проверяется через
- AURORA-1890 (оформление редактируемых, расчётных и занятых ячеек): редактируемость считается из
cellплюсcolumnsCurStageи роли, а они уже в зависимостяхcolumns.stageColumnDeadlinesидёт только в заголовок. Оформление «занята коллегой» — черезuseCellLock, как сейчас. - AURORA-1893 (плашка ошибок):
getCellErrorsдолжен менять идентичность вместе сdata, какisCellInvalid, и стоять в зависимостяхcolumns. Обводка ячейки при переходе к ошибке — черезactiveSearchMatch(CSS контейнера), раскрытие групп — черезexpanded. - AURORA-1889 («только текущие колонки»): массовое скрытие колонок. Закреплённые колонки среди скрытых обрабатывает ключ раскладки, незакреплённые сброса не требуют.
- AURORA-1910 (стабильный
getRowId):row.idперестаёт быть индексным путём.- CSS-селекторы по
data-row-idуже экранируются (activeCellCss.js). - Ключи React ячеек MRT содержат
staticRowIndex, вставка строки по-прежнему перемонтирует ячейки ниже неё. Это корректно.
- CSS-селекторы по
Чек-лист ревью любой правки RealtimeTable/tableColumns.jsx/Cell
- Появилось ли в
muiTableBodyCellProps,col.Cell,col.Editчтение переменной из рендераRealtimeTable, кроме стабильных колбэков,tableиref? - Если новое состояние влияет на вид ячейки — каким из трёх способов оно доставлено? Проверено ли руками, что уже отрисованная ячейка обновляется без прокрутки?
- Появился ли
setStateпо событию прокрутки вRealtimeTable? - Если изменение касается раскладки колонок или фильтров — обновлены ли ключ сброса и его тест?
Риски и как их закрываем
-
[Устаревший вид ячейки из-за состояния, не вошедшего в сброс] → инвентаризация в разделе «Что уже соответствует контракту», ключ D2 с тестом, ручной регресс по spec (требования «Подсветка…» и «Состояние вне данных…»).
-
[Рендер
RealtimeTableпри прокрутке, появившийся в будущей правке, тихо отключит мемоизацию] → правило 5 контракта и сценарий spec «Прокрутка не рендерит владельца таблицы». Проверка —hotspots.spec.tsв режимеreact: числоStyled(td)на шаг; дляRealtimeTableрендеров быть не должно. -
[Обновление MRT меняет пересоздание колонок или условия мемоизации] → явный сброс не зависит от
prepareColumns. Тест-допущение наtable-coreупадёт, если TanStack перестанет пересоздавать ячейки по новому массиву колонок. При обновлении MRT нужно перепроверить пункты 1–5 раздела «Контекст» поdist: вpackage.json^3.2.1, в lock — 3.2.1. -
[Флаг
isResizingColumnне снят: отпускание мыши потеряно — переключение окна, системный диалог] → тело таблицы и колонки останутся замороженными до следующего отпускания: видимые ячейки не обновятся при смене поиска, закрепления и т. п., хотя данные обновятся. Закрываем так:onDragEndвызывается поmouseupнаwindow(как сейчас);- ещё по
blurокна; - ещё в очистке эффекта
ColumnResizerпри размонтировании во время перетаскивания.
Ручная проверка: начать тянуть, переключиться Alt+Tab, вернуться.
-
[Пока тело заморожено, диапазон виртуальных колонок не пересчитывается] → если сузить колонку больше, чем на ширину трёх колонок overscan, справа до отпускания мыши будет пустая полоса. Это штатное поведение MRT при его собственном перетаскивании. Сценарий ручной проверки: сузить широкую колонку на 400 px. По умолчанию полоса принимается как штатное поведение MRT (решение 11 в «Decisions made without discussion»). Запасной вариант — выставлять флаг только при перетаскивании закреплённой колонки. Его включают, только если ручная проверка покажет, что полоса мешает работе. Тогда перетаскивание незакреплённой колонки останется таким, как сейчас (дорогим), и это нужно будет записать в design.md.
-
[Входящая правка соавтора во время перетаскивания перерисует тело] →
Memo_MRT_TableBodyсравниваетdata. Ячейки тянутой колонки на время перетаскивания покажут рамку 2px — MRT рисует её у колонки сisResizingColumn(:2169-2177). Это косметика, только во время перетаскивания, и исчезает по отпусканию. -
[
editingCellв состоянииRealtimeTableпосле сброса указывает на прежний объектcell] → MRT и наш код сравнивают поid(:2443,RealtimeTable.jsx:562-580), редактор продолжает работать. Ручная проверка: закрепить колонку или изменить поиск при открытом редакторе. -
[Пересоздание колонок по фильтру на каждый символ] → по D1 это только
[...columns], безstructuredClone. Стоимость — пересборкаColumnи ячеек видимых строк, она и так происходит на каждый рендер владельца в MRT 3.2.1. -
[Цена входящей правки соавтора не падает] → ожидаемо: новый
dataпересоздаёт все ячейки. Сценарийscroll-coauthorпроверяет, что хуже не стало. Снижение — в AURORA-1838.
Совместимость и порядок PR
- Стек:
feat/211-aurora-1836-row-measure(AURORA-1836, PR #222) ←feat/226-aurora-1837-cell-styles-css(AURORA-1837, PR #230) ←feat/234-aurora-1839-cell-memo;- PR этой задачи — в
feat/226-aurora-1837-cell-styles-css; - после слияния #230 в
testPR перенацеливается наtestс rebase.
- Конфликты:
- общие места с 1836 и 1837 —
RealtimeTable.jsx:325-456,constants/tableConfig.js; - с AURORA-1840 (модалки) —
RealtimeTable.jsx, другие участки, это независимый PR отtest; - с AURORA-1838 —
hooks/useRealtimeData.js,utils/cellUtils.js, не пересекаются.
- общие места с 1836 и 1837 —
- Миграций, SQL и API нет. Правило перенумерации миграций 0050+ к задаче не относится.
- localStorage: ключи настроек колонок (
useColumnSettings.js) не меняются. Сохранённые закрепление, видимость и ширины продолжают действовать. - Откат: убрать
memoMode. Ключ сброса, управляемыеcolumnFilters/columnSizingInfoи флаг перетаскивания безвредны и без мемоизации: флаг даже ускоряет перетаскивание.
Тестирование
Автотесты — cd web && node --test src/components/RealtimeTable/utils/*.test.js.
utils/cellMemoReset.test.js, ключ сброса:
- меняется:
- закрепление колонки слева;
- закрепление справа;
- открепление;
- скрытие закреплённой (
columnVisibility[id] = false); - показ закреплённой;
- смена ширины закреплённой;
globalFilter''→'abc'→'abd'→'';- значение фильтра колонки, включая
[min, max], и очистка фильтра;
- не меняется:
- ширина незакреплённой;
- скрытие и показ незакреплённой;
- новый массив
columnFiltersс теми же значениями в другом порядке; - пока ширину тянут (
isResizing = true), хотяcolumnSizingзакреплённой колонки изменился;
- после отпускания ключ соответствует новым ширинам.
Тест-допущение на @tanstack/table-core — фикстура createTable и updateState, как в columnLayoutUtils.test.js:
- при прежних
columns/datarow.getVisibleCells()[i]те же; - при новом массиве
columnsс теми же определениями — новые; - при смене
columnPinningс прежним массивом — те же.
Третье утверждение — причина, по которой нужен явный сброс. Тест ссылается на этот design.md.
Линт: cd web && npx biome check src. useExhaustiveDependencies настроен на уровень info. Зависимость cellMemoResetKey в useMemo для tableColumns намеренная, помечается комментарием.
Ручной регресс — FORM_2/AHR под исполнителем РФ и под админом; соавтор во втором браузере:
- поиск: подсветка
<mark>, Enter и Shift+Enter, кнопки «‹ ›», обводка текущей, очистка поиска; - фильтры колонок: текст, диапазон, очистка;
- ошибки валидации: ввести нарушающее значение, исправить, плашка;
- блокировка соавтора: появление и снятие 🔒 на ячейке, которая всё время на экране;
- выделение строки кликом и по номеру строки;
- редактирование: двойной клик, ВСП-дропдаун, Enter, клик вне ячейки;
- закрепление, открепление, скрытие и показ закреплённой, ширина закреплённой — по отпусканию соседи не наложены; ширина незакреплённой — сразу;
- сужение широкой колонки на 400 px — пустая полоса, если есть, исчезает по отпусканию;
- Alt+Tab посреди перетаскивания — после возврата таблица реагирует на поиск;
- разрядность, масштаб, раскрытие и свёртка групп;
- добавление и удаление строки;
- то же на одном листе FORM_4 (STRUCTURE) и отчёте проекта.
Замер до и после
Сборки
- «До» — HEAD ветки
feat/226-aurora-1837-cell-styles-css, «после» — HEAD этой ветки. Данные одни и те же:manifest-s1, прогон доgrow. - Ловушки стенда — черновик P1, раздел «Как организовать замеры»:
- замеры — из отдельного worktree (
git checkout --detach <sha>); - перед сборкой «после» удалить
perf/.build/profilingиperf/.build/prod-sourcemapи перезапуститьwebна :8080; - остановить посторонние контейнеры
arm-ka-*; PERF_TRACE=0 PERF_REPEATS=3.
- замеры — из отдельного worktree (
Прогоны — из perf/e2e, переменные стенда как в perf/README.md:
| Что | Команда (env) | Метрика | Порог |
|---|---|---|---|
Скролл по y |
PERF_TARGETS='FORM_2/AHR/*' PERF_ROLES=rf PERF_AXES=y PERF_TRACE=0 PERF_REPEATS=3 npx playwright test scenarios/scroll.spec.ts |
медиана main_thread_ms_per_step |
≤ 25 мс (критерий) |
Скролл по x |
то же, PERF_AXES=x |
медиана main_thread_ms_per_step |
не больше базы |
| Рендеры на шаг | PERF_HOT_MODE=react PERF_TARGETS='FORM_2/AHR/*' scenarios/hotspots.spec.ts на профилирующей сборке :8081 |
Styled(td)/шаг, рендеры RealtimeTable |
Styled(td) кратно меньше 1 696; RealtimeTable — 0 за прокрутку |
| Соавтор | PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=5 PERF_REPEATS=2 scenarios/scroll-coauthor.spec.ts |
main_thread_ms_per_step |
не хуже базы в пределах шума (±5 %) |
| Перетаскивание ширины закреплённой | PERF_TARGETS='FORM_2/AHR/*' PERF_TRACE=0 PERF_REPEATS=3 scenarios/pinned-resize.spec.ts |
медиана main_thread_ms_per_step (шаг = движение мыши), frame_p95_ms, heavy_frame_n |
main_thread_ms_per_step не больше базы; остальное — в PR |
Агрегация: python -m perfkit.aggregate perf/results/<run_id>. Группировка по scenario/target/role/scale/cpu_rate (perfkit/aggregate.py:12), новый сценарий подхватывается без правок.
Сценарий perf/e2e/scenarios/pinned-resize.spec.ts (добавить) — по образцу scroll.spec.ts: loginAs, PROBE_SCRIPT, startProbe/perStep, throttle, append, buildTargets по манифесту, фильтр PERF_TARGETS.
- Перед навигацией (через
addInitScriptилиpage.evaluateдо загрузки листа) очистить в localStorage ключи${formId}_${formType}_${sheetName}_${direction}_columnPinning,_columnSizing,_columnVisibilityэтого листа, чтобы начальная раскладка была по умолчанию: закрепленыmrt-row-expandиsort_order. - Открыть лист FORM_2/AHR под
rf, дождаться строк (какscroll.spec.ts,MIN_ROWS). - Выбрать первую незакреплённую колонку данных: первый
[data-col-select-id]в строке букв, чей id неsort_order/mrt-row-expand, а лучше первыйdata.*. Кликнуть его, затем кнопку «Закрепить область»:[data-pin-panel] button[aria-label="Закрепить область"], MUITooltipставитaria-labelпоtitle. Дождатьсяdata-active="true"на кнопке. - Найти ресайзер
[data-col-resize-id="<id>"], взятьboundingBox,page.mouse.moveв его центр,page.mouse.down(). startProbe. Затем 50 шагов по +4 px и 50 шагов по −4 px:page.mouse.move(x, y)и после каждого шага ожидание кадра (requestAnimationFrame, какtickвlib/scroll.ts). Потомpage.mouse.up(), ещё два кадра,stop().steps = 100.- Проверка геометрии после отпускания: для первой видимой строки тела взять
td[data-pinned="true"]по порядку и проверить, чтоleft[i+1]равенleft[i] + width[i]с допуском 1 px. Нарушение —incomplete: true, skip_reason: 'pinned_overlap'. append({ scenario: 'pinned_resize', target: 'FORM_2/AHR/null@pinned', role: 'rf', cpu_rate, repeat, metrics: { ...probe, ...perStep(probe, steps), steps } }).- Повторы —
PERF_REPEATS(по умолчанию 3). РежимPERF_HOT_MODE=reactпо образцуhotspots.spec.ts— опционально, для числаStyled(td)на шаг перетаскивания.
Атрибут ресайзера в базе. На ветке «до» атрибута data-col-resize-id нет. Сценарий ищет ресайзер так: [data-col-resize-id="<id>"], а если его нет — последний дочерний div с cursor: col-resize внутри ячейки заголовка той же колонки. Ячейка заголовка находится по горизонтальной координате [data-col-select-id="<id>"]. Запасной путь нужен только для замера базы.
Машина замера. Та же Apple M4, на которой снят отчёт профилирования (perf/REPORT.md, методика). Пороги абсолютные (25 мс), отдельный прогон базы test сверх обычного «до/после» не нужен.
Кто и когда меряет. Все замеры из таблицы выше, включая новый pinned-resize.spec.ts, выполняет контроллер на стенде после основных ночных замеров.
- В плане это точка ожидания.
- PR открывается с кодом, тестами и чек-листом ручного регресса, цифры дописываются в PR после замера.
- Колонка «Порог» в таблице — цель, а не условие слияния (решение 8 в разделе «Решения (согласовано 28.10)»). Недобор оформляется follow-up issue с разбором.
Что оставлено исполнителю
- Точная форма тестируемого ядра заморозки ключа: отдельная функция или параметр (D2). Обязательны тесты обоих условий.
- Имена экспортов в
utils/cellMemoReset.js(getCellMemoResetKey,getPinnedLayoutKey,getFilterResetKey— рекомендуемые). - Формат строки ключа — любой детерминированный.
- Как
ColumnResizerподписывается наblur: в том же эффекте, чтоmousemove/mouseup. - Расстановка шагов плана. Рекомендуемый порядок:
- модуль ключа и тесты, включая тест-допущение;
- управляемые
columnFilters/columnSizingInfoи флаг перетаскивания вTableHead/ColumnResizer; tableColumnsиmemoMode: 'cells';- ручной регресс;
- сценарий
pinned-resize.spec.ts— отдельным коммитом вperf/, после ночного прогона; - открыть PR с кодом, тестами и чек-листом ручного регресса;
- точка ожидания: замеры выполняет контроллер, цифры дописываются в PR, недобор — в follow-up issue.
Decisions made without discussion
- 2026-10-28 — сброс делается новым массивом
[...columns]поверх прежних определений колонок (D1), а не пересозданием черезgetTableColumns.- Обоснование: TanStack пересоздаёт
Column/cellпо идентичности массива.structuredCloneконфигурации на каждый символ фильтра — лишняя работа. - Цена ошибки: если какое-то определение колонки окажется зависимым от фильтров или раскладки, его вид не обновится. Сейчас таких нет, инвентаризация — в разделе «Контекст». Исправление — перенести ключ в зависимости
columns, одна строка.
- Обоснование: TanStack пересоздаёт
- 2026-10-28 — флаг перетаскивания ширины передаётся MRT через
columnSizingInfo.isResizingColumnиз собственногоColumnResizer,columnSizingInfoстановится управляемым (D3).- Обоснование: человек решил «сброс по
isResizingColumn === false», но перетаскивание ширины в проекте собственное, и MRT этот флаг сейчас не получает никогда. Черновик разведчика исходил из ресайза MRT. Только этот флаг заставляет MRT 3.2.1 не пересоздавать ячейки и не перерисовывать тело на каждом движении мыши. - Побочный эффект: тело таблицы заморожено при перетаскивании любой колонки, в том числе незакреплённой.
- Цена ошибки:
- пустая полоса справа при сильном сужении колонки до отпускания мыши;
- при потерянном
mouseupтаблица остаётся замороженной (закрываетсяblurи очисткой); - откат — флаг только для закреплённых колонок, около 5 строк.
- Обоснование: человек решил «сброс по
- 2026-10-28 —
columnFiltersстановится управляемым состояниемRealtimeTable(D4).- Обоснование: значение фильтров нужно до создания таблицы, чтобы включить его в ключ сброса.
- Цена ошибки: при ошибке подключения перестанут работать фильтры колонок. Это видно сразу на ручном регрессе, исправление локальное.
- 2026-10-28 — ключ раскладки берёт ширины только закреплённых видимых колонок, групповые id в
columnPinningне раскрываются до листьев (D2).- Обоснование:
leftзакреплённой колонки — сумма ширин закреплённых левее неё. Групповые id TanStack игнорирует вgetLeftLeafColumns. - Цена ошибки: если когда-нибудь закрепят группу через
column.pin()(тогда в состоянии будут листья), ключ останется корректным. Если закрепление групп появится иначе — возможен устаревшийleftдо следующего рендера владельца. Сейчас его гасит MRT 3.2.1.
- Обоснование:
- 2026-10-28 — критерий 25 мс применяется к оси
y, осьx— «не хуже базы».- Обоснование: в Jira критерий «шаг скролла FORM_2/AHR», база отчёта М1 (89,0 мс) — ось
y. - Цена ошибки: если заказчик ждёт 25 мс и по горизонтали, нужен дополнительный замер. По
hotspotsбаза поxблизка кy(81,3 против 76,5 мс), ожидаемый эффект мемоизации тот же.
- Обоснование: в Jira критерий «шаг скролла FORM_2/AHR», база отчёта М1 (89,0 мс) — ось
- 2026-10-28 — для сценария «потянуть ширину закреплённой» порог — «не больше базы» по
main_thread_ms_per_step, абсолютного порога в мс нет.frame_p95_msиheavy_frame_n— для сведения в PR.- Обоснование: человек задал сценарий, но не порог. Базы этого сценария ещё нет, её снимет первый прогон.
- Цена ошибки: требование слабее, чем могло бы быть. После первого замера порог можно ужесточить правкой spec.
- 2026-10-28 — сценарий
pinned-resize.spec.tsдобавляется в этом же PR, отдельным коммитом вperf/после ночного прогона (D6). На ресайзер добавляется атрибутdata-col-resize-id, для базы в сценарии есть запасной поиск.- Обоснование: без сценария нечем доказать требование spec о перетаскивании.
- Цена ошибки: PR затрагивает
perf/. Если стенд в это время занят, сценарий переносится в отдельный PR, требование spec остаётся.
- 2026-10-28 — в контракт добавлено правило 5: «прокрутка не вызывает рендер
RealtimeTable». Человек его не формулировал.- Обоснование: в MRT 3.2.1 любой рендер владельца таблицы пересоздаёт все ячейки (Контекст, п. 3), так что такая правка тихо обнулит эффект задачи.
- Цена ошибки: правило ограничивает будущие правки (например, «липкие» индикаторы по прокрутке в
RealtimeTable). Обходится отдельным компонентом со своим состоянием.
- 2026-10-28 — capability называется
sheet-table, новый модуль —utils/cellMemoReset.js, а неcolumnLayoutUtils.jsиз черновика.- Обоснование: ключ включает фильтры, это не только раскладка.
- Цена ошибки: только имена.
- 2026-10-28 (контроллер) — машина замеров — та же Apple M4, на которой снят отчёт профилирования. Пороги абсолютные (25 мс), отдельный прогон базы сверх обычного «до/после» не нужен.
- Обоснование: на той же машине база отчёта М1 сопоставима с замерами «после» напрямую.
- Цена ошибки: если замер всё же пойдёт на другой машине, абсолютные цифры будут несравнимы с отчётом. Тогда нужен прогон базы
testна этой машине и пересчёт порога в относительный (−72 % от базы).
- 2026-10-28 (контроллер) — пустая полоса справа при перетаскивании незакреплённой колонки решается по ручной проверке; по умолчанию полоса принимается как штатное поведение MRT, флаг выставляется для любой колонки (D3).
- Обоснование: так ведёт себя MRT при собственном ресайзе. Альтернатива (флаг только для закреплённых) возвращает дорогое перетаскивание незакреплённых колонок.
- Цена ошибки: пользователь видит кратковременную пустую полосу до отпускания мыши. Откат — около 5 строк, см. «Риски».
- 2026-10-28 (контроллер) — замеры, включая новый
pinned-resize.spec.ts, выполняет контроллер на стенде после основных ночных замеров; в плане — точка ожидания.- PR открывается с кодом, тестами и чек-листом ручного регресса, цифры дописываются в PR.
- Сценарий
pinned-resize.spec.tsидёт в этом же PR отдельным коммитом (D6). - Обоснование: стенд занят ночным прогоном, а пороги не блокируют слияние (решение человека 8).
- Цена ошибки: если замер покажет регрессию, исправление пойдёт уже после открытия PR, возможно отдельным PR по follow-up.
- 2026-10-28 (план) — ядро заморозки ширин — чистая
pickSettledSizing({ previous, current, isResizing })вutils/cellMemoReset.js.RealtimeTableхранит «устоявшиеся» ширины вref, который обновляется в эффекте (фаза commit). Это вместоuseMemoс зависимостьюisResizingColumn ? null : columnSizingиз D2.- Обоснование: функция покрыта тестом и сама используется в коде, поэтому тест проверяет рабочий путь, а не копию логики. Запись
refв эффекте безопасна при прерванных рендерах React. - Цена ошибки: ключ раскладки обновится на рендер позже или раньше, то есть возможен лишний или пропущенный сброс по отпусканию мыши. Это видно на ручной проверке «ширина закреплённой». Откат — вариант из D2, несколько строк.
- Обоснование: функция покрыта тестом и сама используется в коде, поэтому тест проверяет рабочий путь, а не копию логики. Запись
- 2026-10-28 (план) — апдейтеры
columnSizingInfoвынесены вutils/columnResizeInfo.js:DEFAULT_COLUMN_SIZING_INFO,startColumnResize(id), идемпотентныйendColumnResize. Тест сверяет значение по умолчанию сtable-core.- Обоснование: конец перетаскивания приходит из трёх мест (
mouseup,blur, размонтирование). Идемпотентный апдейтер не даёт лишнего рендера. Значение по умолчанию не разойдётся с TanStack незаметно. - Цена ошибки: лишний модуль из трёх экспортов.
- Обоснование: конец перетаскивания приходит из трёх мест (
- 2026-10-28 (план) — проверка геометрии сценария
pinned-resize— чистая функцияperf/e2e/lib/pinnedGeometry.ts(findPinnedGap). Тест —perf/e2e/tests/pinned-geometry.test.tsнаnode --test(strip-types Node 24), а не Playwright-спека.- Обоснование: прогон Playwright в
perf/e2eзапускаетglobalSetup, а тот сбрасываетpg_stat_statementsна БД стенда. Правило волны запрещает трогать стенд кому-либо, кроме главного контроллера.testMatchPlaywright (*.spec.ts) этот файл не подхватывает. - Цена ошибки: в
perf/e2eпоявляется второй вид самопроверки (node --test). Синтаксис сценария проверяется толькоnode --check, типы — нет: в worktree нетperf/e2e/node_modules. Ошибку типов покажет первый прогон у главного контроллера.
- Обоснование: прогон Playwright в
- 2026-10-28 (план) — параметры сценария
pinned-resize:- по умолчанию
PERF_TARGETS='FORM_2/AHR/*'; - ключи раскладки в localStorage чистятся по префиксу
${form_id}_${form_type}_${sheet}_и суффиксам_columnPinning/_columnSizing/_columnVisibility; - режим
PERF_HOT_MODE=reactне делается. - Обоснование: критерий spec — только FORM_2/AHR.
directionв ключе localStorage бывает строкойnullилиundefined, поэтому точный ключ ненадёжен. React-режим в design.md отмечен как опциональный. - Цена ошибки: число
Styled(td)на шаг перетаскивания придётся снимать отдельной доработкой сценария. Широкий префикс может стереть раскладку другогоdirectionтого же листа, но только в браузерном контексте замера.
- по умолчанию
- 2026-10-28 (план) — порядок завершения:
- PR открывается после финального ревью (opus) с чек-листом ручного регресса, а сам регресс выполняется по чек-листу в PR до слияния;
- pytest и проверка PG15 не запускаются, если
git diffотносительно базы не затрагиваетapi/; - цифры замеров главный контроллер дописывает в PR после точки ожидания.
- Обоснование: решение 12 и требование «PR открывается с кодом, тестами и чек-листом». Backend в задаче не меняется.
- Цена ошибки: если ручной регресс найдёт нарушение корректности, исправление пойдёт коммитами в уже открытый PR.
- 2026-10-28 (контроллер, R-2, O-1) — тест-допущение table-core заменено на фиксацию фактического поведения 8.20.5; ключ сброса по закреплению сохранён.
- Факт: смена
columnPinningпри прежнемcolumnsпересоздаёт ячейку только закрепляемой колонки; сменаcolumnSizingячейки не пересоздаёт. «Контекст» п. 3 и D1 исправлены. - Обоснование: решение человека (сброс при закреплении, скрытии, фильтрах) не меняется. Остальные ячейки закрепление не пересоздаёт, а их
left/data-pinnedсчитает мемоизированная обёртка MRT. - Цена ошибки: если table-core когда-нибудь начнёт пересоздавать все ячейки, ключ станет частично избыточным (лишняя перерисовка на редких действиях). Тесты
table-core:это покажут.
- Факт: смена
- 2026-10-28 (исполнитель задачи 1, R-3) — формулировка «ключ избыточен по закреплению» из рулинга R-2 неточна и не принята. Тест pinning утверждает: новая только ячейка закрепляемой колонки, остальные те же. Обоснование — решение 18. Цена ошибки: только формулировка.