From 668186466ec3b0d6013a9f38c377aa04a46e3515 Mon Sep 17 00:00:00 2001 From: yunqian Date: Fri, 11 Sep 2026 19:33:25 +0800 Subject: [PATCH 1/2] fix(chat): stop the context row re-laying out on every frame ContextRowOverflowController observes class changes on the context row and ends every layout pass with rowEl.toggleClass(expanded). Obsidian's toggleClass calls classList.add or remove unconditionally, which rewrites the class attribute even when nothing changes, so each pass queued a mutation record that scheduled the next pass. Every chat tab ran this loop once per display frame while Obsidian was visible. The renderer stayed busy, and the cost grew with the number of tabs and with attached note chips. Write the expanded class only when it changes, drop the records a layout pass produced itself, and cancel the pending frame on destroy. The test double for toggleClass now writes unconditionally like Obsidian, and new tests check that a blank or collapsed row stops scheduling frames once it settles and that destroy cancels the pending frame. Add AGENTS.md with this class-helper sharp edge, plus the CLAUDE.md pointer. --- AGENTS.md | 13 +++++ CHANGELOG.md | 8 +++ CLAUDE.md | 2 + .../chat/controllers/context-row-overflow.ts | 29 +++++++--- .../controllers/context-row-overflow.test.ts | 56 ++++++++++++++++++- 5 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bb7cfe4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# Project agent memory + +This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code. + +- Layer ownership and SDK lifecycle rules live in `ARCHITECTURE.md`. The local quality gate and naming rules live in `CONTRIBUTING.md`. +- Obsidian's `addClass`, `removeClass` and `toggleClass` call `classList.add` or `classList.remove` without checking the current state, so every call rewrites the `class` attribute and notifies `MutationObserver`s even when nothing changes. Check `hasClass` before writing inside any subtree an observer watches, or the observer re-triggers itself every frame. Test doubles for these helpers must write unconditionally too (see `tests/unit/features/chat/controllers/context-row-overflow.test.ts`); `classList.toggle(cls, force)` hides the loop. + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a9d4d26 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ + +@AGENTS.md diff --git a/src/features/chat/controllers/context-row-overflow.ts b/src/features/chat/controllers/context-row-overflow.ts index 77f1382..812a237 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,23 @@ 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. + // Records still queued here were produced before or during this pass, + // which has already read the DOM they describe, so dropping them keeps + // layout from rescheduling itself on every frame. + 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 +89,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 +187,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(); + }); }); From d39c054ce6059b1e2134f4d033833addc9ba141e Mon Sep 17 00:00:00 2001 From: yunqian Date: Fri, 11 Sep 2026 20:17:18 +0800 Subject: [PATCH 2/2] no-mistakes(review): Drop agent memory files, explain takeRecords purpose --- AGENTS.md | 13 ------------- CLAUDE.md | 2 -- .../chat/controllers/context-row-overflow.ts | 11 +++++++---- 3 files changed, 7 insertions(+), 19 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index bb7cfe4..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,13 +0,0 @@ -# Project agent memory - -This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code. - -- Layer ownership and SDK lifecycle rules live in `ARCHITECTURE.md`. The local quality gate and naming rules live in `CONTRIBUTING.md`. -- Obsidian's `addClass`, `removeClass` and `toggleClass` call `classList.add` or `classList.remove` without checking the current state, so every call rewrites the `class` attribute and notifies `MutationObserver`s even when nothing changes. Check `hasClass` before writing inside any subtree an observer watches, or the observer re-triggers itself every frame. Test doubles for these helpers must write unconditionally too (see `tests/unit/features/chat/controllers/context-row-overflow.test.ts`); `classList.toggle(cls, force)` hides the loop. - -## Maintaining this file - -Keep this file for knowledge useful to almost every future agent session in this project. -Do not repeat what the codebase already shows; point to the authoritative file or command instead. -Prefer rewriting or pruning existing entries over appending new ones. -When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a9d4d26..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,2 +0,0 @@ - -@AGENTS.md diff --git a/src/features/chat/controllers/context-row-overflow.ts b/src/features/chat/controllers/context-row-overflow.ts index 812a237..55ad10d 100644 --- a/src/features/chat/controllers/context-row-overflow.ts +++ b/src/features/chat/controllers/context-row-overflow.ts @@ -74,10 +74,13 @@ export class ContextRowOverflowController { 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. - // Records still queued here were produced before or during this pass, - // which has already read the DOM they describe, so dropping them keeps - // layout from rescheduling itself on every frame. + // 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(); }