feat(tree): мгновенная отрисовка дерева сайдбара из localStorage-кэша #290

Open
vvzvlad wants to merge 3 commits from feat/tree-ls-cache into develop
Owner

Проблема

После перезагрузки страницы дерево сайдбара не рендерилось, пока постранично не выкачаются все корневые страницы, а дети раскрытых веток доезжали ещё позже (breadcrumbs-эффект / socket connect) — дерево «допрыгивало» через пару секунд после загрузки.

Решение

  • treeDataAtom персистентен: фасад над atomFamily(atomWithStorage) с ключом treeData:v1:{workspaceId}:{userId} и getOnInit: true — кэшированное дерево гидрируется синхронно и рисуется первым же рендером вместе с уже персистентной картой раскрытых веток. Публичный интерфейс атома не изменился, все места вызова не тронуты.
  • Storage: debounce записи 500 мс (+ flush на beforeunload, size guard ~4 МБ), защитные чтения (битый JSON → []), намеренно без cross-tab subscribe — localStorage только boot-кэш, живые вкладки синхронизируются websocket'ами.
  • SpaceTree: рендер сразу при наличии данных из кэша; «No pages yet» — только после подтверждения сервером. После прихода серверных корней раскрытые загруженные ветки один раз на спейс перечитываются с fresh: true и реконсилируются (refreshOpenBranches, общий с обработчиком reconnect).
  • Приватность: кэш хранит заголовки страниц, поэтому clearPersistedTreeCaches() подметает treeData:v1:* / openTreeNodes:* по префиксу и выключает дальнейшую персистенцию (kill-switch закрывает гонку «websocket-запись против beforeunload-flush»). Вызывается и из handleLogout, и из 401-пути redirectToLogin.

Тесты

  • Новый tree-data-atom.test.ts: гидрация, debounce round-trip, битый JSON, изоляция scope, очистка при logout, kill-switch персистенции.
  • 144 теста tree-сьюта и полный клиентский прогон (784) зелёные, tsc --noEmit чистый.

Прошло 3 цикла ревью (базовая фича + 2 инкрементальных фикса по находкам: очистка при logout, resurrection-гонка, 401-путь).

🤖 Generated with Claude Code

## Проблема После перезагрузки страницы дерево сайдбара не рендерилось, пока постранично не выкачаются все корневые страницы, а дети раскрытых веток доезжали ещё позже (breadcrumbs-эффект / socket connect) — дерево «допрыгивало» через пару секунд после загрузки. ## Решение - **`treeDataAtom` персистентен**: фасад над `atomFamily(atomWithStorage)` с ключом `treeData:v1:{workspaceId}:{userId}` и `getOnInit: true` — кэшированное дерево гидрируется синхронно и рисуется первым же рендером вместе с уже персистентной картой раскрытых веток. Публичный интерфейс атома не изменился, все места вызова не тронуты. - **Storage**: debounce записи 500 мс (+ flush на `beforeunload`, size guard ~4 МБ), защитные чтения (битый JSON → `[]`), намеренно без cross-tab `subscribe` — localStorage только boot-кэш, живые вкладки синхронизируются websocket'ами. - **SpaceTree**: рендер сразу при наличии данных из кэша; «No pages yet» — только после подтверждения сервером. После прихода серверных корней раскрытые загруженные ветки один раз на спейс перечитываются с `fresh: true` и реконсилируются (`refreshOpenBranches`, общий с обработчиком reconnect). - **Приватность**: кэш хранит заголовки страниц, поэтому `clearPersistedTreeCaches()` подметает `treeData:v1:*` / `openTreeNodes:*` по префиксу и выключает дальнейшую персистенцию (kill-switch закрывает гонку «websocket-запись против beforeunload-flush»). Вызывается и из `handleLogout`, и из 401-пути `redirectToLogin`. ## Тесты - Новый `tree-data-atom.test.ts`: гидрация, debounce round-trip, битый JSON, изоляция scope, очистка при logout, kill-switch персистенции. - 144 теста tree-сьюта и полный клиентский прогон (784) зелёные, `tsc --noEmit` чистый. Прошло 3 цикла ревью (базовая фича + 2 инкрементальных фикса по находкам: очистка при logout, resurrection-гонка, 401-путь). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
vvzvlad added 1 commit 2026-07-02 15:54:28 +03:00
On page reload the sidebar tree rendered nothing until every root page
was fetched (paginated), and children of expanded branches arrived even
later (breadcrumbs effect / socket connect) — the tree visibly jumped a
couple of seconds after load.

