Andrey Epifancev 651d71f7c0 docs(openspec): aurora-1839 — решение 20 (допущения сценария pinned-resize)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:37:35 +04:00

67 KiB
Raw Blame History

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).
    • html font-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"].
  • Подсветка текущего совпадения поиска — правило контейнера 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.

  1. Какие ячейки мемоизируются. MRT_TableBodyRow рендерит Memo_MRT_TableBodyCell только при всех условиях сразу (:2439-2444):

    • memoMode === 'cells';
    • колонка типа data;
    • нет перетаскивания колонки или строки;
    • ячейка не редактируется.

    Компаратор — только prev.cell === next.cell (:2312). Служебная колонка раскрытия строк (mrt-row-expand, тип display) не мемоизируется никогда.

  2. Стили закрепления считаются внутри ячейки. getCommonMRTCellStyles (:843-885) задаёт position: sticky и left: column.getStart('left'), атрибут data-pinned ставит сама ячейка (:2271). Ширина ячейки задана через CSS-переменную --col-<id>-size, сами переменные выставлены на <table> (:3764-3780). Поэтому ширина подхватывается мемоизированной ячейкой без перерисовки, а left — нет.

  3. Объекты ячеек пересоздаются на каждый рендер владельца таблицы. useMRT_TableInstance (:1693-1725) на каждом рендере компонента, вызвавшего useMaterialReactTable (у нас это RealtimeTable), строит columns заново через prepareColumns, а это columnDefs.map(...) — новый массив. Дальше по цепочке:

    • в @tanstack/table-core 8.20.5 getAllColumns мемоизирован по 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 при прежнем массиве — те же ячейки (тест-характеристика).
  4. Пока isResizingColumn истинно, тело таблицы заморожено. MRT_Table рендерит Memo_MRT_TableBody (:3780), а тот сравнивает только table.options.data (:2526). Так MRT ведёт себя при собственном перетаскивании ширины.

  5. Прокрутка перерисовывает только внутренние компоненты 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)

  1. memoMode: 'cells' в RealtimeTable. Задаётся в createTableConfig (RealtimeTable.jsx:344-345) рядом с ...BASE_TABLE_CONFIG. BASE_TABLE_CONFIG использует только RealtimeTable, но опция относится к поведению этого компонента, поэтому стоит рядом с остальными опциями рендера.

  2. Явный сброс мемоизации пересозданием columns. Срабатывает:

    • при изменении раскладки закреплённых колонок: закрепление, открепление, скрытие закреплённой, показ закреплённой, ширина закреплённой;
    • при изменении globalFilter и columnFilters.

    Сброс явный и не полагается на побочный эффект filterFromLeafRows. От побочного эффекта prepareColumns в MRT 3.2.1 он тоже не зависит.

  3. Ширина закреплённой колонки сбрасывает мемоизацию один раз, по отпусканию мыши (columnSizingInfo.isResizingColumn === false). Пока колонку тянут, соседние закреплённые колонки могут отставать. Ширина незакреплённых колонок меняется через CSS-переменные MRT, без сброса.

  4. Скрытие и показ незакреплённой колонки — без сброса.

  5. Инвариант для всех будущих правок таблицы. Записан требованием в spec и разделом «Контракт» ниже. Задачи AURORA-1889, 1890, 1891, 1893, 1910 делаем сами. Достаточно документа и небольшого теста на сброс, lint-правило не нужно.

  6. Критерий:

    • шаг прокрутки FORM_2/AHR ≤ 25 мс (стенд, масштаб 1, CPU 1×);
    • сценарий «закрепить колонку, потянуть ширину закреплённой — время кадра». В perf/e2e его нет, что добавить — описано ниже.
  7. Связь с AURORA-1838 не блокирующая. В Jira она заменена на «связанные».

  8. Пороги производительности не являются условием слияния.

    • 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: «Контракт для правок таблицы листа: рендер ячейки зависит только от ячейки». Ниже — как его соблюдать и что проверять на ревью.

