bug(page-tree/realtime): insertByPosition теряет непоказанных детей — guard проверяет children===undefined, а канон незагруженного узла — children:[] #525

Closed
opened 2026-07-12 00:55:06 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема / Цель

На realtime-пути дерева страниц (серверные broadcast'ы moveTreeNode / addTreeNode) вставка узла в свёрнутую-незагруженную папку молча прячет её реальные серверные дети — тот же класс потери данных #159 #1, который insertByPosition формально должен предотвращать, но не предотвращает из-за неверного предиката «незагруженности».

Для кого: любой пользователь, у которого в дереве есть свёрнутые папки, пока другой пользователь/агент перемещает или создаёт страницу под такой папкой (или под ней самим приходит собственный echo).

Контекст и исследование

Найдено при проектировании #523 (адверсариальный разбор), как отдельный предсуществующий баг — здесь фиксируется, чтобы не чинить его молчком внутри #523.

Файлы:

  • apps/client/src/features/page/tree/model/tree-model.tsinsertByPosition (guard на строке ~137).
  • apps/client/src/features/page/tree/utils/utils.tspageToTreeNode (строка ~39, children: []), pruneCollapsedChildren (строка ~321, сброс свёрнутых веток в children: []).
  • apps/client/src/features/page/tree/components/space-tree.tsxhandleToggle lazy-load gate (строки ~283–287): фетчит когда node.hasChildren && (!node.children || node.children.length === 0).
  • Потребители insertByPosition: placeByPosition (tree-model.ts ~314) и обработчики realtime-редьюсеров (applyAddTreeNode / applyMoveTreeNode).

Суть рассогласования (R3 — что означает «незагружено»):

  • insertByPosition считает незагруженным только parent.children === undefined (tree-model.ts ~137) и в этом случае корректно не материализует список, а лишь ставит hasChildren: true.
  • Но канонический незагруженный вид в этом коде — children: [], а не undefined: pageToTreeNode всегда ставит children: [], pruneCollapsedChildren сбрасывает свёрнутые ветки в children: [].
  • Значит для реального незагруженного узла (children: []) guard === undefined не срабатывает, код проваливается ниже и вставляет [node] в пустой список. Далее lazy-load gate видит length === 1 !== 0 → фетч полного набора не происходит → остальные серверные дети папки не видны до полной перезагрузки (где pruneCollapsedChildren снова сбросит ветку в []).

Gate в handleToggle использует правильный предикат (|| length === 0); рассогласован именно insertByPosition.

Дизайн

Привести предикат «незагружено» в insertByPosition в соответствие с gate: считать узел незагруженным, если у него hasChildren === true && (children == null || children.length === 0) — тогда не материализовать [node], а только выставить hasChildren: true (как уже делает существующая ветка guard'а). Для действительно загруженного родителя (children.length > 0) поведение не меняется — вставка по позиции корректна.

Канон «что такое незагруженный узел» — предикат gate в handleToggle. Оба места (insertByPosition и gate) должны использовать один и тот же предикат; желательно вынести его в общий хелпер (напр. treeModel.isUnloadedBranch(node)), чтобы не разъезжались снова (R3 — механический guard вместо «держим в синхроне»).

Границы scope

  • НЕ трогает DnD-путь (treeModel.move / handleMove) — им занимается #523.
  • Только клиентское дерево; серверных/схемных изменений нет.

Критерии приёмки

Проверяется на стенде с двумя клиентами (или клиент + агент через MCP):

  1. У клиента A есть папка F со ≥2 детьми на сервере, F свёрнута и её дети не подгружались в этой сессии (children: [], hasChildren: true). Клиент B перемещает/создаёт страницу X под F. После прихода broadcast'а на A: раскрытие F показывает полный список детей F (старые + X), а не только X.
  2. Тот же сценарий, но F уже раскрыта и загружена у A — X вставляется на корректную позицию среди существующих детей (без регрессии).
  3. Юнит-тест на insertByPosition: вставка в узел с children: [] + hasChildren: true не материализует [node], а выставляет hasChildren: true и оставляет children в незагруженном виде (gate потом фетчит полный набор).

Отказы и наблюдаемость

Данные на сервере корректны; баг чисто визуальный (частичный список до перезагрузки). После фикса ручное раскрытие догружает полный набор. Rollback тривиален (предикат в одном месте).

Совместимость и миграции

Изменений дефолтов для пользователей нет — только устраняется потеря видимости. Миграций нет.

Решённые развилки

  • Где канон «незагружено» → предикат gate handleToggle (|| length===0) → он уже используется для решения о фетче (дизайн-очевидность).
  • Чинить ли внутри #523 → нет, отдельным ишью → предсуществующий баг на другом (realtime) пути; молчаливый воркараунд в фиче-ишью запрещён (R10).

Связи

Обнаружен при проектировании #523 (fix(page-tree/dnd): авто-раскрытие узла при drag). Родственный корневой класс: #159 #1 (потеря детей при материализации частичного списка). Независим от #523, может приземляться отдельно.

## Проблема / Цель На **realtime-пути** дерева страниц (серверные broadcast'ы `moveTreeNode` / `addTreeNode`) вставка узла в свёрнутую-незагруженную папку молча прячет её реальные серверные дети — тот же класс потери данных #159 #1, который `insertByPosition` формально должен предотвращать, но не предотвращает из-за неверного предиката «незагруженности». Для кого: любой пользователь, у которого в дереве есть свёрнутые папки, пока другой пользователь/агент перемещает или создаёт страницу под такой папкой (или под ней самим приходит собственный echo). ## Контекст и исследование Найдено при проектировании [#523](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/523) (адверсариальный разбор), как отдельный предсуществующий баг — здесь фиксируется, чтобы не чинить его молчком внутри #523. Файлы: - `apps/client/src/features/page/tree/model/tree-model.ts` — `insertByPosition` (guard на строке ~137). - `apps/client/src/features/page/tree/utils/utils.ts` — `pageToTreeNode` (строка ~39, `children: []`), `pruneCollapsedChildren` (строка ~321, сброс свёрнутых веток в `children: []`). - `apps/client/src/features/page/tree/components/space-tree.tsx` — `handleToggle` lazy-load gate (строки ~283–287): фетчит когда `node.hasChildren && (!node.children || node.children.length === 0)`. - Потребители `insertByPosition`: `placeByPosition` (tree-model.ts ~314) и обработчики realtime-редьюсеров (`applyAddTreeNode` / `applyMoveTreeNode`). Суть рассогласования (R3 — что означает «незагружено»): - `insertByPosition` считает незагруженным **только** `parent.children === undefined` (tree-model.ts ~137) и в этом случае корректно не материализует список, а лишь ставит `hasChildren: true`. - Но канонический незагруженный вид в этом коде — **`children: []`**, а не `undefined`: `pageToTreeNode` всегда ставит `children: []`, `pruneCollapsedChildren` сбрасывает свёрнутые ветки в `children: []`. - Значит для реального незагруженного узла (`children: []`) guard `=== undefined` **не срабатывает**, код проваливается ниже и вставляет `[node]` в пустой список. Далее lazy-load gate видит `length === 1 !== 0` → фетч полного набора не происходит → остальные серверные дети папки не видны до полной перезагрузки (где `pruneCollapsedChildren` снова сбросит ветку в `[]`). Gate в `handleToggle` использует **правильный** предикат (`|| length === 0`); рассогласован именно `insertByPosition`. ## Дизайн Привести предикат «незагружено» в `insertByPosition` в соответствие с gate: считать узел незагруженным, если у него `hasChildren === true && (children == null || children.length === 0)` — тогда не материализовать `[node]`, а только выставить `hasChildren: true` (как уже делает существующая ветка guard'а). Для действительно загруженного родителя (`children.length > 0`) поведение не меняется — вставка по позиции корректна. Канон «что такое незагруженный узел» — предикат gate в `handleToggle`. Оба места (`insertByPosition` и gate) должны использовать один и тот же предикат; желательно вынести его в общий хелпер (напр. `treeModel.isUnloadedBranch(node)`), чтобы не разъезжались снова (R3 — механический guard вместо «держим в синхроне»). ## Границы scope - НЕ трогает DnD-путь (`treeModel.move` / `handleMove`) — им занимается [#523](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/523). - Только клиентское дерево; серверных/схемных изменений нет. ## Критерии приёмки Проверяется на стенде с двумя клиентами (или клиент + агент через MCP): 1. У клиента A есть папка F со ≥2 детьми на сервере, F **свёрнута** и её дети не подгружались в этой сессии (`children: []`, `hasChildren: true`). Клиент B перемещает/создаёт страницу X под F. После прихода broadcast'а на A: раскрытие F показывает **полный** список детей F (старые + X), а не только X. 2. Тот же сценарий, но F уже **раскрыта и загружена** у A — X вставляется на корректную позицию среди существующих детей (без регрессии). 3. Юнит-тест на `insertByPosition`: вставка в узел с `children: []` + `hasChildren: true` не материализует `[node]`, а выставляет `hasChildren: true` и оставляет `children` в незагруженном виде (gate потом фетчит полный набор). ## Отказы и наблюдаемость Данные на сервере корректны; баг чисто визуальный (частичный список до перезагрузки). После фикса ручное раскрытие догружает полный набор. Rollback тривиален (предикат в одном месте). ## Совместимость и миграции Изменений дефолтов для пользователей нет — только устраняется потеря видимости. Миграций нет. ## Решённые развилки - Где канон «незагружено» → предикат gate `handleToggle` (`|| length===0`) → он уже используется для решения о фетче (дизайн-очевидность). - Чинить ли внутри #523 → нет, отдельным ишью → предсуществующий баг на другом (realtime) пути; молчаливый воркараунд в фиче-ишью запрещён (R10). ## Связи Обнаружен при проектировании [#523 (fix(page-tree/dnd): авто-раскрытие узла при drag)](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/523). Родственный корневой класс: #159 #1 (потеря детей при материализации частичного списка). Независим от #523, может приземляться отдельно.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#525