From b8327a016d24a5e200e523097bf7445e91fb366d Mon Sep 17 00:00:00 2001 From: MarsLuay <70299537+MarsLuay@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:59:29 +0000 Subject: [PATCH] perf: cache thumbnail DOM elements to avoid repeated querySelectorAll * Created a `cachedThumbnailElements` array in `SlideFilmstripController` to explicitly store `.native-powerpoint-thumbnail` node lists. * Added `getThumbnailElements()` to lazily populate and retrieve the cache. * Used the cached array in `updateThumbnailActiveState` and `applySlideSelectionClasses` to eliminate repeated O(N) DOM query walking. * Invalidated the cache appropriately in `appendThumbnailShell` and `renderThumbnails` to keep the cache and actual DOM precisely synced without risking memory leaks from detached references. --- src/powerpoint/slideFilmstripController.ts | 27 ++++++- tests/benchmark-thumbnail-update-mock.mjs | 91 ++++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/benchmark-thumbnail-update-mock.mjs diff --git a/src/powerpoint/slideFilmstripController.ts b/src/powerpoint/slideFilmstripController.ts index 539f08d..7c9e4ef 100644 --- a/src/powerpoint/slideFilmstripController.ts +++ b/src/powerpoint/slideFilmstripController.ts @@ -115,6 +115,7 @@ export class SlideFilmstripController { private cancelIdleThumbnailFill: (() => void) | null = null; private renderedThumbnailIndices = new Set(); private readonly thumbnailFontSubstitutions = new Map(); + private cachedThumbnailElements: HTMLElement[] | null = null; private thumbnailPointerDrag: { fromIndex: number; pointerId: number; @@ -358,6 +359,7 @@ export class SlideFilmstripController { this.cancelIdleThumbnailFill = null; this.renderedThumbnailIndices.clear(); this.thumbnailFontSubstitutions.clear(); + this.cachedThumbnailElements = null; thumbnailContainer.empty(); for (let index = 0; index < slideCount; index += 1) { @@ -401,6 +403,7 @@ export class SlideFilmstripController { private appendThumbnailShell(index: number, renderImmediately: boolean): void { if (!this.host.thumbnailContainer) return; + this.cachedThumbnailElements = null; const item = this.host.thumbnailContainer.createDiv({ cls: 'native-powerpoint-thumbnail' }); item.dataset.slideIndex = String(index); if (index === this.host.currentSlide) item.addClass('active'); @@ -719,10 +722,29 @@ export class SlideFilmstripController { return this.slideNavigationPromise; } + private getThumbnailElements(): HTMLElement[] { + if (this.cachedThumbnailElements) { + return this.cachedThumbnailElements; + } + const container = this.host.thumbnailContainer; + if (!container) return []; + + const nodes = container.querySelectorAll('.native-powerpoint-thumbnail'); + const elements: HTMLElement[] = []; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node instanceof HTMLElement) { + elements.push(node); + } + } + this.cachedThumbnailElements = elements; + return elements; + } + private updateThumbnailActiveState(): void { if (!this.host.thumbnailContainer) return; - const items = this.host.thumbnailContainer.querySelectorAll('.native-powerpoint-thumbnail'); + const items = this.getThumbnailElements(); items.forEach((item, index) => { item.toggleClass('active', index === this.host.currentSlide); item.toggleClass('is-selected', this.selectedSlideIndices.has(index)); @@ -1142,7 +1164,8 @@ export class SlideFilmstripController { } private applySlideSelectionClasses(): void { - this.host.thumbnailContainer?.querySelectorAll('.native-powerpoint-thumbnail').forEach((thumbnail, index) => { + const items = this.getThumbnailElements(); + items.forEach((thumbnail, index) => { thumbnail.classList.toggle('is-selected', this.selectedSlideIndices.has(index)); }); } diff --git a/tests/benchmark-thumbnail-update-mock.mjs b/tests/benchmark-thumbnail-update-mock.mjs new file mode 100644 index 0000000..85a1031 --- /dev/null +++ b/tests/benchmark-thumbnail-update-mock.mjs @@ -0,0 +1,91 @@ +import { performance } from 'node:perf_hooks'; + +class MockElement { + constructor(className) { + this.className = className; + this.classList = new Set(); + this.children = []; + } + + toggleClass(cls, cond) { + if (cond) { + this.classList.add(cls); + } else { + this.classList.delete(cls); + } + } + + querySelectorAll(selector) { + const cls = selector.replace('.', ''); + const results = []; + const walk = (node) => { + if (node.className === cls) { + results.push(node); + } + for (const child of node.children) { + walk(child); + } + }; + walk(this); + // querySelectorAll adds a forEach + results.forEach = Array.prototype.forEach; + return results; + } + + appendChild(child) { + this.children.push(child); + } +} + +const container = new MockElement('container'); + +// Create 500 items (typical large slide deck) +for (let i = 0; i < 500; i++) { + const div = new MockElement('native-powerpoint-thumbnail'); + container.appendChild(div); +} + +const host = { + thumbnailContainer: container, + currentSlide: 10 +}; +const selectedSlideIndices = new Set([10, 11, 12]); + +function updateThumbnailActiveStateQSA() { + const items = host.thumbnailContainer.querySelectorAll('.native-powerpoint-thumbnail'); + items.forEach((item, index) => { + item.toggleClass('active', index === host.currentSlide); + item.toggleClass('is-selected', selectedSlideIndices.has(index)); + }); +} + +function updateThumbnailActiveStateChildren() { + const items = host.thumbnailContainer.children; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + item.toggleClass('active', index === host.currentSlide); + item.toggleClass('is-selected', selectedSlideIndices.has(index)); + } +} + +// Warmup +for (let i = 0; i < 1000; i++) { + updateThumbnailActiveStateQSA(); + updateThumbnailActiveStateChildren(); +} + +let start, end; + +start = performance.now(); +for (let i = 0; i < 10000; i++) { + updateThumbnailActiveStateQSA(); +} +end = performance.now(); +console.log('querySelectorAll:', (end - start).toFixed(2), 'ms'); + +start = performance.now(); +for (let i = 0; i < 10000; i++) { + updateThumbnailActiveStateChildren(); +} +end = performance.now(); +console.log('children:', (end - start).toFixed(2), 'ms');