Правила

  1. Содержимое и оформление ячейки вычисляются только из cell: cell.row.original и определение колонки, плюс значения, от которых зависят определения колонок (этап, роль, разрядность, правила валидации). Всё, от чего зависят определения колонок, стоит в зависимостях columns (RealtimeTable.jsx:255-273).

  2. Состояние вне данных строки доставляется одним из трёх способов:

    • CSS-правилом контейнера по атрибутам data-row-id, data-col-id, data-selected, data-pinned или новым атрибутом — образец getActiveCellSx;
    • React-контекстом, который читает собственный компонент ячейки;
    • хранилищем с подпиской (useSyncExternalStore) — образец useCellLock.

    Изменяемое состояние не замыкается в muiTableBodyCellProps, col.Cell, col.Edit. Стабильные колбэки (useCallback без изменяемых зависимостей), table и ref замыкать можно.

  3. Обработчики в muiTableBodyCellProps/col.Cell читают изменяемое состояние в момент события: table.getState() (editingCell, rowSelection, фильтры), ref (rowSelectionRef, selectedColumnIdRef) или хранилище (lockedCellsStore). Замыкание на editingCell из рендера RealtimeTable запрещено.

  4. Новое состояние, которое меняет определения колонок или стили закрепления, добавляется в ключ сброса (utils/cellMemoReset.js), и к нему пишется строка теста.

  5. Прокрутка не вызывает 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, вставка строки по-прежнему перемонтирует ячейки ниже неё. Это корректно.

Чек-лист ревью любой правки 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 в test PR перенацеливается на 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, не пересекаются.
  • Миграций, 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/data row.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.

Прогоны — из 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.

  1. Перед навигацией (через addInitScript или page.evaluate до загрузки листа) очистить в localStorage ключи ${formId}_${formType}_${sheetName}_${direction}_columnPinning, _columnSizing, _columnVisibility этого листа, чтобы начальная раскладка была по умолчанию: закреплены mrt-row-expand и sort_order.
  2. Открыть лист FORM_2/AHR под rf, дождаться строк (как scroll.spec.ts, MIN_ROWS).
  3. Выбрать первую незакреплённую колонку данных: первый [data-col-select-id] в строке букв, чей id не sort_order/mrt-row-expand, а лучше первый data.*. Кликнуть его, затем кнопку «Закрепить область»: [data-pin-panel] button[aria-label="Закрепить область"], MUI Tooltip ставит aria-label по title. Дождаться data-active="true" на кнопке.
  4. Найти ресайзер [data-col-resize-id="<id>"], взять boundingBox, page.mouse.move в его центр, page.mouse.down().
  5. startProbe. Затем 50 шагов по +4 px и 50 шагов по −4 px: page.mouse.move(x, y) и после каждого шага ожидание кадра (requestAnimationFrame, как tick в lib/scroll.ts). Потом page.mouse.up(), ещё два кадра, stop(). steps = 100.
  6. Проверка геометрии после отпускания: для первой видимой строки тела взять td[data-pinned="true"] по порядку и проверить, что left[i+1] равен left[i] + width[i] с допуском 1 px. Нарушение — incomplete: true, skip_reason: 'pinned_overlap'.
  7. append({ scenario: 'pinned_resize', target: 'FORM_2/AHR/null@pinned', role: 'rf', cpu_rate, repeat, metrics: { ...probe, ...perStep(probe, steps), steps } }).
  8. Повторы — 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.
  • Расстановка шагов плана. Рекомендуемый порядок:
    1. модуль ключа и тесты, включая тест-допущение;
    2. управляемые columnFilters/columnSizingInfo и флаг перетаскивания в TableHead/ColumnResizer;
    3. tableColumns и memoMode: 'cells';
    4. ручной регресс;
    5. сценарий pinned-resize.spec.ts — отдельным коммитом в perf/, после ночного прогона;
    6. открыть PR с кодом, тестами и чек-листом ручного регресса;
    7. точка ожидания: замеры выполняет контроллер, цифры дописываются в PR, недобор — в follow-up issue.

