Page Tree Loading Flash Masks Successful Refresh
Page Tree Loading Flash Masks Successful Refresh
Problem
After creating, deleting, or moving a page, the sidebar page tree appeared to not refresh. Users had no confirmation the action succeeded.
Symptoms:
- Tree looks unchanged after page creation (new page not visible)
- Brief flicker when tree refreshes (unmounts and remounts)
- No toast, notification, or visual feedback on success
- Newly created pages not scrolled into view (off-screen in long trees)
Investigation
Steps Tried
- Checked refresh mechanism - The
refreshKeycounter in Zustand incremented correctly on every action. ThePageTreecomponent re-fetched data vialist_page_tree. The backend used fresh SQLite connections. Data flow was correct. - Checked dialog callbacks - Dialogs had redundant
onClose()calls after success callbacks, but this was cosmetically redundant, not functionally broken. - Identified the real issue - Every
loadTree()call setloading = true, which unmounted the entire tree and showed “Loading pages…” text. For fast local SQLite reads (~5ms), this appeared as a brief flicker where the tree disappeared and reappeared looking nearly identical.
Root Cause
Perceived failure, not actual failure. The refresh mechanism worked correctly. Two compounding UX issues made it invisible:
-
Loading flash:
setLoading(true)on every fetch unmounted the tree, causing a visible flicker. Users perceived this as “nothing happened” because the tree disappeared and reappeared looking the same (one node added/removed in a long list is subtle). -
No success feedback: No toast or notification. The only signal was the tree subtly changing, which is easy to miss.
-
New page off-screen: After creation, the tree didn’t scroll to the new page if it was below the viewport.
Solution
Four changes, ordered by impact:
1. Eliminate loading flash on refresh
Track whether this is the first load vs. a subsequent refresh. Only show the loading skeleton on initial mount.
// Before (problematic)const loadTree = useCallback(async () => { setLoading(true); // Unmounts tree on EVERY refresh // ...}, []);
// After (fixed)const hasLoadedOnce = useRef(false);
const loadTree = useCallback(async () => { if (!hasLoadedOnce.current) { setLoading(true); // Only on initial mount } // ... hasLoadedOnce.current = true;}, []);2. Add toast notifications
Added a minimal toast system to Zustand store with auto-dismiss:
addToast: (message) => { const id = `toast-${++toastCounter}`; set((state) => ({ toasts: [...state.toasts, { id, message }] })); setTimeout(() => { set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })); }, 3000);},Wired into success handlers: “Page created”, “Page deleted”, “Page moved”, “Import complete”.
3. Scroll to newly selected page
Added data-slug attributes to tree nodes and a scroll-into-view effect:
useEffect(() => { if (selectedSlug && tree.length > 0 && containerRef.current) { requestAnimationFrame(() => { const el = containerRef.current?.querySelector( `[data-slug="${CSS.escape(selectedSlug)}"]` ); el?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }); }}, [selectedSlug, tree]);4. Fix double-close in dialogs
Removed redundant onClose()/handleClose() calls after success callbacks. The parent (App.tsx) already closes the
dialog in the success handler.
Implementation Notes
requestAnimationFrameis needed beforescrollIntoViewto let React render the updated tree firstCSS.escape()handles slugs with special characters (e.g., slashes in nested page slugs)- Toast auto-dismiss uses
setTimeoutinside the Zustand action, keeping the component stateless
Prevention
Best Practices
- Never unconditionally set loading state on data refresh. Distinguish initial load (show skeleton) from refresh
(keep stale data visible while fetching). Use a
useRefflag for this. - Always provide success feedback for user-initiated actions. Even if the UI updates, a toast confirms “your action worked.”
- Scroll to the thing the user just acted on. After creation or move, scroll the item into view so the user sees confirmation.
Warning Signs
- Loading state that unmounts content on every fetch (look for
setLoading(true)without conditional guards) - Action handlers that close a dialog but provide no other feedback
- Trees or lists that refresh data but don’t scroll to the relevant item
Pattern: Optimistic Loading State
const hasLoadedOnce = useRef(false);
const loadData = useCallback(async () => { if (!hasLoadedOnce.current) setLoading(true); try { const data = await fetchData(); setData(data); hasLoadedOnce.current = true; } catch (err) { setError(err); } finally { setLoading(false); }}, []);References
- Commit:
aa298b6on branchmatt/ink-50-create-page-does-not-persist-to-filesystem - Linear: INK-51
- Files:
PageTree.tsx,appStore.ts,Toast.tsx,App.tsx,CreatePageDialog.tsx,DeletePageDialog.tsx,MovePageDialog.tsx
E2E Test Robustness Patterns - Condition-Based Waits and Error Simulation Next
Setting Up Vitest in a Tauri + Vite Monorepo Package
Was this page helpful?
Thanks for your feedback!