* analysis: AI analysis for issue #826 [Bug]: Task widget is not shown on Obsidian start Generated by ai-issue-analyzer * fix: task card widget not appearing on Obsidian startup (#826) - Deferred view.dispatch() calls in dispatchUpdate() using queueMicrotask() - Prevents "EditorView.update not allowed during update" error during initialization - Exported TaskCardNoteDecorationsPlugin class for unit testing - Widget now renders correctly when Obsidian starts with task note open
11 KiB
Issue #826: Task Widget Not Shown on Obsidian Start
Problem Understanding
Bug Description
When Obsidian starts with a task note already open:
- The task card widget at the top of the note is not displayed initially
- The console shows an error:
Error dispatching task card update: Error: Calls to EditorView.update are not allowed while an update is in progress - The subtasks widget (if present) works correctly and appears immediately
- Workarounds: The widget appears after making changes to the note or closing and reopening it
Impact
- Poor user experience when starting Obsidian with task notes open
- Inconsistent behavior between task card widget and subtasks widget
- Users must perform extra actions to see the task card widget
Test File Location
Test File: tests/unit/editor/TaskCardNoteDecorations.test.ts
How to Run:
npm run test:unit -- TaskCardNoteDecorations
The test simulates the EditorView update state during Obsidian startup and verifies that:
- No
view.dispatch()calls occur during the initial construction phase - The plugin doesn't throw errors when instantiated during an ongoing update
- Widget rendering is deferred until after the initial update cycle completes
Relevant Code Locations
Primary Issue Location
File: src/editor/TaskCardNoteDecorations.ts
- Constructor (lines 110-122): Calls
buildDecorations()synchronously, thenloadTaskForCurrentFile()asynchronously - loadTaskForCurrentFile() (lines 203-253): Uses synchronous
getCachedTaskInfoSync()and callsdispatchUpdate()immediately when task changes - dispatchUpdate() (lines 189-201): Calls
view.dispatch()which throws error if called during an update cycle
Key Code Paths
-
TaskCardNoteDecorationsPlugin Constructor (
src/editor/TaskCardNoteDecorations.ts:110-122)constructor(view: EditorView, private plugin: TaskNotesPlugin) { this.view = view; this.decorations = this.buildDecorations(view); // Sync call this.setupEventListeners(); this.loadTaskForCurrentFile(view); // Async call, but executes sync code } -
loadTaskForCurrentFile (
src/editor/TaskCardNoteDecorations.ts:203-253)private loadTaskForCurrentFile(view: EditorView) { const newTask = this.plugin.cacheManager.getCachedTaskInfoSync(file.path); // SYNC if (taskChanged) { this.cachedTask = newTask; this.dispatchUpdate(); // Called during constructor execution! } } -
dispatchUpdate (
src/editor/TaskCardNoteDecorations.ts:189-201)private dispatchUpdate() { this.version++; try { this.view.dispatch({ // ERROR: Called during ongoing update effects: [taskCardUpdateEffect.of({ forceUpdate: true })], }); } catch (error) { console.error("Error dispatching task card update:", error); // This is logged } }
Working Comparison: ProjectNoteDecorations
File: src/editor/ProjectNoteDecorations.ts
- Constructor (lines 750-763): Similar structure but uses async data loading
- loadTasksForCurrentFile (lines 877-932): Uses
await this.projectService.getTasksLinkedToProject(file)which defers the dispatch call - This natural deferral prevents the dispatch from happening during the initial update cycle
Key difference at line 882:
const newTasks = await this.projectService.getTasksLinkedToProject(file); // ASYNC
Root Cause Analysis
The Problem
-
During Obsidian startup, when a task note is already open, the
TaskCardNoteDecorationsPluginconstructor is called during an active EditorView update cycle -
Synchronous data loading: The plugin uses
getCachedTaskInfoSync()which returns data immediately -
Immediate dispatch: When task data is available,
loadTaskForCurrentFile()callsdispatchUpdate()which tries to callview.dispatch() -
EditorView restriction: CodeMirror/EditorView doesn't allow
dispatch()calls while an update is in progress, throwing the error
Why Subtasks Widget Works
The ProjectNoteDecorations (subtasks widget) uses async data loading:
await this.projectService.getTasksLinkedToProject(file)- This pushes the
dispatchUpdate()call to the next microtask/tick - By then, the initial EditorView update has completed
view.dispatch()succeeds without error
Why Workarounds Work
- Making changes: Triggers a new update cycle where dispatch is allowed
- Closing and reopening: Creates a fresh EditorView instance with no ongoing update
Proposed Solutions
Solution 1: Defer Dispatch with setTimeout/queueMicrotask (Recommended)
Approach: Wrap the dispatchUpdate() call in queueMicrotask() or setTimeout() to defer it until after the current update cycle completes.
Implementation:
private dispatchUpdate() {
this.version++;
if (this.view && typeof this.view.dispatch === "function") {
// Defer dispatch to avoid calling during active update cycle
queueMicrotask(() => {
try {
this.view.dispatch({
effects: [taskCardUpdateEffect.of({ forceUpdate: true })],
});
} catch (error) {
console.error("Error dispatching task card update:", error);
}
});
}
}
Pros:
- Minimal code change: Only affects
dispatchUpdate()method - Simple and reliable:
queueMicrotask()ensures execution after current synchronous code - Maintains synchronous data loading: No need to refactor cache system
- Matches async behavior: Achieves same deferral as ProjectNoteDecorations
Cons:
- Slight delay before widget appears (negligible, 1 microtask)
- Adds async behavior to sync code path
Files to modify:
src/editor/TaskCardNoteDecorations.ts:189-201(dispatchUpdate method)
Solution 2: Make loadTaskForCurrentFile Async
Approach: Convert loadTaskForCurrentFile() to async and use await with a promise-wrapped version of getCachedTaskInfoSync().
Implementation:
private async loadTaskForCurrentFile(view: EditorView) {
const file = this.getFileFromView(view);
if (file instanceof TFile) {
try {
// Wrap sync call in promise to defer execution
const newTask = await Promise.resolve(
this.plugin.cacheManager.getCachedTaskInfoSync(file.path)
);
// ... rest of the method unchanged
if (taskChanged) {
this.cachedTask = newTask;
this.dispatchUpdate();
}
} catch (error) {
console.error("Error loading task for task note:", error);
}
} else {
if (this.cachedTask !== null) {
this.cachedTask = null;
this.dispatchUpdate();
}
}
}
Pros:
- More explicit about async nature
- Follows the pattern used in ProjectNoteDecorations
- Could facilitate future async cache operations
Cons:
- Larger change: Function signature changes from sync to async
- May require updating callers (constructor already doesn't await it)
- Adds promise overhead for sync operation
- Less semantically accurate (wrapping sync in async)
Files to modify:
src/editor/TaskCardNoteDecorations.ts:203-253(loadTaskForCurrentFile method)
Solution 3: Check EditorView State Before Dispatch
Approach: Add a flag to track if we're in the initial construction phase and skip dispatch during that time.
Implementation:
class TaskCardNoteDecorationsPlugin implements PluginValue {
decorations: DecorationSet;
private cachedTask: TaskInfo | null = null;
private currentFile: TFile | null = null;
private eventListeners: EventRef[] = [];
private view: EditorView;
private version = 0;
private isInitializing = true; // NEW FLAG
constructor(view: EditorView, private plugin: TaskNotesPlugin) {
this.view = view;
this.decorations = this.buildDecorations(view);
this.setupEventListeners();
this.loadTaskForCurrentFile(view);
// Mark initialization complete after current update cycle
queueMicrotask(() => {
this.isInitializing = false;
// Trigger deferred update if needed
if (this.cachedTask) {
this.dispatchUpdate();
}
});
}
private dispatchUpdate() {
// Skip dispatch during initialization
if (this.isInitializing) {
return;
}
this.version++;
if (this.view && typeof this.view.dispatch === "function") {
try {
this.view.dispatch({
effects: [taskCardUpdateEffect.of({ forceUpdate: true })],
});
} catch (error) {
console.error("Error dispatching task card update:", error);
}
}
}
}
Pros:
- Explicit control over when dispatch is allowed
- Clear initialization phase management
- Guarantees no dispatch during construction
Cons:
- More complex: Adds state management and deferred update logic
- More code changes: Constructor and dispatchUpdate both modified
- Requires careful handling of deferred updates
- Adds initialization flag that must be maintained
Files to modify:
src/editor/TaskCardNoteDecorations.ts:102-122(add flag, modify constructor)src/editor/TaskCardNoteDecorations.ts:189-201(modify dispatchUpdate)
Recommended Approach
Solution 1: Defer Dispatch with queueMicrotask is the recommended approach because:
- Minimal change: Single method modification, low risk
- Clean solution: Directly addresses the timing issue without complexity
- Proven pattern: Similar to how async operations naturally defer execution
- Maintains existing architecture: No need to refactor data loading or add state management
- Fast to implement and test: Simple change with clear behavior
Implementation Plan
- Modify
src/editor/TaskCardNoteDecorations.ts:189-201 - Wrap
view.dispatch()call inqueueMicrotask() - Add test to verify no errors during initialization
- Test in Obsidian with task note open at startup
- Verify widget appears correctly after deferral
Alternative if Solution 1 Fails
If queueMicrotask() proves insufficient (unlikely), Solution 3 provides the most robust guarantee by explicitly managing the initialization state, though at the cost of additional complexity.
Solution 2 is not recommended as it adds unnecessary async overhead and doesn't provide clear benefits over Solution 1.