Skip to content
Documentation GitHub
Test Failures

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

  1. Checked refresh mechanism - The refreshKey counter in Zustand incremented correctly on every action. The PageTree component re-fetched data via list_page_tree. The backend used fresh SQLite connections. Data flow was correct.
  2. Checked dialog callbacks - Dialogs had redundant onClose() calls after success callbacks, but this was cosmetically redundant, not functionally broken.
  3. Identified the real issue - Every loadTree() call set loading = 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:

  1. 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).

  2. No success feedback: No toast or notification. The only signal was the tree subtly changing, which is easy to miss.

  3. 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:

appStore.ts
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

  • requestAnimationFrame is needed before scrollIntoView to let React render the updated tree first
  • CSS.escape() handles slugs with special characters (e.g., slashes in nested page slugs)
  • Toast auto-dismiss uses setTimeout inside 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 useRef flag 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: aa298b6 on branch matt/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

Was this page helpful?