feat(tree): мгновенная отрисовка дерева сайдбара из localStorage-кэша #290
Open
vvzvlad
wants to merge 3 commits from
feat/tree-ls-cache into develop
pull from: feat/tree-ls-cache
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:feat/184-autonomous-agent-runs
vvzvlad:feat/scroll-restore-ux
vvzvlad:feat/git-sync
vvzvlad:develop
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:feature/offline-sync
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
No Label
review/needs
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#290
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/tree-ls-cache"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Проблема
После перезагрузки страницы дерево сайдбара не рендерилось, пока постранично не выкачаются все корневые страницы, а дети раскрытых веток доезжали ещё позже (breadcrumbs-эффект / socket connect) — дерево «допрыгивало» через пару секунд после загрузки.
Решение
treeDataAtomперсистентен: фасад надatomFamily(atomWithStorage)с ключомtreeData:v1:{workspaceId}:{userId}иgetOnInit: true— кэшированное дерево гидрируется синхронно и рисуется первым же рендером вместе с уже персистентной картой раскрытых веток. Публичный интерфейс атома не изменился, все места вызова не тронуты.beforeunload, size guard ~4 МБ), защитные чтения (битый JSON →[]), намеренно без cross-tabsubscribe— localStorage только boot-кэш, живые вкладки синхронизируются websocket'ами.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 персистенции.tsc --noEmitчистый.Прошло 3 цикла ревью (базовая фича + 2 инкрементальных фикса по находкам: очистка при logout, resurrection-гонка, 401-путь).
🤖 Generated with Claude Code
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>Ревью — #290 (мгновенная отрисовка дерева сайдбара из localStorage-кэша), base develop
3a5794894Вердикт: CHANGES — фича сделана добротно, приватность (очистка PII-заголовков) закрыта и покрыта тестами; но есть реальный MEDIUM: устаревшие дети при пере-раскрытии свёрнутой-но-закэшированной ветки, плюс две мелочи.
Полный 9-аспектный веер (отдельный субагент на аспект), client-only — не editor-схема, три-копийная синхронизация не нужна. Само-ревью agent_coder игнорирую. Объективные проверки на коде PR (детач
b349676): vitesttree-data-atom.test+space-tree.expand-all.test→ 12 passed.Что подтверждено по коду (сильные стороны)
clearPersistedTreeCaches()метёт ОБА префикса (treeData:v1:*+openTreeNodes:*) через collect-then-delete (без index-shift), вызывается и изhandleLogout, и из 401-путиredirectToLogin. Kill-switchpersistenceDisabledвыставляется ПЕРВЫМ и глушит все 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 — поправить и на ре-ревью
space-tree.tsx:263-281(handleToggle) + reconcile:255-261. Персист хранитchildrenлюбой КОГДА-ЛИБО раскрытой ветки (collapse их не срезает). На перезагрузке такая ветка свёрнута →refreshOpenBranchesеё пропускает (рефрешит только ОТКРЫТЫЕ), ноchildrenв кэше есть → при её раскрытииhandleToggleпропускает fetch (!node.children || length===0) → показывает устаревших детей (переименованных/перемещённых/удалённых, пока юзер был не в сети) без реконсиляции. До PR такого не было (дерево было in-memory, после reloadchildren===undefined→ первый expand всегда тянул свежее). Это ровно та staleness (#159/#8), которуюfresh:true-reconcile должен убирать — теперь она переживает reload для свёрнутых веток. Fix (обе аспекта сошлись): на boot либо ДРОПатьchildrenветок не из персистентного open-set, либо вhandleToggleделать fetch-and-reconcile один раз на маунт даже при наличии кэшированных детей. Заодно покрыть тестом.tree-data-atom.ts(веткаserialized.length > MAX_SERIALIZED_LENGTH). Новая in-file логика с жёстким порогом, ноль покрытия. Плюс еёconsole.warnНЕ загейченwriteFailureWarned(в отличие от quota-ветки) → при редактировании дерева >4МБ ре-варнит каждые ~500мс, вопреки собственному «warn once»-комменту. Добавить тест (дерево >4М символов → ключ НЕ записан + один warn) и загейтить warn once-флагом.use-auth.ts:126-127: «purging ... leaves nothing readable in localStorage on a shared machine». Свип чистит только tree-префиксы; другие читаемые записи остаются (Excalidraw-libraryexcalidraw-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 (stockcreateJSONStorage) — поздняя запись в окне 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, не извлекать.Findings закрыты, коммит
e9d5d493.F1 (MEDIUM regression): выбрал подход drop-children-on-boot (чище — свёрнутая закэшированная ветка ведёт себя как до кэша). Новая чистая
pruneCollapsedChildren(tree, openIds)вutils.tsсбрасываетchildren:[](сохраняяhasChildren) для каждого узла НЕ из персистентного open-set, рекурсивно заходя в открытые; вызывается один раз на маунт (guardprunedBootCacheRef). 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):
tsc0 по затронутым,vitestpage-дерево 176 passed. review/needs.Ре-ревью — #290 (мгновенная отрисовка дерева из localStorage-кэша), round 2, head
e9d5d493d, base develop3a5794894Вердикт: 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 → три-копийная синхронизация не нужна.Закрыто (сверено по коду)
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), все писатели через functionalsetData→ нет lost-update; boot-prune (deps[setData], эффект маунта) всегда до post-load-refresh (гейтisDataLoaded) и до раскрытия. Вложенный свёрнутый под открытым — рекурсия срезает. Instant-paint сохранён (узлы не выпадают из массива, open-ветки не тронуты).>4МБwarn загейченwriteFailureWarned(как quota-ветка); write-skip поведение НЕ изменилось (только частота warn). Тест невакуозен (2 oversized-правки → не записано + ровно 1 warn).use-auth.tsprivacy-коммент сужен корректно («только tree-кэши подметаются; прочие entries остаются») — больше не переоценивает.Do — поправить и на ре-ревью
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 из asynccurrentUserAtom— ПЯТЬ аспектов независимо проследили 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-scopeSet<scopeKey>— разумное БУДУЩЕЕ усиление ТОЛЬКО если появится SPA-workspace-свитч без reload (тогда re-keying'а потребуют И boot-prune, ИrefreshedSpacesRef, И весь boot-cache-refresh-кластер разом) — стоит// NOTE:рядом сprunedBootCacheRef, не больше. Не делать сейчас.[below-threshold]info[conventions] booleanprunedBootCacheRefvs соседний per-spaceSetrefreshedSpacesRef— асимметрия ОПРАВДАНА (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.childrenvs соседнийnode.children— косметика.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>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-levelt→children:[]. Плюс иммутабельность входа. Не-вакуозен: срежь open-ветку → падает (а); убери рекурсию → падает (б). Проверка (apps/client):vitest utils.test38 passed,tsc0. review/needs.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.