fix: task card widget not appearing on Obsidian startup (#826) (#846)

* 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
This commit is contained in:
callumalpass 2025-10-06 21:24:04 +11:00 committed by GitHub
parent a146453a72
commit e2c5fb1403
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 467 additions and 8 deletions

View file

@ -86,6 +86,11 @@ Example:
- RRule strings passed directly without conversion (preferred format)
- Thanks to @Justin-Burg for reporting
- (#826) Fixed task card widget not appearing when Obsidian starts with task note open
- Deferred dispatch calls to avoid "EditorView.update not allowed during update" error
- Widget now renders correctly on startup without requiring workarounds
- Thanks to @nightroman for reporting
- (#810) Fixed recurring tasks with overdue due dates disappearing from agenda view
- Recurring tasks now check both `due` and `scheduled` dates for overdue status
- Previously only `scheduled` was checked for recurring tasks, causing tasks with overdue `due` dates to be hidden

295
issue-analysis/issue-826.md Normal file
View file

@ -0,0 +1,295 @@
# 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**:
```bash
npm run test:unit -- TaskCardNoteDecorations
```
The test simulates the EditorView update state during Obsidian startup and verifies that:
1. No `view.dispatch()` calls occur during the initial construction phase
2. The plugin doesn't throw errors when instantiated during an ongoing update
3. 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, then `loadTaskForCurrentFile()` asynchronously
- **loadTaskForCurrentFile()** (lines 203-253): Uses synchronous `getCachedTaskInfoSync()` and calls `dispatchUpdate()` immediately when task changes
- **dispatchUpdate()** (lines 189-201): Calls `view.dispatch()` which throws error if called during an update cycle
### Key Code Paths
1. **TaskCardNoteDecorationsPlugin Constructor** (`src/editor/TaskCardNoteDecorations.ts:110-122`)
```typescript
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
}
```
2. **loadTaskForCurrentFile** (`src/editor/TaskCardNoteDecorations.ts:203-253`)
```typescript
private loadTaskForCurrentFile(view: EditorView) {
const newTask = this.plugin.cacheManager.getCachedTaskInfoSync(file.path); // SYNC
if (taskChanged) {
this.cachedTask = newTask;
this.dispatchUpdate(); // Called during constructor execution!
}
}
```
3. **dispatchUpdate** (`src/editor/TaskCardNoteDecorations.ts:189-201`)
```typescript
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:
```typescript
const newTasks = await this.projectService.getTasksLinkedToProject(file); // ASYNC
```
## Root Cause Analysis
### The Problem
1. **During Obsidian startup**, when a task note is already open, the `TaskCardNoteDecorationsPlugin` constructor is called **during an active EditorView update cycle**
2. **Synchronous data loading**: The plugin uses `getCachedTaskInfoSync()` which returns data immediately
3. **Immediate dispatch**: When task data is available, `loadTaskForCurrentFile()` calls `dispatchUpdate()` which tries to call `view.dispatch()`
4. **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
1. **Making changes**: Triggers a new update cycle where dispatch is allowed
2. **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**:
```typescript
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**:
```typescript
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**:
```typescript
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:
1. **Minimal change**: Single method modification, low risk
2. **Clean solution**: Directly addresses the timing issue without complexity
3. **Proven pattern**: Similar to how async operations naturally defer execution
4. **Maintains existing architecture**: No need to refactor data loading or add state management
5. **Fast to implement and test**: Simple change with clear behavior
### Implementation Plan
1. Modify `src/editor/TaskCardNoteDecorations.ts:189-201`
2. Wrap `view.dispatch()` call in `queueMicrotask()`
3. Add test to verify no errors during initialization
4. Test in Obsidian with task note open at startup
5. 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.

View file

@ -99,7 +99,7 @@ export class TaskCardWidget extends WidgetType {
}
}
class TaskCardNoteDecorationsPlugin implements PluginValue {
export class TaskCardNoteDecorationsPlugin implements PluginValue {
decorations: DecorationSet;
private cachedTask: TaskInfo | null = null;
private currentFile: TFile | null = null;
@ -190,13 +190,16 @@ class TaskCardNoteDecorationsPlugin implements PluginValue {
// Increment version and dispatch update effect
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);
}
// 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);
}
});
}
}

View file

