Skip to content

Date navigation triggers state_unsafe_mutation and leaves the app unresponsive #1564

Closed

Description

@malibio

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:

  1. User types / in a node's editing area — the node being edited disappears.
  2. 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").
  3. User cannot land on/navigate to a specific date (July 7, 2026) — navigation keeps landing on the 8th instead.
  4. After these interactions, the app becomes unresponsive to clicks — no node can be clicked or focused anymore.

Evidence:

  • Console fires a Svelte state_unsafe_mutation error ("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() in date-node-viewer.svelte calls onNodeIdChange -> updateTabContent(activeTabId, { nodeId: newNodeId, ... }), changing activeTab.content.nodeId, and separately calls onTitleChange -> updateTabTitle(...). These are two independent calls, not atomic, so title and content can desync if anything interleaves between them.
  • pane-content.svelte has an $effect (~line 108) that reacts to activeTab?.content?.nodeId and calls hydrateNode(nodeId, tabId) (async, defined ~line 70).
  • hydrateNode awaits sharedNodeStore.ensureNode(nodeId), then mutates hydratedNodeIds (a $state<Set<string>>) via hydratedNodeIds = new Set(hydratedNodeIds).add(nodeId) (~line 104), or calls closeTab(tabId) if the node isn't found.
  • Several $derived.by() blocks (isNodeHydrated, ViewerComponent, isViewerLoading) read hydratedNodeIds / 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/events calls intermittently fail with "Could not connect to the server" — the async hydrateNode resumes and mutates hydratedNodeIds state during a window Svelte's reactivity tracker still associates with an in-flight derived flush, producing state_unsafe_mutation.
  • Rapid/repeated date navigation can also fire the effect multiple times before earlier in-flight hydrateNode calls resolve, racing writes to hydratedNodeIds for different nodeIds — a plausible source of the tab-title/content-date desync and the inability to land on a specific date.
  • This pattern (mutating $state from 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_mutation console 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 ensureNode fetch
  • 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/isViewerLoading derived blocks
  • packages/desktop-app/src/lib/components/viewers/date-node-viewer.svelte — navigateDate(), onNodeIdChange/onTitleChange calls
  • packages/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

  1. Run the community desktop app fresh (bun run dev:tauri or bun run dev:browser) with a clean database.
  2. Open the Daily Journal / Today view.
  3. Click into the node text area, type / — observe the node disappear.
  4. Navigate to a different date via the prev/next day buttons, then navigate back.
  5. Observe tab title vs. displayed date mismatch, and/or inability to land on the originally intended date.
  6. Check browser console for the state_unsafe_mutation error and the hydrateNode unhandled rejection.
  7. 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

Activity

  1. malibio commented on Jul 6, 2026

    @malibio
    CollaboratorAuthor

    Additional reproduction: slash command alone triggers the same crash

    Confirmed independently of date navigation: typing / in a node's editing area reproduces the identical state_unsafe_mutation error (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>"> with textContent: "" — 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_mutation is not specific to the date-navigation/hydrateNode path 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 beyond pane-content.svelte's hydration effect to also check the slash-command handling path in base-node.svelte (see controller.insertSlashCommand / setSlashCommandDropdownActive calls) for a similar async-resumes-during-flush or read-triggers-write pattern.

    No fix attempted — filing for investigation only.

  2. malibio commented on Jul 6, 2026

    @malibio
    CollaboratorAuthor

    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 computes afterContent = prefix + afterCursor for 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):

    [ERROR] [BaseNodeViewer] Target node does not exist: 63dcc183-666a-4f79-a045-b9d9ca96687a
    

    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):

    TypeError: undefined is not an object (evaluating '$.get(content).nodeId')
    	in <unknown>
    	in pane-content.svelte
    	in tab-system.svelte
    	in pane-manager.svelte
        at handle_event_propagation
    

    This indicates activeTab.content was undefined at the moment some event handler read content.nodeId in pane-content.svelte — likely the same tab/content desync root cause manifesting as a null-reference crash instead of the state_unsafe_mutation warning, 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 new content.nodeId crash) look like manifestations of one shared root cause in the tab-content/node-hydration reactivity path centered on pane-content.svelte. Recommend the eventual fix investigate that file's hydrateNode/$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.

  3. malibio commented on Jul 6, 2026

    @malibio
    CollaboratorAuthor

    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 .value correctly reads native:ministral-3b-q4km — so the selection UI widget itself registered the choice — but .chat-viewer-messages was absent and .provider-prompt (the "choose a model" placeholder) was still present and showing the same text.

    Root cause (traced in code): handleModelSelect in packages/desktop-app/src/lib/components/viewers/ai-chat-node-viewer.svelte (~line 144) persists the selection via sharedNodeStore.updateNode(nodeId, { properties: { ..., provider: 'native', model: selection.modelId } }, ...). The chat interface is gated in the template by provider === undefined / isMessageProvider && !model checks (~line 403-419) reading derived provider/model values off the node. This is the same sharedNodeStore.updateNode -> reactive-derived-read path already implicated in the state_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:

    • Tauri desktop app (packages/desktop-app/src-tauri/src/commands/chat_models.rs): chat_model_list correctly filters GGUF models to EXPOSED_GGUF_MODEL_IDS = ["ministral-8b-q4km"] (~line 54) before returning to the frontend. This filter is intact and working as designed.
    • Browser/dev-proxy mode (packages/dev-tools/src/dev-proxy.ts, /api/agent/models route, ~line 712): calls the daemon's listModels gRPC method directly with no equivalent filtering — it returns the full catalog from model_manager.rs unfiltered. 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_IDS allowlist (or an equivalent), so browser-mode dev testing matches production behavior.

    No fix attempted for either finding — filing for investigation only.

  4. added a commit that references this issue on Jul 7, 2026
    a7afb50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions