diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a0..ed1c9132 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Fixed + +- (#2081) Kept task card and relationships widgets visible in reading mode when Obsidian scroll or preview updates remove injected widget DOM, and refreshed visible widgets immediately after task metadata changes. Thanks to @mukhozhuk for reporting and debugging. diff --git a/src/editor/ReadingModeWidgetObserver.ts b/src/editor/ReadingModeWidgetObserver.ts new file mode 100644 index 00000000..4579ae31 --- /dev/null +++ b/src/editor/ReadingModeWidgetObserver.ts @@ -0,0 +1,105 @@ +import { MarkdownView, WorkspaceLeaf } from "obsidian"; +import { shouldSkipMarkdownWidgetLeaf } from "./MarkdownWidgetContext"; + +type FrameHandle = { + id: number; + cancel: () => void; +}; + +function scheduleBeforePaint(win: Window, callback: () => void): FrameHandle { + if (typeof win.requestAnimationFrame === "function") { + const id = win.requestAnimationFrame(callback); + return { + id, + cancel: () => win.cancelAnimationFrame(id), + }; + } + + const id = win.setTimeout(callback, 0); + return { + id, + cancel: () => win.clearTimeout(id), + }; +} + +function nodeContainsSelector(node: Node, selector: string): boolean { + if (node.nodeType !== Node.ELEMENT_NODE) { + return false; + } + + const element = node as Element; + return element.matches(selector) || Boolean(element.querySelector(selector)); +} + +function mutationTouchesPreviewWidget(mutation: MutationRecord, widgetSelector: string): boolean { + const changedNodes = [...Array.from(mutation.addedNodes), ...Array.from(mutation.removedNodes)]; + return changedNodes.some( + (node) => + nodeContainsSelector(node, widgetSelector) || + nodeContainsSelector(node, ".markdown-preview-sizer") + ); +} + +function getReadingModeContainer(leaf: WorkspaceLeaf): HTMLElement | null { + const view = leaf.view; + if (!(view instanceof MarkdownView) || view.getMode() !== "preview") { + return null; + } + + if (shouldSkipMarkdownWidgetLeaf(leaf)) { + return null; + } + + return view.previewMode.containerEl; +} + +export function observeReadingModeWidgetMutations( + leaf: WorkspaceLeaf, + widgetSelector: string, + scheduleInjection: (leaf: WorkspaceLeaf) => void, + observedContainers: WeakSet, + cleanupCallbacks: Array<() => void>, + shouldRefresh: (leaf: WorkspaceLeaf) => boolean +): void { + const containerEl = getReadingModeContainer(leaf); + if (!containerEl || observedContainers.has(containerEl)) { + return; + } + + let pendingFrame: FrameHandle | null = null; + const requestRefresh = () => { + if (pendingFrame) { + return; + } + + const win = containerEl.ownerDocument.defaultView ?? window; + pendingFrame = scheduleBeforePaint(win, () => { + pendingFrame = null; + if (!containerEl.isConnected || !shouldRefresh(leaf)) { + return; + } + + const sizer = containerEl.querySelector(".markdown-preview-sizer"); + if (sizer && !sizer.querySelector(widgetSelector)) { + scheduleInjection(leaf); + } + }); + }; + + const observer = new MutationObserver((mutations) => { + if (mutations.some((mutation) => mutationTouchesPreviewWidget(mutation, widgetSelector))) { + requestRefresh(); + } + }); + + observer.observe(containerEl, { + childList: true, + subtree: true, + }); + + observedContainers.add(containerEl); + cleanupCallbacks.push(() => { + pendingFrame?.cancel(); + observer.disconnect(); + }); +} diff --git a/src/editor/RelationshipsDecorations.ts b/src/editor/RelationshipsDecorations.ts index 7f53682d..7a9493e5 100644 --- a/src/editor/RelationshipsDecorations.ts +++ b/src/editor/RelationshipsDecorations.ts @@ -57,6 +57,7 @@ import { ReadingModeInjectionContext, ReadingModeInjectionScheduler, } from "./ReadingModeInjectionScheduler"; +import { observeReadingModeWidgetMutations } from "./ReadingModeWidgetObserver"; import { shouldSkipMarkdownWidgetEditor, shouldSkipMarkdownWidgetLeaf, @@ -211,10 +212,7 @@ export function applyRelationshipsBottomOffset(container: HTMLElement, widget: H const spacerGap = Math.max( 0, - Math.round( - contentContainer.getBoundingClientRect().bottom - - contentBottom - ) + Math.round(contentContainer.getBoundingClientRect().bottom - contentBottom) ); if (spacerGap > 0) { const defaultMarginTop = getRelationshipsWidgetDefaultMarginTop(widget); @@ -249,6 +247,7 @@ async function createRelationshipsWidget( container.setAttribute("contenteditable", "false"); container.setAttribute("spellcheck", "false"); container.setAttribute("data-widget-type", "relationships"); + container.setAttribute("data-note-path", notePath); // Create container for embedded Bases view const basesContainer = activeDocument.createElement("div"); @@ -709,15 +708,25 @@ async function injectReadingModeWidget( } if (!isTaskNote && !isProjectNote) { - // Remove any existing widgets if conditions no longer met + // Preserve same-file widgets while Obsidian is rebuilding metadata. try { const previewView = view.previewMode; const containerEl = previewView.containerEl; - containerEl.querySelectorAll(`.${CSS_RELATIONSHIPS_WIDGET}`).forEach((el) => { - const holder = el as HTMLElementWithComponent; - holder.component?.unload(); - el.remove(); - }); + const existingWidgets = Array.from( + containerEl.querySelectorAll(`.${CSS_RELATIONSHIPS_WIDGET}`) + ); + const hasWidgetForDifferentFile = existingWidgets.some( + (widget) => widget.dataset.notePath !== file.path + ); + const isConfirmedNonRelationshipsNote = Boolean(metadata?.frontmatter); + + if (hasWidgetForDifferentFile || isConfirmedNonRelationshipsNote) { + existingWidgets.forEach((el) => { + const holder = el as HTMLElementWithComponent; + holder.component?.unload(); + el.remove(); + }); + } } catch (error) { tasknotesLogger.debug( "[TaskNotes] Error cleaning up relationships widget in reading mode:", @@ -797,10 +806,49 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const workspaceRefs: EventRef[] = []; const metadataCacheRefs: EventRef[] = []; const dependencyCacheRefs: EventRef[] = []; + const markdownWidgetObserverCleanups: Array<() => void> = []; + const observedMarkdownContainers = new WeakSet(); const scheduler = new ReadingModeInjectionScheduler(); const scheduleInjection = (leaf: WorkspaceLeaf) => { scheduler.schedule(leaf, (context) => injectReadingModeWidget(leaf, plugin, context)); }; + const shouldRefreshMarkdownLeaf = (leaf: WorkspaceLeaf) => { + const view = leaf.view; + return ( + plugin.settings.showRelationships && + view instanceof MarkdownView && + view.getMode() === "preview" && + Boolean(view.file) + ); + }; + const observeMarkdownLeaf = (leaf: WorkspaceLeaf) => { + observeReadingModeWidgetMutations( + leaf, + `.${CSS_RELATIONSHIPS_WIDGET}`, + scheduleInjection, + observedMarkdownContainers, + markdownWidgetObserverCleanups, + shouldRefreshMarkdownLeaf + ); + }; + const observeMarkdownLeaves = () => { + plugin.app.workspace.getLeavesOfType("markdown").forEach((leaf) => { + observeMarkdownLeaf(leaf); + }); + }; + const leafMatchesFile = (leaf: WorkspaceLeaf, file: TFile | null) => { + const view = leaf.view; + return view instanceof MarkdownView && (!file || view.file?.path === file.path); + }; + const refreshLeavesForFile = (file: TFile | null) => { + const leaves = plugin.app.workspace.getLeavesOfType("markdown"); + leaves.forEach((leaf) => { + if (leafMatchesFile(leaf, file)) { + scheduleInjection(leaf); + observeMarkdownLeaf(leaf); + } + }); + }; // Debounce to prevent excessive re-renders let debounceTimer: number | null = null; @@ -811,6 +859,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { leaves.forEach((leaf) => { scheduleInjection(leaf); }); + observeMarkdownLeaves(); }, 100); }; @@ -822,10 +871,17 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const activeLeafChangeRef = plugin.app.workspace.on("active-leaf-change", (leaf) => { if (leaf) { scheduleInjection(leaf); + observeMarkdownLeaf(leaf); } }); workspaceRefs.push(activeLeafChangeRef); + // Inject widget when a new file is opened in the same leaf + const fileOpenRef = plugin.app.workspace.on("file-open", (file) => { + refreshLeavesForFile(file instanceof TFile ? file : null); + }); + workspaceRefs.push(fileOpenRef); + // Inject widget when file is modified (metadata changes) - debounced per file const metadataDebounceTimers = new Map(); const metadataChangeRef = plugin.app.metadataCache.on("changed", (file) => { @@ -833,14 +889,33 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const existingTimer = metadataDebounceTimers.get(file.path); if (existingTimer) window.clearTimeout(existingTimer); + const leaves = plugin.app.workspace.getLeavesOfType("markdown"); + const visibleReadingLeaves = leaves.filter((leaf) => { + const view = leaf.view; + return ( + view instanceof MarkdownView && + view.file?.path === file.path && + view.getMode() === "preview" + ); + }); + + if (visibleReadingLeaves.length > 0) { + metadataDebounceTimers.delete(file.path); + visibleReadingLeaves.forEach((leaf) => { + scheduleInjection(leaf); + observeMarkdownLeaf(leaf); + }); + return; + } + // Debounce per file to avoid freezing during typing const timer = window.setTimeout(() => { metadataDebounceTimers.delete(file.path); - const leaves = plugin.app.workspace.getLeavesOfType("markdown"); leaves.forEach((leaf) => { const view = leaf.view; - if (view instanceof MarkdownView && view.file === file) { + if (view instanceof MarkdownView && view.file?.path === file.path) { scheduleInjection(leaf); + observeMarkdownLeaf(leaf); } }); }, 500); @@ -861,10 +936,14 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { leaves.forEach((leaf) => { scheduleInjection(leaf); }); + observeMarkdownLeaves(); // Return cleanup function return () => { if (debounceTimer) window.clearTimeout(debounceTimer); + metadataDebounceTimers.forEach((timer) => window.clearTimeout(timer)); + metadataDebounceTimers.clear(); + markdownWidgetObserverCleanups.forEach((cleanup) => cleanup()); // Clean up each type of event ref with the correct method workspaceRefs.forEach((ref) => plugin.app.workspace.offref(ref)); diff --git a/src/editor/TaskCardNoteDecorations.ts b/src/editor/TaskCardNoteDecorations.ts index 6d73762d..20353406 100644 --- a/src/editor/TaskCardNoteDecorations.ts +++ b/src/editor/TaskCardNoteDecorations.ts @@ -55,6 +55,7 @@ import { ReadingModeInjectionContext, ReadingModeInjectionScheduler, } from "./ReadingModeInjectionScheduler"; +import { observeReadingModeWidgetMutations } from "./ReadingModeWidgetObserver"; import { shouldSkipMarkdownWidgetEditor, shouldSkipMarkdownWidgetLeaf, @@ -585,11 +586,25 @@ async function injectReadingModeWidget( // Get task info for this file const task = plugin.cacheManager.getCachedTaskInfoSync(file.path); if (!task) { - // Not a task note - remove any existing widgets + // A same-file cache miss can happen while Obsidian is rebuilding metadata. + // Keep the existing card until metadata confirms this is no longer a task note. try { const previewView = view.previewMode; const containerEl = previewView.containerEl; - removeTaskCardWidgets(containerEl); + const existingWidgets = Array.from( + containerEl.querySelectorAll(`.${CSS_TASK_CARD_WIDGET}`) + ); + const hasWidgetForDifferentFile = existingWidgets.some( + (widget) => widget.dataset.taskPath !== file.path + ); + const metadata = plugin.app.metadataCache.getFileCache(file); + const isConfirmedNonTask = Boolean( + metadata?.frontmatter && !plugin.cacheManager.isTaskFile(metadata.frontmatter) + ); + + if (hasWidgetForDifferentFile || isConfirmedNonTask) { + removeTaskCardWidgets(containerEl); + } } catch (error) { tasknotesLogger.debug("[TaskNotes] Error cleaning up task card in reading mode:", { category: "persistence", @@ -700,10 +715,49 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const canvasObservers: MutationObserver[] = []; const canvasInteractionCleanups: Array<() => void> = []; const observedCanvasContainers = new WeakSet(); + const markdownWidgetObserverCleanups: Array<() => void> = []; + const observedMarkdownContainers = new WeakSet(); const scheduler = new ReadingModeInjectionScheduler(); const scheduleInjection = (leaf: WorkspaceLeaf) => { scheduler.schedule(leaf, (context) => injectReadingModeWidget(leaf, plugin, context)); }; + const shouldRefreshMarkdownLeaf = (leaf: WorkspaceLeaf) => { + const view = leaf.view; + return ( + plugin.settings.showTaskCardInNote && + view instanceof MarkdownView && + view.getMode() === "preview" && + Boolean(view.file) + ); + }; + const observeMarkdownLeaf = (leaf: WorkspaceLeaf) => { + observeReadingModeWidgetMutations( + leaf, + `.${CSS_TASK_CARD_WIDGET}`, + scheduleInjection, + observedMarkdownContainers, + markdownWidgetObserverCleanups, + shouldRefreshMarkdownLeaf + ); + }; + const observeMarkdownLeaves = () => { + plugin.app.workspace.getLeavesOfType("markdown").forEach((leaf) => { + observeMarkdownLeaf(leaf); + }); + }; + const leafMatchesFile = (leaf: WorkspaceLeaf, file: TFile | null) => { + const view = leaf.view; + return view instanceof MarkdownView && (!file || view.file?.path === file.path); + }; + const refreshLeavesForFile = (file: TFile | null) => { + const leaves = plugin.app.workspace.getLeavesOfType("markdown"); + leaves.forEach((leaf) => { + if (leafMatchesFile(leaf, file)) { + scheduleInjection(leaf); + observeMarkdownLeaf(leaf); + } + }); + }; // Debounce to prevent excessive re-renders let debounceTimer: number | null = null; @@ -753,6 +807,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { leaves.forEach((leaf) => { scheduleInjection(leaf); }); + observeMarkdownLeaves(); observeCanvasLeaves(); debouncedCanvasRefresh({ force: true }); }, 100); @@ -766,12 +821,21 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const activeLeafChangeRef = plugin.app.workspace.on("active-leaf-change", (leaf) => { if (leaf) { scheduleInjection(leaf); + observeMarkdownLeaf(leaf); observeCanvasLeaves(); debouncedCanvasRefresh(); } }); workspaceRefs.push(activeLeafChangeRef); + // Inject widget when a new file is opened in the same leaf + const fileOpenRef = plugin.app.workspace.on("file-open", (file) => { + refreshLeavesForFile(file instanceof TFile ? file : null); + observeCanvasLeaves(); + debouncedCanvasRefresh({ force: true }); + }); + workspaceRefs.push(fileOpenRef); + // Inject widget when file is modified (metadata changes) - debounced per file const metadataDebounceTimers = new Map(); const metadataChangeRef = plugin.app.metadataCache.on("changed", (file) => { @@ -779,14 +843,34 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { const existingTimer = metadataDebounceTimers.get(file.path); if (existingTimer) window.clearTimeout(existingTimer); + const leaves = plugin.app.workspace.getLeavesOfType("markdown"); + const visibleReadingLeaves = leaves.filter((leaf) => { + const view = leaf.view; + return ( + view instanceof MarkdownView && + view.file?.path === file.path && + view.getMode() === "preview" + ); + }); + + if (visibleReadingLeaves.length > 0) { + metadataDebounceTimers.delete(file.path); + visibleReadingLeaves.forEach((leaf) => { + scheduleInjection(leaf); + observeMarkdownLeaf(leaf); + }); + debouncedCanvasRefresh({ force: true }); + return; + } + // Debounce per file to avoid freezing during typing const timer = window.setTimeout(() => { metadataDebounceTimers.delete(file.path); - const leaves = plugin.app.workspace.getLeavesOfType("markdown"); leaves.forEach((leaf) => { const view = leaf.view; - if (view instanceof MarkdownView && view.file === file) { + if (view instanceof MarkdownView && view.file?.path === file.path) { scheduleInjection(leaf); + observeMarkdownLeaf(leaf); } }); debouncedCanvasRefresh({ force: true }); @@ -807,6 +891,7 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { leaves.forEach((leaf) => { scheduleInjection(leaf); }); + observeMarkdownLeaves(); observeCanvasLeaves(); debouncedCanvasRefresh({ force: true }); @@ -814,6 +899,9 @@ export function setupReadingModeHandlers(plugin: TaskNotesPlugin): () => void { return () => { if (debounceTimer) window.clearTimeout(debounceTimer); if (canvasDebounceTimer) window.clearTimeout(canvasDebounceTimer); + metadataDebounceTimers.forEach((timer) => window.clearTimeout(timer)); + metadataDebounceTimers.clear(); + markdownWidgetObserverCleanups.forEach((cleanup) => cleanup()); canvasObservers.forEach((observer) => observer.disconnect()); canvasInteractionCleanups.forEach((cleanup) => cleanup()); diff --git a/tests/unit/issues/issue-2081-reading-mode-widget-stability.test.ts b/tests/unit/issues/issue-2081-reading-mode-widget-stability.test.ts new file mode 100644 index 00000000..ac1b9d74 --- /dev/null +++ b/tests/unit/issues/issue-2081-reading-mode-widget-stability.test.ts @@ -0,0 +1,323 @@ +jest.mock("../../../src/ui/TaskCard", () => ({ + createTaskCard: jest.fn((task) => { + const card = document.createElement("div"); + card.className = "task-card"; + card.dataset.taskPath = task.path; + return card; + }), +})); + +import { Component, MarkdownView, TFile } from "obsidian"; +import { createTaskCard } from "../../../src/ui/TaskCard"; +import { setupReadingModeHandlers as setupTaskCardReadingModeHandlers } from "../../../src/editor/TaskCardNoteDecorations"; +import { setupReadingModeHandlers as setupRelationshipsReadingModeHandlers } from "../../../src/editor/RelationshipsDecorations"; + +type EventCallback = (...args: unknown[]) => void; + +function createEventSource() { + const handlers = new Map(); + + return { + on: jest.fn((event: string, callback: EventCallback) => { + const callbacks = handlers.get(event) ?? []; + callbacks.push(callback); + handlers.set(event, callbacks); + return { event, callback }; + }), + offref: jest.fn(), + trigger: (event: string, ...args: unknown[]) => { + for (const callback of handlers.get(event) ?? []) { + callback(...args); + } + }, + }; +} + +function createPreviewDom(): HTMLElement { + const containerEl = document.createElement("div"); + containerEl.innerHTML = ` +
+
+
+
+
Task
+ +
+

Body

+
+
+ `; + document.body.appendChild(containerEl); + return containerEl; +} + +function createMarkdownLeaf(path = "Tasks/test.md") { + const file = new TFile(path); + const containerEl = createPreviewDom(); + const view = Object.assign(Object.create(MarkdownView.prototype), { + containerEl, + file, + previewMode: { + containerEl, + }, + getMode: jest.fn(() => "preview"), + }) as MarkdownView; + + return { + containerEl, + file, + leaf: { + parent: {}, + view, + } as any, + view, + }; +} + +function createPluginMock(leaf: any) { + const workspaceEvents = createEventSource(); + const metadataEvents = createEventSource(); + const emitterEvents = createEventSource(); + let metadata: { frontmatter?: Record } | null = { + frontmatter: { tags: ["task"] }, + }; + let task: any = { + title: "Task", + status: "open", + priority: "normal", + path: leaf.file.path, + }; + + return { + workspaceEvents, + metadataEvents, + emitterEvents, + setMetadata: (nextMetadata: { frontmatter?: Record } | null) => { + metadata = nextMetadata; + }, + setTask: (nextTask: any) => { + task = nextTask; + }, + plugin: { + settings: { + showTaskCardInNote: true, + showRelationships: true, + defaultVisibleProperties: ["status", "priority"], + relationshipsPosition: "top", + commandFileMapping: { + relationships: "TaskNotes/Views/Relationships.base", + }, + }, + app: { + workspace: { + getLeavesOfType: jest.fn((type: string) => + type === "markdown" ? [leaf.leaf] : [] + ), + on: workspaceEvents.on, + offref: workspaceEvents.offref, + }, + metadataCache: { + getFileCache: jest.fn(() => metadata), + on: metadataEvents.on, + offref: metadataEvents.offref, + }, + }, + cacheManager: { + getCachedTaskInfoSync: jest.fn(() => task), + isTaskFile: jest.fn((frontmatter: Record | undefined) => + Array.isArray(frontmatter?.tags) + ? frontmatter.tags.includes("task") + : frontmatter?.type === "task" + ), + }, + dependencyCache: { + isFileUsedAsProject: jest.fn(() => false), + on: jest.fn(() => ({})), + offref: jest.fn(), + }, + fieldMapper: { + getMapping: jest.fn(() => ({})), + toUserField: jest.fn((field: string) => field), + }, + emitter: { + on: emitterEvents.on, + offref: emitterEvents.offref, + trigger: emitterEvents.trigger, + }, + } as any, + }; +} + +async function flushMicrotasks(): Promise { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +} + +async function flushMutationObserver(): Promise { + await Promise.resolve(); + jest.advanceTimersByTime(20); + await flushMicrotasks(); +} + +describe("Issue #2081: reading mode note widgets stay mounted", () => { + beforeEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ""; + const componentPrototype = Component.prototype as unknown as { + load: jest.Mock; + unload: jest.Mock; + }; + componentPrototype.load = jest.fn(); + componentPrototype.unload = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("refreshes a task card when file-open reuses the current leaf", async () => { + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupTaskCardReadingModeHandlers(pluginMock.plugin); + + try { + await flushMicrotasks(); + const newFile = new TFile("Tasks/next.md"); + leaf.view.file = newFile; + pluginMock.setTask({ + title: "Next task", + status: "open", + priority: "normal", + path: newFile.path, + }); + + pluginMock.workspaceEvents.trigger("file-open", newFile); + + expect( + leaf.containerEl.querySelector( + `.tasknotes-task-card-note-widget[data-task-path="${newFile.path}"]` + ) + ).not.toBeNull(); + } finally { + cleanup(); + } + }); + + it("re-injects a task card removed by Obsidian reading-mode DOM updates", async () => { + jest.useFakeTimers(); + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupTaskCardReadingModeHandlers(pluginMock.plugin); + + try { + const widget = leaf.containerEl.querySelector(".tasknotes-task-card-note-widget"); + expect(widget).not.toBeNull(); + + widget?.remove(); + await flushMutationObserver(); + + expect( + leaf.containerEl.querySelector(".tasknotes-task-card-note-widget") + ).not.toBeNull(); + expect(createTaskCard).toHaveBeenCalledTimes(2); + } finally { + cleanup(); + } + }); + + it("refreshes visible reading-mode task cards immediately on metadata changes", async () => { + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupTaskCardReadingModeHandlers(pluginMock.plugin); + + try { + await flushMicrotasks(); + (createTaskCard as jest.Mock).mockClear(); + pluginMock.setTask({ + title: "Updated task", + status: "done", + priority: "normal", + path: leaf.file.path, + }); + + pluginMock.metadataEvents.trigger("changed", leaf.file); + + expect(createTaskCard).toHaveBeenCalledTimes(1); + expect(createTaskCard).toHaveBeenCalledWith( + expect.objectContaining({ title: "Updated task", status: "done" }), + pluginMock.plugin, + expect.any(Array) + ); + } finally { + cleanup(); + } + }); + + it("keeps an existing task card during a same-file transient cache miss", async () => { + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupTaskCardReadingModeHandlers(pluginMock.plugin); + + try { + await flushMicrotasks(); + expect( + leaf.containerEl.querySelector(".tasknotes-task-card-note-widget") + ).not.toBeNull(); + pluginMock.setTask(null); + pluginMock.setMetadata(null); + + pluginMock.metadataEvents.trigger("changed", leaf.file); + + expect( + leaf.containerEl.querySelector(".tasknotes-task-card-note-widget") + ).not.toBeNull(); + } finally { + cleanup(); + } + }); + + it("removes a stale task card when metadata confirms the file is not a task", async () => { + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupTaskCardReadingModeHandlers(pluginMock.plugin); + + try { + await flushMicrotasks(); + expect( + leaf.containerEl.querySelector(".tasknotes-task-card-note-widget") + ).not.toBeNull(); + pluginMock.setTask(null); + pluginMock.setMetadata({ frontmatter: { tags: ["note"] } }); + + pluginMock.metadataEvents.trigger("changed", leaf.file); + + expect(leaf.containerEl.querySelector(".tasknotes-task-card-note-widget")).toBeNull(); + } finally { + cleanup(); + } + }); + + it("re-injects a relationships widget removed by Obsidian reading-mode DOM updates", async () => { + jest.useFakeTimers(); + const leaf = createMarkdownLeaf(); + const pluginMock = createPluginMock(leaf); + const cleanup = setupRelationshipsReadingModeHandlers(pluginMock.plugin); + + try { + jest.runOnlyPendingTimers(); + await flushMicrotasks(); + const widget = leaf.containerEl.querySelector(".tasknotes-relationships-widget"); + expect(widget).not.toBeNull(); + + widget?.remove(); + await flushMutationObserver(); + + expect( + leaf.containerEl.querySelector(".tasknotes-relationships-widget") + ).not.toBeNull(); + } finally { + cleanup(); + } + }); +});