449 lines
67 KiB
Markdown
449 lines
67 KiB
Markdown
# 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`, без изменений;
|
||
- массив, который уходит в таблицу:
|
||
|
||
```js
|
||
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`
|
||
|
||
```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` строит ключ раскладки по «устоявшимся» ширинам:
|
||
|
||
```js
|
||
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` в результатах.
|