diff --git a/CHANGELOG.md b/CHANGELOG.md index b5bdc66..afa53a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Fixed + +- Qoderian tabs no longer keep Obsidian's renderer busy while idle. The + context row above the composer re-checked its chip layout on every display + frame for as long as Obsidian was visible, once per open tab, and each frame + did more work when a note was attached. The row now re-measures only when + its chips or width change. + ## [1.0.7] - 2026-09-02 ### Added diff --git a/src/features/chat/controllers/context-row-overflow.ts b/src/features/chat/controllers/context-row-overflow.ts index 77f1382..55ad10d 100644 --- a/src/features/chat/controllers/context-row-overflow.ts +++ b/src/features/chat/controllers/context-row-overflow.ts @@ -11,7 +11,7 @@ export class ContextRowOverflowController { private readonly measureEl: HTMLElement; private readonly resizeObserver: ResizeObserver; private readonly mutationObserver: MutationObserver; - private layoutScheduled = false; + private layoutFrame: number | null = null; private expanded = false; private destroyed = false; @@ -53,6 +53,10 @@ export class ContextRowOverflowController { destroy(): void { this.destroyed = true; + if (this.layoutFrame !== null) { + window.cancelAnimationFrame(this.layoutFrame); + this.layoutFrame = null; + } this.resizeObserver.disconnect(); this.mutationObserver.disconnect(); this.pillEl.remove(); @@ -60,14 +64,26 @@ export class ContextRowOverflowController { } private scheduleLayout(): void { - if (this.layoutScheduled) return; - this.layoutScheduled = true; - window.requestAnimationFrame(() => { - this.layoutScheduled = false; + if (this.layoutFrame !== null) return; + this.layoutFrame = window.requestAnimationFrame(() => { + this.layoutFrame = null; if (!this.destroyed) this.layout(); }); } + private layout(): void { + this.applyLayout(); + // Obsidian's class helpers rewrite the class attribute even when the class + // does not change, and the observer above reports every such write, so + // applyState checks each class before writing it. Records still queued + // here describe DOM this pass has already read; dropping them saves a + // second clone-and-measure pass a frame after every real change, so the + // row settles in one frame. Either the class checks or this call alone + // stops layout rescheduling itself on every frame, and the regression + // tests cover only the two together. + this.mutationObserver.takeRecords(); + } + /** Content items are row children that are currently meant to be visible. */ private contentItems(): HTMLElement[] { return Array.from(this.rowEl.children).filter( @@ -76,7 +92,7 @@ export class ContextRowOverflowController { ); } - private layout(): void { + private applyLayout(): void { const items = this.contentItems(); if (items.length === 0 || !this.rowEl.hasClass('has-content')) { @@ -174,7 +190,9 @@ export class ContextRowOverflowController { } }); - this.rowEl.toggleClass('qoderian-context-row--expanded', this.expanded); + if (this.expanded !== this.rowEl.hasClass('qoderian-context-row--expanded')) { + this.rowEl.toggleClass('qoderian-context-row--expanded', this.expanded); + } const hiddenCount = items.length - (this.expanded ? items.length : visibleCount); const showPill = this.expanded || hiddenCount > 0; diff --git a/tests/unit/features/chat/controllers/context-row-overflow.test.ts b/tests/unit/features/chat/controllers/context-row-overflow.test.ts index f669bce..27cf46e 100644 --- a/tests/unit/features/chat/controllers/context-row-overflow.test.ts +++ b/tests/unit/features/chat/controllers/context-row-overflow.test.ts @@ -46,8 +46,13 @@ function installDomMocks(): void { }; } if (!proto.toggleClass) { + // Mirrors Obsidian: toggleClass always calls classList.add or remove, which + // rewrites the class attribute and queues a MutationObserver record even + // when nothing changes. classList.toggle(cls, force) skips that write and + // would hide an observer that keeps re-triggering itself. proto.toggleClass = function toggleClass(this: HTMLElement, cls: string, force: boolean) { - this.classList.toggle(cls, force); + if (force) this.classList.add(cls); + else this.classList.remove(cls); return this; }; } @@ -370,4 +375,53 @@ describe('ContextRowOverflowController', () => { controller.destroy(); }); + + it('stops scheduling layout frames once a blank row settles', async () => { + // A new tab starts with nothing attached, so its row has no content. + const row = createRow(); + row.removeClass('has-content'); + + const controller = new ContextRowOverflowController(row); + await settle(); + + const requestFrame = jest.spyOn(window, 'requestAnimationFrame'); + await new Promise(resolve => setTimeout(resolve, 200)); + expect(requestFrame).not.toHaveBeenCalled(); + + requestFrame.mockRestore(); + controller.destroy(); + }); + + it('stops scheduling layout frames once a collapsed row settles', async () => { + const row = createRow(); + [createChip(100), createChip(100), createChip(100)].forEach(chip => row.appendChild(chip)); + rowClientWidth = 200; + + const controller = new ContextRowOverflowController(row); + await settle(); + const pill = row.querySelector('.qoderian-context-overflow-pill') as HTMLElement; + expect(pill.hasClass('qoderian-hidden')).toBe(false); + + const requestFrame = jest.spyOn(window, 'requestAnimationFrame'); + await new Promise(resolve => setTimeout(resolve, 200)); + expect(requestFrame).not.toHaveBeenCalled(); + + requestFrame.mockRestore(); + controller.destroy(); + }); + + it('cancels its pending layout frame on destroy', () => { + const row = createRow(); + const requestFrame = jest.spyOn(window, 'requestAnimationFrame'); + const cancelFrame = jest.spyOn(window, 'cancelAnimationFrame'); + + const controller = new ContextRowOverflowController(row); + const pendingFrame = requestFrame.mock.results[0]?.value as number; + controller.destroy(); + + expect(cancelFrame).toHaveBeenCalledWith(pendingFrame); + + requestFrame.mockRestore(); + cancelFrame.mockRestore(); + }); });