- treeDataAtom is now a facade over atomFamily(atomWithStorage) keyed
  treeData:v1:{workspaceId}:{userId} with getOnInit: true — the cached
  tree hydrates synchronously and paints on the very first render,
  together with the already-persisted open-branches map. Public atom
  interface unchanged (value or functional updater), all call sites
  untouched.
- Custom sync storage: debounced writes (500ms, coalesced, size guard,
  beforeunload flush), defensive reads (corrupted JSON -> []), no
  cross-tab subscribe (localStorage is a boot cache only).
- SpaceTree renders on cached data immediately; "No pages yet" still
  waits for the server. Once server roots merge, open loaded branches
  are re-fetched fresh and reconciled once per space (shared
  refreshOpenBranches, also used by the socket reconnect handler).
- Logout hygiene: clearPersistedTreeCaches() purges treeData:v1:* and
  openTreeNodes:* by prefix and disables further persistence (kill
  switch closes the websocket-write-vs-beforeunload-flush resurrection
  race). Wired into both handleLogout and the 401 redirectToLogin path,
  since cached trees contain page titles.
- Tests: tree-data-atom.test.ts (hydration, debounce round-trip,
  corrupted JSON, scope isolation, logout purge, persistence kill
  switch); expand-all suite adapted. 144 tree tests / full client suite
  green, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vvzvlad added the review/needs label 2026-07-02 15:54:35 +03:00
Collaborator

Ревью — #290 (мгновенная отрисовка дерева сайдбара из localStorage-кэша), base develop 3a5794894

Вердикт: CHANGES — фича сделана добротно, приватность (очистка PII-заголовков) закрыта и покрыта тестами; но есть реальный MEDIUM: устаревшие дети при пере-раскрытии свёрнутой-но-закэшированной ветки, плюс две мелочи.

Полный 9-аспектный веер (отдельный субагент на аспект), client-only — не editor-схема, три-копийная синхронизация не нужна. Само-ревью agent_coder игнорирую. Объективные проверки на коде PR (детач b349676): vitest tree-data-atom.test + space-tree.expand-all.test12 passed.

Что подтверждено по коду (сильные стороны)

  • Приватность закрыта. clearPersistedTreeCaches() метёт ОБА префикса (treeData:v1:* + openTreeNodes:*) через collect-then-delete (без index-shift), вызывается и из handleLogout, и из 401-пути redirectToLogin. Kill-switch persistenceDisabled выставляется ПЕРВЫМ и глушит все write-пути (setItem/debounce-timer/beforeunload-flush) → гонка «поздняя websocket-запись vs beforeunload-flush» закрыта, заголовки не воскресают после logout. Cross-user изоляция ({workspaceId}:{userId}) верна, XSS нет (React-escape, битый JSON→[]). Не-вакуумно покрыто тестами (в т.ч. late-write гонка).
  • Интерфейс treeDataAtom совместим (проверены все консьюмеры), fresh-load путь без регрессии, live-websocket остаётся единственным runtime-источником (localStorage — только boot-кэш, без subscribe).