@ -0,0 +1,156 @@
/**
* TaskCardNoteDecorations Integration Tests
*
* Tests the initialization and update behavior of the TaskCardNoteDecorationsPlugin,
* particularly focusing on the issue where calling view.dispatch() during
* the initial update cycle causes "Calls to EditorView.update are not allowed
* while an update is in progress" errors on Obsidian startup.
*/
import { EditorView, ViewUpdate } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
// Mock task info
const mockTaskInfo = {
title: 'Test Task',
status: 'todo',
path: 'test-task.md',
content: '- [ ] Test Task',
line: 1,
dateCreated: '2024-01-01',
dateModified: '2024-01-01'
} as any;
// Mock plugin
const createMockPlugin = () => ({
cacheManager: {
getCachedTaskInfoSync: jest.fn(() => mockTaskInfo)
},
emitter: {
on: jest.fn(() => ({})),
offref: jest.fn()
},
settings: {
showTaskCardInNote: true
}
} as any);
// Mock EditorView with tracking of dispatch calls
const createMockEditorView = () => {
let isUpdating = false;
const dispatchCalls: any[] = [];
const mockView = {
state: {
doc: {
length: 100,
lines: 10,
toString: () => '---\ntitle: Test\n---\n\nSome content'
},
field: jest.fn(() => ({
file: { path: 'test-task.md' }
}))
},
dispatch: jest.fn((spec: any) => {
dispatchCalls.push({
spec,
duringUpdate: isUpdating
});
// Simulate the error that occurs when dispatch is called during update
if (isUpdating) {
throw new Error('Calls to EditorView.update are not allowed while an update is in progress');
}
}),
dom: document.createElement('div')
};
return {
view: mockView as any as EditorView,
dispatchCalls,
setUpdating: (value: boolean) => { isUpdating = value; }
};
};
describe('TaskCardNoteDecorations - Issue #826', () => {
let mockPlugin: any;
beforeEach(() => {
jest.clearAllMocks();
mockPlugin = createMockPlugin();
});
describe('initialization during Obsidian startup', () => {
it('should not call view.dispatch() during initial construction', () => {
// This test simulates what happens when Obsidian starts up with a task note already open
const { view, dispatchCalls, setUpdating } = createMockEditorView();
// Simulate that we're in the middle of an EditorView update
setUpdating(true);
// Dynamically import and instantiate the plugin
// In reality, this happens during ViewPlugin construction
const TaskCardNoteDecorations = require('../../../src/editor/TaskCardNoteDecorations');
// The plugin should not throw an error during construction
// even though an update is in progress
expect(() => {
// This simulates ViewPlugin.fromClass() instantiation
const plugin = new (TaskCardNoteDecorations.TaskCardNoteDecorationsPlugin as any)(
view,
mockPlugin
);
}).not.toThrow();
// End the simulated update
setUpdating(false);
});
it('should defer widget rendering until after initial update completes', () => {
// This test verifies that the widget appears eventually, just not during
// the initial update cycle
const { view, dispatchCalls, setUpdating } = createMockEditorView();
// Start with update in progress (simulating Obsidian startup)
setUpdating(true);
const TaskCardNoteDecorations = require('../../../src/editor/TaskCardNoteDecorations');
const plugin = new (TaskCardNoteDecorations.TaskCardNoteDecorationsPlugin as any)(
view,
mockPlugin
);
// No dispatch calls should happen during construction
const callsDuringUpdate = dispatchCalls.filter(c => c.duringUpdate);
expect(callsDuringUpdate.length).toBe(0);
// End the simulated update
setUpdating(false);
// Now dispatch should be allowed
// This would happen via event listeners or subsequent updates
});
});
describe('comparison with ProjectNoteDecorations behavior', () => {
it('should behave like ProjectNoteDecorations which does not have this issue', () => {
// ProjectNoteDecorations uses async data loading (await this.projectService.getTasksLinkedToProject)
// which naturally defers the dispatch call until after the constructor completes
// TaskCardNoteDecorations uses sync data loading (getCachedTaskInfoSync)
// which can trigger dispatch during construction
// Both should eventually show their widgets, but TaskCard should not dispatch during construction
const { view, dispatchCalls } = createMockEditorView();
const TaskCardNoteDecorations = require('../../../src/editor/TaskCardNoteDecorations');
const plugin = new (TaskCardNoteDecorations.TaskCardNoteDecorationsPlugin as any)(
view,
mockPlugin
);
// Verify no synchronous dispatch during construction
expect(dispatchCalls.length).toBe(0);
});
});
});