Decisions made without discussion

  1. 2026-10-28 — сброс делается новым массивом [...columns] поверх прежних определений колонок (D1), а не пересозданием через getTableColumns.
    • Обоснование: TanStack пересоздаёт Column/cell по идентичности массива. structuredClone конфигурации на каждый символ фильтра — лишняя работа.
    • Цена ошибки: если какое-то определение колонки окажется зависимым от фильтров или раскладки, его вид не обновится. Сейчас таких нет, инвентаризация — в разделе «Контекст». Исправление — перенести ключ в зависимости columns, одна строка.
  2. 2026-10-28 — флаг перетаскивания ширины передаётся MRT через columnSizingInfo.isResizingColumn из собственного ColumnResizer, columnSizingInfo становится управляемым (D3).
    • Обоснование: человек решил «сброс по isResizingColumn === false», но перетаскивание ширины в проекте собственное, и MRT этот флаг сейчас не получает никогда. Черновик разведчика исходил из ресайза MRT. Только этот флаг заставляет MRT 3.2.1 не пересоздавать ячейки и не перерисовывать тело на каждом движении мыши.
    • Побочный эффект: тело таблицы заморожено при перетаскивании любой колонки, в том числе незакреплённой.
    • Цена ошибки:
      • пустая полоса справа при сильном сужении колонки до отпускания мыши;
      • при потерянном mouseup таблица остаётся замороженной (закрывается blur и очисткой);
      • откат — флаг только для закреплённых колонок, около 5 строк.
  3. 2026-10-28 — columnFilters становится управляемым состоянием RealtimeTable (D4).
    • Обоснование: значение фильтров нужно до создания таблицы, чтобы включить его в ключ сброса.
    • Цена ошибки: при ошибке подключения перестанут работать фильтры колонок. Это видно сразу на ручном регрессе, исправление локальное.
  4. 2026-10-28 — ключ раскладки берёт ширины только закреплённых видимых колонок, групповые id в columnPinning не раскрываются до листьев (D2).
    • Обоснование: left закреплённой колонки — сумма ширин закреплённых левее неё. Групповые id TanStack игнорирует в getLeftLeafColumns.
    • Цена ошибки: если когда-нибудь закрепят группу через column.pin() (тогда в состоянии будут листья), ключ останется корректным. Если закрепление групп появится иначе — возможен устаревший left до следующего рендера владельца. Сейчас его гасит MRT 3.2.1.
  5. 2026-10-28 — критерий 25 мс применяется к оси y, ось x — «не хуже базы».
    • Обоснование: в Jira критерий «шаг скролла FORM_2/AHR», база отчёта М1 (89,0 мс) — ось y.
    • Цена ошибки: если заказчик ждёт 25 мс и по горизонтали, нужен дополнительный замер. По hotspots база по x близка к y (81,3 против 76,5 мс), ожидаемый эффект мемоизации тот же.
  6. 2026-10-28 — для сценария «потянуть ширину закреплённой» порог — «не больше базы» по main_thread_ms_per_step, абсолютного порога в мс нет. frame_p95_ms и heavy_frame_n — для сведения в PR.
    • Обоснование: человек задал сценарий, но не порог. Базы этого сценария ещё нет, её снимет первый прогон.
    • Цена ошибки: требование слабее, чем могло бы быть. После первого замера порог можно ужесточить правкой spec.
  7. 2026-10-28 — сценарий pinned-resize.spec.ts добавляется в этом же PR, отдельным коммитом в perf/ после ночного прогона (D6). На ресайзер добавляется атрибут data-col-resize-id, для базы в сценарии есть запасной поиск.
    • Обоснование: без сценария нечем доказать требование spec о перетаскивании.
    • Цена ошибки: PR затрагивает perf/. Если стенд в это время занят, сценарий переносится в отдельный PR, требование spec остаётся.
  8. 2026-10-28 — в контракт добавлено правило 5: «прокрутка не вызывает рендер RealtimeTable». Человек его не формулировал.
    • Обоснование: в MRT 3.2.1 любой рендер владельца таблицы пересоздаёт все ячейки (Контекст, п. 3), так что такая правка тихо обнулит эффект задачи.
    • Цена ошибки: правило ограничивает будущие правки (например, «липкие» индикаторы по прокрутке в RealtimeTable). Обходится отдельным компонентом со своим состоянием.
  9. 2026-10-28 — capability называется sheet-table, новый модуль — utils/cellMemoReset.js, а не columnLayoutUtils.js из черновика.
    • Обоснование: ключ включает фильтры, это не только раскладка.
    • Цена ошибки: только имена.
  10. 2026-10-28 (контроллер) — машина замеров — та же Apple M4, на которой снят отчёт профилирования. Пороги абсолютные (25 мс), отдельный прогон базы сверх обычного «до/после» не нужен.
    • Обоснование: на той же машине база отчёта М1 сопоставима с замерами «после» напрямую.
    • Цена ошибки: если замер всё же пойдёт на другой машине, абсолютные цифры будут несравнимы с отчётом. Тогда нужен прогон базы test на этой машине и пересчёт порога в относительный (−72 % от базы).
  11. 2026-10-28 (контроллер) — пустая полоса справа при перетаскивании незакреплённой колонки решается по ручной проверке; по умолчанию полоса принимается как штатное поведение MRT, флаг выставляется для любой колонки (D3).
    • Обоснование: так ведёт себя MRT при собственном ресайзе. Альтернатива (флаг только для закреплённых) возвращает дорогое перетаскивание незакреплённых колонок.
    • Цена ошибки: пользователь видит кратковременную пустую полосу до отпускания мыши. Откат — около 5 строк, см. «Риски».
  12. 2026-10-28 (контроллер) — замеры, включая новый pinned-resize.spec.ts, выполняет контроллер на стенде после основных ночных замеров; в плане — точка ожидания.
    • PR открывается с кодом, тестами и чек-листом ручного регресса, цифры дописываются в PR.
    • Сценарий pinned-resize.spec.ts идёт в этом же PR отдельным коммитом (D6).
    • Обоснование: стенд занят ночным прогоном, а пороги не блокируют слияние (решение человека 8).
    • Цена ошибки: если замер покажет регрессию, исправление пойдёт уже после открытия PR, возможно отдельным PR по follow-up.
  13. 2026-10-28 (план) — ядро заморозки ширин — чистая pickSettledSizing({ previous, current, isResizing }) в utils/cellMemoReset.js. RealtimeTable хранит «устоявшиеся» ширины в ref, который обновляется в эффекте (фаза commit). Это вместо useMemo с зависимостью isResizingColumn ? null : columnSizing из D2.
    • Обоснование: функция покрыта тестом и сама используется в коде, поэтому тест проверяет рабочий путь, а не копию логики. Запись ref в эффекте безопасна при прерванных рендерах React.
    • Цена ошибки: ключ раскладки обновится на рендер позже или раньше, то есть возможен лишний или пропущенный сброс по отпусканию мыши. Это видно на ручной проверке «ширина закреплённой». Откат — вариант из D2, несколько строк.
  14. 2026-10-28 (план) — апдейтеры columnSizingInfo вынесены в utils/columnResizeInfo.js: DEFAULT_COLUMN_SIZING_INFO, startColumnResize(id), идемпотентный endColumnResize. Тест сверяет значение по умолчанию с table-core.
    • Обоснование: конец перетаскивания приходит из трёх мест (mouseup, blur, размонтирование). Идемпотентный апдейтер не даёт лишнего рендера. Значение по умолчанию не разойдётся с TanStack незаметно.
    • Цена ошибки: лишний модуль из трёх экспортов.
  15. 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 на БД стенда. Правило волны запрещает трогать стенд кому-либо, кроме главного контроллера. testMatch Playwright (*.spec.ts) этот файл не подхватывает.
    • Цена ошибки: в perf/e2e появляется второй вид самопроверки (node --test). Синтаксис сценария проверяется только node --check, типы — нет: в worktree нет perf/e2e/node_modules. Ошибку типов покажет первый прогон у главного контроллера.
  16. 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 того же листа, но только в браузерном контексте замера.
  17. 2026-10-28 (план) — порядок завершения:
    • PR открывается после финального ревью (opus) с чек-листом ручного регресса, а сам регресс выполняется по чек-листу в PR до слияния;
    • pytest и проверка PG15 не запускаются, если git diff относительно базы не затрагивает api/;
    • цифры замеров главный контроллер дописывает в PR после точки ожидания.
    • Обоснование: решение 12 и требование «PR открывается с кодом, тестами и чек-листом». Backend в задаче не меняется.
    • Цена ошибки: если ручной регресс найдёт нарушение корректности, исправление пойдёт коммитами в уже открытый PR.
  18. 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: это покажут.
  19. 2026-10-28 (исполнитель задачи 1, R-3) — формулировка «ключ избыточен по закреплению» из рулинга R-2 неточна и не принята. Тест pinning утверждает: новая только ячейка закрепляемой колонки, остальные те же. Обоснование — решение 18. Цена ошибки: только формулировка.
  20. 2026-10-28 (исполнитель задачи 6, R-4) — в сценарий pinned-resize добавлены решения, которых нет в плане:
    • новая причина пропуска skip_reason: 'column_not_found' (не нашлась колонка для закрепления);
    • если в манифесте нет пользователя rf, тесты не регистрируются;
    • ожидание не менее 20 строк сделано через page.waitForFunction с таймаутом PERF_LOAD_TIMEOUT_MS, без SETTLE-логики scroll.spec.ts.
    • Обоснование: аналогично resizer_not_found; сценарий не должен падать целым прогоном при отсутствии данных.
    • Цена ошибки: запись помечается incomplete вместо падения теста. Видно по skip_reason в результатах.