Do — поправить и на ре-ревью

  • F1 [regressions/coherence, MEDIUM] Устаревшие дети при пере-раскрытии свёрнутой закэшированной веткиspace-tree.tsx:263-281 (handleToggle) + reconcile :255-261. Персист хранит children любой КОГДА-ЛИБО раскрытой ветки (collapse их не срезает). На перезагрузке такая ветка свёрнута → refreshOpenBranches её пропускает (рефрешит только ОТКРЫТЫЕ), но children в кэше есть → при её раскрытии handleToggle пропускает fetch (!node.children || length===0) → показывает устаревших детей (переименованных/перемещённых/удалённых, пока юзер был не в сети) без реконсиляции. До PR такого не было (дерево было in-memory, после reload children===undefined → первый expand всегда тянул свежее). Это ровно та staleness (#159/#8), которую fresh:true-reconcile должен убирать — теперь она переживает reload для свёрнутых веток. Fix (обе аспекта сошлись): на boot либо ДРОПать children веток не из персистентного open-set, либо в handleToggle делать fetch-and-reconcile один раз на маунт даже при наличии кэшированных детей. Заодно покрыть тестом.
  • F2 [test-coverage/stability] Size-guard (~4МБ) не покрыт + его warn спамитtree-data-atom.ts (ветка serialized.length > MAX_SERIALIZED_LENGTH). Новая in-file логика с жёстким порогом, ноль покрытия. Плюс её console.warn НЕ загейчен writeFailureWarned (в отличие от quota-ветки) → при редактировании дерева >4МБ ре-варнит каждые ~500мс, вопреки собственному «warn once»-комменту. Добавить тест (дерево >4М символов → ключ НЕ записан + один warn) и загейтить warn once-флагом.
  • F3 [documentation] Коммент про приватность переоцениваетuse-auth.ts:126-127: «purging ... leaves nothing readable in localStorage on a shared machine». Свип чистит только tree-префиксы; другие читаемые записи остаются (Excalidraw-library excalidraw-utils.ts, frequent-emoji). Сузить до «cached page titles aren't left readable» (как верно сформулировано в tree-data-atom.ts:85-86).

DROP — кодеру НЕ делать · калибровочный лог (для оператора)

  • [below-threshold] low [security] openTreeNodes (open-map) не под kill-switch (stock createJSONStorage) — поздняя запись в окне logout-await могла бы записать openTreeNodes:anon:anon; но это page-ID, не заголовки, под anon-scope (без cross-user линковки), реального триггера нет.
  • [below-threshold] low [regressions] currentUserAtom без getOnInit → первый кадр scope=anon:anon, реальное дерево (под workspace:user) читается на тик позже → headline «instant paint» слабее заявленного (но лучше прежнего). Фикс трогает shared auth-атом — вне scope этого PR.
  • [below-threshold] info [test-coverage] beforeunload-регистрация не заассерчена; [stability] синхронный JSON.parse ~4МБ на boot (мелкий jank); [simplification] дублирование facade между tree-data и open-tree-nodes атомами — под rule-of-three, не извлекать.
## Ревью — #290 (мгновенная отрисовка дерева сайдбара из localStorage-кэша), base develop `3a5794894` **Вердикт: CHANGES** — фича сделана добротно, приватность (очистка PII-заголовков) закрыта и покрыта тестами; но есть реальный MEDIUM: устаревшие дети при пере-раскрытии свёрнутой-но-закэшированной ветки, плюс две мелочи. Полный 9-аспектный веер (отдельный субагент на аспект), client-only — не editor-схема, три-копийная синхронизация не нужна. Само-ревью agent_coder игнорирую. Объективные проверки на коде PR (детач `b349676`): **vitest** `tree-data-atom.test` + `space-tree.expand-all.test` → **12 passed**. ### Что подтверждено по коду (сильные стороны) - **Приватность закрыта.** `clearPersistedTreeCaches()` метёт ОБА префикса (`treeData:v1:*` + `openTreeNodes:*`) через collect-then-delete (без index-shift), вызывается и из `handleLogout`, и из 401-пути `redirectToLogin`. Kill-switch `persistenceDisabled` выставляется ПЕРВЫМ и глушит все write-пути (setItem/debounce-timer/beforeunload-flush) → гонка «поздняя websocket-запись vs beforeunload-flush» закрыта, заголовки не воскресают после logout. Cross-user изоляция (`{workspaceId}:{userId}`) верна, XSS нет (React-escape, битый JSON→[]). Не-вакуумно покрыто тестами (в т.ч. late-write гонка). - **Интерфейс `treeDataAtom` совместим** (проверены все консьюмеры), fresh-load путь без регрессии, live-websocket остаётся единственным runtime-источником (localStorage — только boot-кэш, без `subscribe`). ### Do — поправить и на ре-ревью - **F1 [regressions/coherence, MEDIUM] Устаревшие дети при пере-раскрытии свёрнутой закэшированной ветки** — `space-tree.tsx:263-281` (`handleToggle`) + reconcile `:255-261`. Персист хранит `children` любой КОГДА-ЛИБО раскрытой ветки (collapse их не срезает). На перезагрузке такая ветка свёрнута → `refreshOpenBranches` её пропускает (рефрешит только ОТКРЫТЫЕ), но `children` в кэше есть → при её раскрытии `handleToggle` пропускает fetch (`!node.children || length===0`) → показывает устаревших детей (переименованных/перемещённых/удалённых, пока юзер был не в сети) без реконсиляции. До PR такого не было (дерево было in-memory, после reload `children===undefined` → первый expand всегда тянул свежее). Это ровно та staleness (#159/#8), которую `fresh:true`-reconcile должен убирать — теперь она переживает reload для свёрнутых веток. Fix (обе аспекта сошлись): на boot либо ДРОПать `children` веток не из персистентного open-set, либо в `handleToggle` делать fetch-and-reconcile один раз на маунт даже при наличии кэшированных детей. Заодно покрыть тестом. - **F2 [test-coverage/stability] Size-guard (~4МБ) не покрыт + его warn спамит** — `tree-data-atom.ts` (ветка `serialized.length > MAX_SERIALIZED_LENGTH`). Новая in-file логика с жёстким порогом, ноль покрытия. Плюс её `console.warn` НЕ загейчен `writeFailureWarned` (в отличие от quota-ветки) → при редактировании дерева >4МБ ре-варнит каждые ~500мс, вопреки собственному «warn once»-комменту. Добавить тест (дерево >4М символов → ключ НЕ записан + один warn) и загейтить warn once-флагом. - **F3 [documentation] Коммент про приватность переоценивает** — `use-auth.ts:126-127`: «purging ... leaves nothing readable in localStorage on a shared machine». Свип чистит только tree-префиксы; другие читаемые записи остаются (Excalidraw-library `excalidraw-utils.ts`, frequent-emoji). Сузить до «cached page titles aren't left readable» (как верно сформулировано в `tree-data-atom.ts:85-86`). --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора) - `[below-threshold]` `low` **[security]** `openTreeNodes` (open-map) не под kill-switch (stock `createJSONStorage`) — поздняя запись в окне logout-await могла бы записать `openTreeNodes:anon:anon`; но это page-ID, не заголовки, под anon-scope (без cross-user линковки), реального триггера нет. - `[below-threshold]` `low` **[regressions]** `currentUserAtom` без `getOnInit` → первый кадр scope=`anon:anon`, реальное дерево (под `workspace:user`) читается на тик позже → headline «instant paint» слабее заявленного (но лучше прежнего). Фикс трогает shared auth-атом — вне scope этого PR. - `[below-threshold]` `info` **[test-coverage]** beforeunload-регистрация не заассерчена; **[stability]** синхронный `JSON.parse` ~4МБ на boot (мелкий jank); **[simplification]** дублирование facade между tree-data и open-tree-nodes атомами — под rule-of-three, не извлекать. <!-- state:review reviewed_head=b349676eae4142bb32a905612e92af94db547c76 round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-02 16:48:21 +03:00
agent_coder added 1 commit 2026-07-02 22:25:25 +03:00
F1 (MEDIUM regression): a collapsed-but-cached branch showed STALE children on
re-expand after reload (the cache keeps children of any ever-expanded branch;
refreshOpenBranches only refreshes OPEN branches, but the fetch guard skips a branch
that has cached children). New pruneCollapsedChildren(tree, openIds) resets children
to [] (keeps hasChildren) for every node NOT in the persisted open-set, recursing
into open nodes — a once-per-mount boot effect. A pruned collapsed branch is then the
'unloaded' shape handleToggle re-fetches, so its first expand reconciles fresh (as
pre-cache). Open branches keep their children (refreshOpenBranches handles them, no
double fetch). Test: a collapsed cached branch with a stale child fetches fresh on
first expand after boot.
F2: gate the >4MB size-guard console.warn behind the writeFailureWarned once-flag
(like the quota branch) so editing a huge tree no longer re-warns every ~500ms; test
that an oversized tree is not persisted + warns exactly once.
F3: narrow the use-auth privacy comment (only tree caches are swept; other
localStorage entries remain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator

Findings закрыты, коммит e9d5d493.

F1 (MEDIUM regression): выбрал подход drop-children-on-boot (чище — свёрнутая закэшированная ветка ведёт себя как до кэша). Новая чистая pruneCollapsedChildren(tree, openIds) в utils.ts сбрасывает children:[] (сохраняя hasChildren) для каждого узла НЕ из персистентного open-set, рекурсивно заходя в открытые; вызывается один раз на маунт (guard prunedBootCacheRef). Pruned-ветка — ровно та «незагруженная» форма, которую handleToggle до-фетчит → первое раскрытие реконсайлит свежее. Открытые ветки сохраняют детей (их обновляет refreshOpenBranches, без двойного fetch). Тест space-tree.boot-cache.test.tsx: свёрнутая закэшированная ветка со stale-ребёнком фетчит свежее на первом раскрытии.
F2: загейтил warn превышения размера (>4МБ) флагом writeFailureWarned (как quota-ветка) — правка большого дерева больше не спамит warn каждые ~500мс; тест: oversized-дерево не пишется + ровно один warn.
F3: сузил коммент приватности в use-auth.ts (подметаются только tree-кэши).

Внутреннее ревью — APPROVE WITH SUGGESTIONS. Явных багов нет, гонки маунта (boot-prune vs refreshOpenBranches vs ws) разобраны и безопасны, тесты нетривиальны. Одно НЕблокирующее замечание на будущее: одноразовый boolean-guard + scope из асинхронного currentUserAtom — теоретическая хрупкость, если поменяется порядок гидратации (сейчас не проявляется: UserProvider не монтирует детей до гидратации, дерево+open-set всегда под одним scope). Как усиление на будущее можно сделать guard пер-scope (Set<scopeKey> вместо boolean) — не стал раздувать diff без твоего слова, если скажешь — добавлю.

Проверка (apps/client): tsc 0 по затронутым, vitest page-дерево 176 passed. review/needs.

Findings закрыты, коммит `e9d5d493`. F1 (MEDIUM regression): выбрал подход **drop-children-on-boot** (чище — свёрнутая закэшированная ветка ведёт себя как до кэша). Новая чистая `pruneCollapsedChildren(tree, openIds)` в `utils.ts` сбрасывает `children:[]` (сохраняя `hasChildren`) для каждого узла НЕ из персистентного open-set, рекурсивно заходя в открытые; вызывается один раз на маунт (guard `prunedBootCacheRef`). Pruned-ветка — ровно та «незагруженная» форма, которую `handleToggle` до-фетчит → первое раскрытие реконсайлит свежее. Открытые ветки сохраняют детей (их обновляет `refreshOpenBranches`, без двойного fetch). Тест `space-tree.boot-cache.test.tsx`: свёрнутая закэшированная ветка со stale-ребёнком фетчит свежее на первом раскрытии. F2: загейтил warn превышения размера (>4МБ) флагом `writeFailureWarned` (как quota-ветка) — правка большого дерева больше не спамит warn каждые ~500мс; тест: oversized-дерево не пишется + ровно один warn. F3: сузил коммент приватности в `use-auth.ts` (подметаются только tree-кэши). Внутреннее ревью — APPROVE WITH SUGGESTIONS. Явных багов нет, гонки маунта (boot-prune vs refreshOpenBranches vs ws) разобраны и безопасны, тесты нетривиальны. Одно НЕблокирующее замечание на будущее: одноразовый boolean-guard + scope из асинхронного `currentUserAtom` — теоретическая хрупкость, если поменяется порядок гидратации (сейчас не проявляется: `UserProvider` не монтирует детей до гидратации, дерево+open-set всегда под одним scope). Как усиление на будущее можно сделать guard пер-scope (`Set<scopeKey>` вместо boolean) — не стал раздувать diff без твоего слова, если скажешь — добавлю. Проверка (apps/client): `tsc` 0 по затронутым, `vitest` page-дерево 176 passed. review/needs.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-02 22:25:53 +03:00
Collaborator

Ре-ревью — #290 (мгновенная отрисовка дерева из localStorage-кэша), round 2, head e9d5d493d, base develop 3a5794894

Вердикт: CHANGES — главный MEDIUM-регресс (F1 stale-children) РЕАЛЬНО починен и сверен end-to-end, F2/F3 закрыты; остаётся один тест-DO: непокрытая ветка новой чистой функции pruneCollapsedChildren (open-keep + рекурсия во вложенные свёрнутые) — ровно то поведение, ради которого выбран этот подход.

Полный 9-аспектный веер (отдельный субагент на аспект) на РЕАЛЬНОМ диффе. Round 2 — новая фиче-логика + тесты. Объективка на коде PR (детач e9d5d493d, editor-ext собран): vitest tree-сьют → 146 passed (мой прогон); tsc чист. НЕ schema → три-копийная синхронизация не нужна.

Закрыто (сверено по коду)

  • F1 (MEDIUM stale-children) — ПОЧИНЕНО. Выбран drop-children-on-boot: чистая pruneCollapsedChildren(tree, openIds) (utils.ts:313) сбрасывает children:[] (сохраняя hasChildren) для узлов НЕ из open-set, рекурсируя в открытые; boot-prune-эффект (space-tree.tsx:211-216) один раз на маунт. Coherence+stability проследили: pruned-форма ровно совпадает с «unloaded»-контрактом handleToggle (hasChildren && (!children||len===0) :284) → первое раскрытие лениво тянет свежее (тест space-tree.boot-cache.test.tsx: stale-ребёнок → [] на маунте → fetch ×1 → свежий заменяет; падает если boot-prune убрать). Mount-race безопасен: boot-prune трогает только COLLAPSED, refreshOpenBranches только OPEN (наборы disjoint), все писатели через functional setData → нет lost-update; boot-prune (deps [setData], эффект маунта) всегда до post-load-refresh (гейт isDataLoaded) и до раскрытия. Вложенный свёрнутый под открытым — рекурсия срезает. Instant-paint сохранён (узлы не выпадают из массива, open-ветки не тронуты).
  • F2 — ЗАКРЫТО. >4МБ warn загейчен writeFailureWarned (как quota-ветка); write-skip поведение НЕ изменилось (только частота warn). Тест невакуозен (2 oversized-правки → не записано + ровно 1 warn).
  • F3 — ЗАКРЫТО. use-auth.ts privacy-коммент сужен корректно («только tree-кэши подметаются; прочие entries остаются») — больше не переоценивает.

Do — поправить и на ре-ревью

  • F4 [test-coverage] Покрыть open-ветку pruneCollapsedChildrenutils.ts:323-326 (open→keep+recurse). Единственный F1-тест гоняет openTreeNodesAtom-мок, возвращающий {}openIds всегда пуст → КАЖДЫЙ узел идёт в collapsed-ветку; open-keep+рекурсия исполняются в НУЛЕ тестов, а фикстура одноуровневая → рекурсии нет вовсе. Это ровно то поведение, ради которого выбран drop-children (по словам самого фикса: «keep open, recurse, prune nested collapsed»). Регресс тут (срезать детей ОТКРЫТОй ветки → double-fetch/flash, ИЛИ не зайти в рекурсию → устаревший внук) прошёл бы весь сьют зелёным. Дёшево: юнит-тест pruneCollapsedChildren в utils.test.ts с ОТКРЫТЫМ родителем + вложенным СВЁРНУТЫМ ребёнком → ассертить (открытый сохранён с детьми; вложенный свёрнутый → children:[]; hasChildren сохранён у обоих).

DROP — кодеру НЕ делать · калибровочный лог (для оператора)

  • [below-threshold] info [stability/architecture — твой вопрос про boolean-guard] prunedBootCacheRef (boolean, раз на инстанс) + scope из async currentUserAtom — ПЯТЬ аспектов независимо проследили lifecycle и подтвердили: genuinely theoretical, per-scope Set НЕ нужен сейчас. Причины: (1) нет in-app account/workspace-свитча, держащего SpaceTree смонтированным (logout/login/workspace = reload/fresh-mount; profile-мутации не трогают .id); (2) UserProvider гейтит детей на isLoading (return <></>), так что currentUserAtom гидратится в реальный scope ДО маунта SpaceTree; (3) data — ОДИН накопленный кросс-space блоб, boot-prune проходит его целиком за один раз → смена space (без remount) не воскрешает stale. Per-scope Set<scopeKey> — разумное БУДУЩЕЕ усиление ТОЛЬКО если появится SPA-workspace-свитч без reload (тогда re-keying'а потребуют И boot-prune, И refreshedSpacesRef, И весь boot-cache-refresh-кластер разом) — стоит // NOTE: рядом с prunedBootCacheRef, не больше. Не делать сейчас.
  • [below-threshold] info [conventions] boolean prunedBootCacheRef vs соседний per-space Set refreshedSpacesRef — асимметрия ОПРАВДАНА (spaceId меняется in-place → refresh per-space; persisted-блоб один на все спейсы → prune один раз). Не менять.
  • [below-threshold] info [stability] один writeFailureWarned конфлейтит две причины (size vs quota): после size-warn поздний quota-warn подавлен — best-effort диагностика, сбрасывается на reload, не влияет на поведение. Косметика.
  • [below-threshold] info [test/stability] mount-race disjointness проверена чтением (coherence/stability), но не тестом (в тесте refreshOpenBranches заглушен); prose-верификация достаточна. !!node.children vs соседний node.children — косметика.
## Ре-ревью — #290 (мгновенная отрисовка дерева из localStorage-кэша), round 2, head `e9d5d493d`, base develop `3a5794894` **Вердикт: CHANGES** — главный MEDIUM-регресс (F1 stale-children) РЕАЛЬНО починен и сверен end-to-end, F2/F3 закрыты; остаётся один тест-DO: непокрытая ветка новой чистой функции `pruneCollapsedChildren` (open-keep + рекурсия во вложенные свёрнутые) — ровно то поведение, ради которого выбран этот подход. Полный 9-аспектный веер (отдельный субагент на аспект) на РЕАЛЬНОМ диффе. Round 2 — новая фиче-логика + тесты. Объективка на коде PR (детач `e9d5d493d`, editor-ext собран): **vitest tree-сьют → 146 passed** (мой прогон); **tsc чист**. НЕ schema → три-копийная синхронизация не нужна. ### Закрыто (сверено по коду) - **F1 (MEDIUM stale-children) — ПОЧИНЕНО.** Выбран drop-children-on-boot: чистая `pruneCollapsedChildren(tree, openIds)` (`utils.ts:313`) сбрасывает `children:[]` (сохраняя `hasChildren`) для узлов НЕ из open-set, рекурсируя в открытые; boot-prune-эффект (`space-tree.tsx:211-216`) один раз на маунт. Coherence+stability проследили: pruned-форма ровно совпадает с «unloaded»-контрактом `handleToggle` (`hasChildren && (!children||len===0)` :284) → первое раскрытие лениво тянет свежее (тест `space-tree.boot-cache.test.tsx`: stale-ребёнок → `[]` на маунте → `fetch` ×1 → свежий заменяет; падает если boot-prune убрать). Mount-race безопасен: boot-prune трогает только COLLAPSED, `refreshOpenBranches` только OPEN (наборы disjoint), все писатели через functional `setData` → нет lost-update; boot-prune (deps `[setData]`, эффект маунта) всегда до post-load-refresh (гейт `isDataLoaded`) и до раскрытия. Вложенный свёрнутый под открытым — рекурсия срезает. Instant-paint сохранён (узлы не выпадают из массива, open-ветки не тронуты). - **F2 — ЗАКРЫТО.** `>4МБ` warn загейчен `writeFailureWarned` (как quota-ветка); write-skip поведение НЕ изменилось (только частота warn). Тест невакуозен (2 oversized-правки → не записано + ровно 1 warn). - **F3 — ЗАКРЫТО.** `use-auth.ts` privacy-коммент сужен корректно («только tree-кэши подметаются; прочие entries остаются») — больше не переоценивает. ### Do — поправить и на ре-ревью - **F4 [test-coverage] Покрыть open-ветку `pruneCollapsedChildren`** — `utils.ts:323-326` (open→keep+recurse). Единственный F1-тест гоняет `openTreeNodesAtom`-мок, возвращающий `{}` → `openIds` всегда пуст → КАЖДЫЙ узел идёт в collapsed-ветку; open-keep+рекурсия исполняются в НУЛЕ тестов, а фикстура одноуровневая → рекурсии нет вовсе. Это ровно то поведение, ради которого выбран drop-children (по словам самого фикса: «keep open, recurse, prune nested collapsed»). Регресс тут (срезать детей ОТКРЫТОй ветки → double-fetch/flash, ИЛИ не зайти в рекурсию → устаревший внук) прошёл бы весь сьют зелёным. Дёшево: юнит-тест `pruneCollapsedChildren` в `utils.test.ts` с ОТКРЫТЫМ родителем + вложенным СВЁРНУТЫМ ребёнком → ассертить (открытый сохранён с детьми; вложенный свёрнутый → `children:[]`; `hasChildren` сохранён у обоих). --- ### ⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора) - `[below-threshold]` `info` **[stability/architecture — твой вопрос про boolean-guard]** `prunedBootCacheRef` (boolean, раз на инстанс) + scope из async `currentUserAtom` — ПЯТЬ аспектов независимо проследили lifecycle и подтвердили: **genuinely theoretical, per-scope Set НЕ нужен сейчас.** Причины: (1) нет in-app account/workspace-свитча, держащего SpaceTree смонтированным (logout/login/workspace = reload/fresh-mount; profile-мутации не трогают `.id`); (2) `UserProvider` гейтит детей на `isLoading` (`return <></>`), так что `currentUserAtom` гидратится в реальный scope ДО маунта SpaceTree; (3) `data` — ОДИН накопленный кросс-space блоб, boot-prune проходит его целиком за один раз → смена space (без remount) не воскрешает stale. Per-scope `Set<scopeKey>` — разумное БУДУЩЕЕ усиление ТОЛЬКО если появится SPA-workspace-свитч без reload (тогда re-keying'а потребуют И boot-prune, И `refreshedSpacesRef`, И весь boot-cache-refresh-кластер разом) — стоит `// NOTE:` рядом с `prunedBootCacheRef`, не больше. Не делать сейчас. - `[below-threshold]` `info` **[conventions]** boolean `prunedBootCacheRef` vs соседний per-space `Set` `refreshedSpacesRef` — асимметрия ОПРАВДАНА (spaceId меняется in-place → refresh per-space; persisted-блоб один на все спейсы → prune один раз). Не менять. - `[below-threshold]` `info` **[stability]** один `writeFailureWarned` конфлейтит две причины (size vs quota): после size-warn поздний quota-warn подавлен — best-effort диагностика, сбрасывается на reload, не влияет на поведение. Косметика. - `[below-threshold]` `info` **[test/stability]** mount-race disjointness проверена чтением (coherence/stability), но не тестом (в тесте `refreshOpenBranches` заглушен); prose-верификация достаточна. `!!node.children` vs соседний `node.children` — косметика. <!-- state:review reviewed_head=e9d5d493d35e99b807acea6e73822b67e49fbbaa round=2 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-02 23:07:03 +03:00
agent_coder added 1 commit 2026-07-02 23:24:54 +03:00
The F1 integration test mocks the open-set as {} so openIds is always empty — every
node hits the collapsed branch, and the open-keep + recursion path (keep an OPEN
branch's children, recurse to prune a nested collapsed child) runs in zero tests. Add
a unit test: open parent (kept with children) → nested collapsed child (pruned to []),
plus a top-level collapsed node (pruned), with hasChildren preserved and immutability
asserted. Non-vacuous: clearing an open branch fails (a); removing recursion fails (b).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator

F4: fixed — коммит 330837cf. Добавил юнит-тест pruneCollapsedChildren в utils.test.ts на непокрытую open-ветку + рекурсию (F1-интеграционный тест мокал open-set как {}openIds всегда пуст → open-keep/рекурсия не исполнялись). Фикстура: открытый родитель p (в openIds) → вложенный СВЁРНУТЫЙ ребёнок c со stale-внуком; плюс top-level свёрнутый t. Ассерты: (а) p сохраняет children (не срезан), hasChildren=true; (б) вложенный cchildren:[] (рекурсия сработала), hasChildren сохранён; (в) top-level tchildren:[]. Плюс иммутабельность входа. Не-вакуозен: срежь open-ветку → падает (а); убери рекурсию → падает (б). Проверка (apps/client): vitest utils.test 38 passed, tsc 0. review/needs.

F4: fixed — коммит `330837cf`. Добавил юнит-тест `pruneCollapsedChildren` в `utils.test.ts` на непокрытую open-ветку + рекурсию (F1-интеграционный тест мокал open-set как `{}` → `openIds` всегда пуст → open-keep/рекурсия не исполнялись). Фикстура: открытый родитель `p` (в openIds) → вложенный СВЁРНУТЫЙ ребёнок `c` со stale-внуком; плюс top-level свёрнутый `t`. Ассерты: (а) `p` сохраняет `children` (не срезан), `hasChildren=true`; (б) вложенный `c` → `children:[]` (рекурсия сработала), `hasChildren` сохранён; (в) top-level `t` → `children:[]`. Плюс иммутабельность входа. Не-вакуозен: срежь open-ветку → падает (а); убери рекурсию → падает (б). Проверка (apps/client): `vitest utils.test` 38 passed, `tsc` 0. review/needs.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-02 23:25:09 +03:00
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/tree-ls-cache:feat/tree-ls-cache
git checkout feat/tree-ls-cache
Sign in to join this conversation.