Skip to content

TaskNodeViewer header shows stale cached title instead of current content when unfocused #2005

Closed

Description

@malibio

Overview

BaseNodeViewer's header input (used by TaskNodeViewer and any other viewer with no custom header snippet) shows a stale, incorrect value when not focused, even though the underlying node's content/title are both correct in the database. Reproduced on a real task node: the header shows / when unfocused, but the same node's inline TaskNode row correctly shows "Another Task", and clicking into the header itself also immediately shows "Another Task" correctly.

Problem Statement

Reproduction:

  1. Have a task node whose title has gone through several edits (e.g. originally created via / slash command, then typed into) — confirmed on a real node at version 11
  2. Open it in TaskNodeViewer
  3. With the header not focused: header shows /
  4. Click into the header (focus it): immediately shows the correct value, "Another Task"
  5. The inline TaskNode block (child row rendering, not the viewer header) also always shows "Another Task" correctly

Confirmed NOT a data/persistence bug — queried the actual node directly from the database:

sqlite3 nodespace.db "SELECT content, title FROM node WHERE id='<the node>'"
Another Task|Another Task

Both content and title are correct and identical in the database. No node in the database has a literal / as content or title. This rules out a backend compute_title() / strip_markdown() bug — the persisted title is already right.

Root cause — a title captured mid-slash-command, before the type conversion, survives indefinitely in sharedNodeStore because later content updates never refresh title:

Tracing the exact sequence that produces this (matching how the reproducing node was actually created — typed /, then task, then a real title):

  1. Typing / fires a normal content update on the keystroke, before any slash command is selected. textarea-controller.svelte.ts's per-keystroke path calls this.events.contentChanged(newContent, cursorPosition) on every character — there is no special-casing that suppresses this for a lone /. This flows up through base-node.svelte's contentChanged dispatch into reactive-node-service.svelte.ts's updateNodeContent() (line 464-483), which applies an optimistic local update via sharedNodeStore.updateNode(nodeId, { content: '/', nodeType: <still 'text' at this point> }, ...). Content changes persist in debounced mode (per the comment at shared-node-store.svelte.ts:~1073-1074), so if the debounce fires before the slash command is selected (e.g. the user pauses on the dropdown), content: '/' round-trips to the backend while node_type is still text.
  2. On that round-trip, the backend's compute_title() (packages/core/src/services/node_service/crud.rs:2015-2075) computes a title for the still-text-typed node. For a root text node with no schema title_template, the fallback path (is_root branch, line 2067-2069) produces Some(strip_markdown(&node.content)) — i.e. Some("/"). This is a legitimate, correct title computation for a text node whose content is literally / at that instant — the bug is not here.
  3. The fresh node (now carrying title: "/") merges back into sharedNodeStore, caching title: "/".
  4. The user selects "task" from the dropdown. insertSlashCommand() (textarea-controller.svelte.ts:1011-1035) replaces the /task trigger text with the command's contentTemplate ('' for task, per core-plugins.ts), and slashCommandSelected fires, flipping nodeType to task. The user then types a real title, e.g. "Another Task" — updateNodeContent() fires again, optimistically patching content only: sharedNodeStore.updateNode(nodeId, { content: 'Another Task', nodeType: 'task' }, ...).
  5. In shared-node-store.svelte.ts's updateNode() (around line 1036-1041), the new in-memory node is built as { ...existingNode, ...changes, ... } — since changes has no title key, title is spread forward unchanged from step 3: still "/". content is now correct ("Another Task"), but title is stuck at the pre-conversion value from before the type ever became task, and stays that way until/unless some other full backend round-trip happens to overwrite it — which, per the reproducing node being at version 11, evidently never reliably happens.

The header's displayed value is gated by focus state (base-node-viewer.svelte:1321):

value={isHeaderBeingEdited ? currentViewedNode?.content || '' : headerDisplayValue}
  • Focused → reads .content directly → always correct (optimistically updated every keystroke).
  • Unfocused → reads headerDisplayValue ($derived.by, lines 349-356), which prefers .title over .content: currentViewedNode?.title || currentViewedNode?.content || '' — so it surfaces the stale "/" from step 3 instead of the current, correct content.

Proposed Solution

Either of these resolves the symptom; the right one depends on which invariant the team wants to hold:

  1. Keep content and title in sync on every optimistic local update. When updateNodeContent() patches content locally, also patch title to the same client-side-stripped value (mirroring what compute_title() would produce for a task/collection node: strip_markdown(content)), so the store's title never lags content between keystrokes and the debounced backend round-trip. This also needs to account for the nodeType having changed mid-flow (step 4 above) — the stale title was computed for the old type (text), not the new one (task), so a naive "just keep the last-known title" fix wouldn't be enough; the client-side recompute needs to run against the current nodeType/content pairing.
  2. Stop preferring .title over .content in headerDisplayValue for types where they're expected to track each other (e.g. task, where title is directly content-derived per compute_title()'s fallback, not template-computed). Reserve the .title-preference for types where title is genuinely a different, template-computed value (i.e. has a titleTemplate) — for everything else, .content is already the source of truth and should be preferred, matching what the focused/inline paths already do.

