Repository navigation
Date navigation triggers state_unsafe_mutation and leaves the app unresponsive #1564
Description
Overview
After the reactivity refactor chain landed (#1490, #1492, #1491, #1511 — SharedNodeStore reactivity, write serialization, subscribe-in-$effect conversion, remaining $effect cleanup), date navigation in the Daily Journal view is broken. Reproduced independently in both the native Tauri dev app (bun run dev:tauri) and browser/dev-proxy mode (bun run dev:browser), so it is not proxy-specific.
Problem Statement
User Experience:
- User types
/in a node's editing area — the node being edited disappears. - User navigates to another date and back — the tab title does not match the date shown in the content pane (tab said "Today" while the heading showed "July 8, 2026").
- User cannot land on/navigate to a specific date (July 7, 2026) — navigation keeps landing on the 8th instead.
- After these interactions, the app becomes unresponsive to clicks — no node can be clicked or focused anymore.
Evidence:
- Console fires a Svelte
state_unsafe_mutationerror ("Updating state inside$derived(...),$inspect(...)or a template expression is forbidden") with this component stack:in <unknown> in date-node-viewer.svelte in pane-content.svelte in tab-system.svelte in pane-manager.svelte - An unhandled promise rejection also fires:
TypeError: Load failed at hydrateNode (pane-content.svelte:104:46). - Reproduces on a fresh page reload, not just after prior navigation — confirmed persistent, not a one-off race.
Root Cause
Location: packages/desktop-app/src/lib/components/layout/pane-content.svelte
navigateDate()indate-node-viewer.sveltecallsonNodeIdChange->updateTabContent(activeTabId, { nodeId: newNodeId, ... }), changingactiveTab.content.nodeId, and separately callsonTitleChange->updateTabTitle(...). These are two independent calls, not atomic, so title and content can desync if anything interleaves between them.pane-content.sveltehas an$effect(~line 108) that reacts toactiveTab?.content?.nodeIdand callshydrateNode(nodeId, tabId)(async, defined ~line 70).hydrateNodeawaitssharedNodeStore.ensureNode(nodeId), then mutateshydratedNodeIds(a$state<Set<string>>) viahydratedNodeIds = new Set(hydratedNodeIds).add(nodeId)(~line 104), or callscloseTab(tabId)if the node isn't found.- Several
$derived.by()blocks (isNodeHydrated,ViewerComponent,isViewerLoading) readhydratedNodeIds/activeTab.
Why it happens:
- When the underlying fetch (
ensureNode, which round-trips through the daemon/dev-proxy) is slow or fails — independently observed in this session: the dev-proxy's/api/nodes/...,/api/schemas,/api/collections,/api/eventscalls intermittently fail with "Could not connect to the server" — the asynchydrateNoderesumes and mutateshydratedNodeIdsstate during a window Svelte's reactivity tracker still associates with an in-flight derived flush, producingstate_unsafe_mutation. - Rapid/repeated date navigation can also fire the effect multiple times before earlier in-flight
hydrateNodecalls resolve, racing writes tohydratedNodeIdsfor different nodeIds — a plausible source of the tab-title/content-date desync and the inability to land on a specific date. - This pattern (mutating
$statefrom an async callback resuming during a reactive flush) is a known Svelte 5 footgun and may predate today's changes, but the reactivity-timing changes from SharedNodeStore fakes reactivity — replace the version-counter Map with a genuinely reactive per-node source #1490 (SharedNodeStore -> SvelteMap, per-key tracking) likely altered how often/when the surrounding derived blocks re-fire, changing the race window from rare to consistently reproducible. - The dev-proxy connection instability itself (WatchNodes stream / SSE intermittently failing) is a contributing/exacerbating factor, not necessarily the sole root cause, and may be broader than the scope of dev-proxy's gRPC-js channel can carry forward reconnect backoff past daemon readiness #1551 (which frames dev-proxy reconnect-backoff as a test-harness concern).
Proposed Solution
Investigate making hydrateNode's post-await state mutation safe against concurrent/overlapping calls (e.g. guard against stale in-flight requests when nodeId changes again before the previous ensureNode resolves, and/or defer the hydratedNodeIds mutation out of the reactive flush window). Consider unifying onNodeIdChange + onTitleChange into a single atomic tab-content update to eliminate the title/content desync window.
Acceptance Criteria
- Typing
/in a node's editing area no longer causes the node to disappear - Tab title always matches the date shown in the content pane after navigation
- Date navigation can reliably land on any target date, including via repeated/rapid prev/next clicks
- No
state_unsafe_mutationconsole error during date navigation - No unhandled promise rejection from
hydrateNode - App remains responsive to node clicks after date navigation
- Regression test added covering rapid date navigation and a simulated slow/failed
ensureNodefetch - Code passes
bun run quality:fix - No new failures in
bun run test:all
Technical Specifications
Reference Files
packages/desktop-app/src/lib/components/layout/pane-content.svelte—hydrateNode(~line 70), hydration$effect(~line 108),isNodeHydrated/ViewerComponent/isViewerLoadingderived blockspackages/desktop-app/src/lib/components/viewers/date-node-viewer.svelte—navigateDate(),onNodeIdChange/onTitleChangecallspackages/desktop-app/src/lib/services/shared-node-store.svelte.ts—ensureNode(),getNode()packages/desktop-app/src/lib/stores/navigation.js—updateTabContent,updateTabTitle,closeTab
Reproduction Steps
- Run the community desktop app fresh (
bun run dev:tauriorbun run dev:browser) with a clean database. - Open the Daily Journal / Today view.
- Click into the node text area, type
/— observe the node disappear. - Navigate to a different date via the prev/next day buttons, then navigate back.
- Observe tab title vs. displayed date mismatch, and/or inability to land on the originally intended date.
- Check browser console for the
state_unsafe_mutationerror and thehydrateNodeunhandled rejection. - Attempt to click any node afterward — app is unresponsive to node clicks.
Environment
Reproduced immediately after merging #1490, #1492, #1491, #1511, #1500, #1521, #1515, #1508, #1509, #1525 on a fresh main. Both the native Tauri dev app and bun run dev:browser (dev-proxy + vite) show the same symptoms.
Related Issues
- SharedNodeStore fakes reactivity — replace the version-counter Map with a genuinely reactive per-node source #1490 (reactivity change that likely altered the race timing)
- Edit persistence amplifies writes and self-conflicts during fast typing — serialize per-node writes #1492
- Convert subscribe-in-$effect components to reactive derived reads (blocked on reactive store) #1491
- Convert remaining $effect misuse to derived values and event handlers #1511
- dev-proxy's gRPC-js channel can carry forward reconnect backoff past daemon readiness #1551 (dev-proxy reconnect behavior — possibly related exacerbating factor)
Activity
malibio commentedon Jul 6, 2026
Additional reproduction: slash command alone triggers the same crash
Confirmed independently of date navigation: typing
/in a node's editing area reproduces the identicalstate_unsafe_mutationerror (same component stack:date-node-viewer.svelte->pane-content.svelte->tab-system.svelte->pane-manager.svelte), with no date navigation involved at all.Observed DOM state after the crash:
The node that was being edited is left as an empty view-mode
<div id="view__pane-1__<nodeId>">withtextContent: ""— not a<textarea>(edit mode), and not showing the typed/or any placeholder. The node exists in the DOM (still connected) but is stuck in view mode with no content and cannot be re-entered for editing, matching the earlier report of the node "disappearing."This suggests the
state_unsafe_mutationis not specific to the date-navigation/hydrateNodepath described above — it's more likely a shared reactive-state mutation happening somewhere in the render-flush cycle that both the slash-command handler and date navigation can trigger. Worth widening the investigation beyondpane-content.svelte's hydration effect to also check the slash-command handling path inbase-node.svelte(seecontroller.insertSlashCommand/setSlashCommandDropdownActivecalls) for a similar async-resumes-during-flush or read-triggers-write pattern.No fix attempted — filing for investigation only.
malibio commentedon Jul 6, 2026
Additional reproduction: list/quote-block auto-continuation broken, plus a new distinct crash
Symptom: Pressing Enter inside an ordered-list node (
1.) or quote-block node (>) no longer auto-inherits the prefix on the new line — only the first line's syntax is preserved, subsequent Enter presses do not continue the list/quote pattern.Investigation: The prefix-inheritance logic itself (
packages/desktop-app/src/lib/patterns/splitter/strategies/prefix-inheritance.ts) has not been touched since #329 and looks correct in isolation — it correctly computesafterContent = prefix + afterCursorfor the new sibling node. This means the split calculation isn't the problem; the newly-created sibling node is most likely failing to persist/hydrate correctly after creation, consistent with the same reactive-mutation issue already described above.New console evidence supporting this (captured live during list/quote testing):
This fired right around list-continuation testing — a node the UI expected to exist (likely the newly-split sibling) was not found in the store, which would explain the new line silently losing its prefix/content (the split node is created in the UI's intent but never lands in
sharedNodeStore).A second, distinct crash also appeared (different from
state_unsafe_mutation):This indicates
activeTab.contentwasundefinedat the moment some event handler readcontent.nodeIdinpane-content.svelte— likely the same tab/content desync root cause manifesting as a null-reference crash instead of thestate_unsafe_mutationwarning, depending on timing.Assessment: All symptoms filed on this issue so far (node disappearing on
/, date-nav tab/content desync, list/quote-continuation only preserving the first line, "Target node does not exist", and this newcontent.nodeIdcrash) look like manifestations of one shared root cause in the tab-content/node-hydration reactivity path centered onpane-content.svelte. Recommend the eventual fix investigate that file'shydrateNode/$effect/derived chain as the primary target, with the splitter/pattern-detection code treated as innocent (verified not it).No fix attempted — filing for investigation only.
malibio commentedon Jul 6, 2026
Additional reproduction: AI-chat model selection doesn't unlock the chat interface
Symptom: Selecting a model in the AI Chat node's model dropdown (
AiChatModelSelector) does not open the chat/message interface. The view stays on the "Choose a model to get started" / "Select a model from the dropdown above to begin the conversation" placeholder even after a model is selected.Confirmed via direct DOM inspection (not just visual): after selecting "Ministral 3B" in the dropdown, the underlying
<select>element's.valuecorrectly readsnative:ministral-3b-q4km— so the selection UI widget itself registered the choice — but.chat-viewer-messageswas absent and.provider-prompt(the "choose a model" placeholder) was still present and showing the same text.Root cause (traced in code):
handleModelSelectinpackages/desktop-app/src/lib/components/viewers/ai-chat-node-viewer.svelte(~line 144) persists the selection viasharedNodeStore.updateNode(nodeId, { properties: { ..., provider: 'native', model: selection.modelId } }, ...). The chat interface is gated in the template byprovider === undefined/isMessageProvider && !modelchecks (~line 403-419) reading derivedprovider/modelvalues off the node. This is the samesharedNodeStore.updateNode-> reactive-derived-read path already implicated in thestate_unsafe_mutation/ "Target node does not exist" symptoms above — the write is not reliably reaching the derived reads that gate this UI, so the dropdown's own DOM state and the app's reactive node state desync.Not a browser/proxy-only limitation - the underlying write/read desync is the same root cause already tracked on this issue, not something specific to dev-proxy/browser-mode testing.
Separate observation: AI Chat model picker exposes the full, unfiltered catalog in browser/dev-proxy mode
Distinct from the reactivity bug above. The AI Chat model dropdown showed 13+ local GGUF models plus 3 Ollama-style entries, when the intended UX (per #1371, "Lock llama.cpp path to Ministral 8B only") is to expose only Ministral 8B locally (Ollama/OpenAI-compat remain open paths).
Traced to two different code paths with two different behaviors:
packages/desktop-app/src-tauri/src/commands/chat_models.rs):chat_model_listcorrectly filters GGUF models toEXPOSED_GGUF_MODEL_IDS = ["ministral-8b-q4km"](~line 54) before returning to the frontend. This filter is intact and working as designed.packages/dev-tools/src/dev-proxy.ts,/api/agent/modelsroute, ~line 712): calls the daemon'slistModelsgRPC method directly with no equivalent filtering — it returns the full catalog frommodel_manager.rsunfiltered. This is why the dropdown showed every catalog entry (most "download needed") instead of just Ministral 8B (+ Ollama).This means dev-proxy/browser-mode testing does not accurately reflect the real app's model picker UX — worth fixing dev-proxy to apply the same
EXPOSED_GGUF_MODEL_IDSallowlist (or an equivalent), so browser-mode dev testing matches production behavior.No fix attempted for either finding — filing for investigation only.
Date navigation triggers state_unsafe_mutation and leaves the app unr…