Repository navigation
TaskNodeViewer header shows stale cached title instead of current content when unfocused #2005
Description
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:
- 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 - Open it in
TaskNodeViewer - With the header not focused: header shows
/ - Click into the header (focus it): immediately shows the correct value, "Another Task"
- The inline
TaskNodeblock (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):
- Typing
/fires a normal content update on the keystroke, before any slash command is selected.textarea-controller.svelte.ts's per-keystroke path callsthis.events.contentChanged(newContent, cursorPosition)on every character — there is no special-casing that suppresses this for a lone/. This flows up throughbase-node.svelte'scontentChangeddispatch intoreactive-node-service.svelte.ts'supdateNodeContent()(line 464-483), which applies an optimistic local update viasharedNodeStore.updateNode(nodeId, { content: '/', nodeType: <still 'text' at this point> }, ...). Content changes persist in debounced mode (per the comment atshared-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 whilenode_typeis stilltext. - 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 roottextnode with no schematitle_template, the fallback path (is_rootbranch, line 2067-2069) producesSome(strip_markdown(&node.content))— i.e.Some("/"). This is a legitimate, correct title computation for atextnode whose content is literally/at that instant — the bug is not here. - The fresh node (now carrying
title: "/") merges back intosharedNodeStore, cachingtitle: "/". - The user selects "task" from the dropdown.
insertSlashCommand()(textarea-controller.svelte.ts:1011-1035) replaces the/tasktrigger text with the command'scontentTemplate(''fortask, percore-plugins.ts), andslashCommandSelectedfires, flippingnodeTypetotask. The user then types a real title, e.g. "Another Task" —updateNodeContent()fires again, optimistically patchingcontentonly:sharedNodeStore.updateNode(nodeId, { content: 'Another Task', nodeType: 'task' }, ...). - In
shared-node-store.svelte.ts'supdateNode()(around line 1036-1041), the new in-memory node is built as{ ...existingNode, ...changes, ... }— sincechangeshas notitlekey,titleis spread forward unchanged from step 3: still"/".contentis now correct ("Another Task"), buttitleis stuck at the pre-conversion value from before the type ever becametask, 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
.contentdirectly → always correct (optimistically updated every keystroke). - Unfocused → reads
headerDisplayValue($derived.by, lines 349-356), which prefers.titleover.content:currentViewedNode?.title || currentViewedNode?.content || ''— so it surfaces the stale"/"from step 3 instead of the current, correctcontent.
Proposed Solution
Either of these resolves the symptom; the right one depends on which invariant the team wants to hold:
- Keep
contentandtitlein sync on every optimistic local update. WhenupdateNodeContent()patchescontentlocally, also patchtitleto the same client-side-stripped value (mirroring whatcompute_title()would produce for atask/collectionnode:strip_markdown(content)), so the store'stitlenever lagscontentbetween keystrokes and the debounced backend round-trip. This also needs to account for thenodeTypehaving changed mid-flow (step 4 above) — the staletitlewas 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 currentnodeType/content pairing. - Stop preferring
.titleover.contentinheaderDisplayValuefor types where they're expected to track each other (e.g.task, wheretitleis directly content-derived percompute_title()'s fallback, not template-computed). Reserve the.title-preference for types wheretitleis genuinely a different, template-computed value (i.e. has atitleTemplate) — for everything else,.contentis 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
TaskNodeViewerheader 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 (perschema-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-gatedvalue=binding (line 1321)packages/desktop-app/src/lib/design/components/textarea-controller.svelte.ts— per-keystrokecontentChangeddispatch (fires even for a lone/before any slash command is selected);insertSlashCommand()(lines 1011-1035), which replaces the trigger text with the command'scontentTemplateon selectionpackages/desktop-app/src/lib/services/reactive-node-service.svelte.ts—updateNodeContent()(lines 464-483), the optimistic content-only patch that never touchestitlepackages/desktop-app/src/lib/services/shared-node-store.svelte.ts—updateNode()(around line 939-1041), where the local merge ({ ...existingNode, ...changes, ... }) leavestitleunchanged when absent fromchanges; also note the debounced persistence behavior described around line 1073packages/core/src/services/node_service/crud.rs:2015-2075—compute_title(); confirms the pre-conversiontext-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 itselfpackages/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 theheaderDisplayValuecomment 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 oftitle: "/"for the transienttext-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
titleTemplatecustom-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
malibio commentedon Aug 7, 2026
Baseline (worktree issue-2005-stale-header-title): Frontend 191 test files passed, 4439 tests passed.
malibio commentedon Aug 7, 2026
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'sreadonly, placeholder, andoninput/onfocusbehavior), so.titlewins only for genuinely template-computed titles and.contentis the source of truth everywhere else.Two details worth calling out:
contentfallback 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 hownode-row.svelterenders those nodes inline. The previoustitle || contentchain would have leaked raw content in that state.computeHeaderDisplayValueinnode-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 oldtitle || contentlogic (3 failures, including the traced/→ 'Another Task' case) and pass with the fix.Related, out of scope for this issue: the same stale cached
titleis preferred overcontentby other surfaces reading the same store — notablypluginRegistry.getNodeTitle()(plugin-registry.ts:469,node.title || node.content), which backscomputeTabTitle, 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.Prefer content over cached title in viewer header for non-template ty…