Option 2 is likely the more targeted fix, since headerDisplayValue's own comment ("Prefer computed title (from title_template) over raw content when available") already signals the intent was specifically for titleTemplate types — the bug is that it unconditionally prefers .title for every type, not just template-driven ones, so it accidentally also governs task, where .content should always win. It also sidesteps the mid-flow-type-change complication in Option 1, since it stops relying on a locally-cached title being correct at all for non-template types.

Acceptance Criteria

  • A task node's TaskNodeViewer header shows the correct, current content when unfocused, immediately after an edit — not a stale cached value
  • No regression for titleTemplate-driven types — their computed title should still display correctly when unfocused (per schema-management.md's documented behavior for that case)
  • Reproduce and verify against the exact traced sequence: type / and pause long enough for a debounced persist to round-trip (or otherwise force one), then select "task" from the dropdown, then type a real title, then blur — confirm the header shows the typed title (not / or any pre-conversion value)
  • No new test failures (bun run test:all)
  • Code passes bun run quality:fix

Technical Specifications

Reference Files

  • packages/desktop-app/src/lib/design/components/base-node-viewer.svelte — headerDisplayValue (lines 349-356), the focus-gated value= binding (line 1321)
  • packages/desktop-app/src/lib/design/components/textarea-controller.svelte.ts — per-keystroke contentChanged dispatch (fires even for a lone / before any slash command is selected); insertSlashCommand() (lines 1011-1035), which replaces the trigger text with the command's contentTemplate on selection
  • packages/desktop-app/src/lib/services/reactive-node-service.svelte.ts — updateNodeContent() (lines 464-483), the optimistic content-only patch that never touches title
  • packages/desktop-app/src/lib/services/shared-node-store.svelte.ts — updateNode() (around line 939-1041), where the local merge ({ ...existingNode, ...changes, ... }) leaves title unchanged when absent from changes; also note the debounced persistence behavior described around line 1073
  • packages/core/src/services/node_service/crud.rs:2015-2075 — compute_title(); confirms the pre-conversion text-node title (strip_markdown("/") → "/") is a legitimate computation at the moment it runs — the bug is the stale cache surviving the later type conversion, not this computation itself
  • packages/core/src/utils/markdown.rs:78-98 — strip_markdown(), the exact stripping logic to mirror client-side if Option 1 is chosen (or confirm an equivalent already exists client-side, e.g. formatTabTitle, referenced in the headerDisplayValue comment as sharing similar logic)

Non-Goals

  • Does not change compute_title() or any backend title-computation logic — the persisted title is already correct, and the mid-flow backend computation of title: "/" for the transient text-typed state is itself correct; this is purely a client-side staleness bug in how long that value survives after the type/content later change.
  • Does not address titleTemplate custom-entity title display, which is intentionally different (title computed from properties, not content) and appears to work correctly already.
  • Does not attempt to suppress the debounced persist from firing on a lone / before slash-command selection completes — that's existing, intentional behavior (every keystroke is content) and not itself a bug.

Activity

  1. malibio commented on Aug 7, 2026

    @malibio
    CollaboratorAuthor

    Baseline (worktree issue-2005-stale-header-title): Frontend 191 test files passed, 4439 tests passed.

  2. self-assigned this
    on Aug 7, 2026
  3. malibio commented on Aug 7, 2026

    @malibio
    CollaboratorAuthor

    Implementation note — took Option 2, as the issue recommended.

    The header's title-preference is now gated on schemaFormLoader.hasTitleTemplate (the signal that already governs the same input's readonly, placeholder, and oninput/onfocus behavior), so .title wins only for genuinely template-computed titles and .content is the source of truth everywhere else.

    Two details worth calling out:

    1. No content fallback for template types. For a titleTemplate schema an unresolved title now renders empty rather than falling back to raw content, so the read-only header shows its titleTemplate placeholder — matching how node-row.svelte renders those nodes inline. The previous title || content chain would have leaked raw content in that state.
    2. Logic extracted to computeHeaderDisplayValue in node-type-predicates.ts, so the regression test exercises the real code path instead of a mirrored copy in a test harness. Verified the new tests fail against the old title || content logic (3 failures, including the traced / → 'Another Task' case) and pass with the fix.

    Related, out of scope for this issue: the same stale cached title is preferred over content by other surfaces reading the same store — notably pluginRegistry.getNodeTitle() (plugin-registry.ts:469, node.title || node.content), which backs computeTabTitle, so the tab title for the reproducing node shows the same stale value. Query list/kanban/table views share the pattern. Not touched here per the issue's Non-Goals; may be worth a follow-up issue.

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingui

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions