From c95922aed579dc3a02283f03357de0a1ee04bc57 Mon Sep 17 00:00:00 2001 From: Alexander Lelidis Date: Thu, 16 Apr 2026 15:50:48 +0200 Subject: [PATCH 1/4] fix: coalesce scan() into a single rAF to avoid paint delay on bulk DOM mutations Replace per-element WeakMap+rAF deduplication with a shared Set of pending elements and a single requestAnimationFrame timer. When many elements are added in one frame (e.g. framework list rendering), only one rAF callback now runs before paint instead of one per element. Fixes #343 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/lazy-define.ts | 25 ++++++++++++++++--------- test/lazy-define.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/lazy-define.ts b/src/lazy-define.ts index 016b886..a97c616 100644 --- a/src/lazy-define.ts +++ b/src/lazy-define.ts @@ -57,26 +57,33 @@ const strategies: Record = { type ElementLike = Element | Document | ShadowRoot -const timers = new WeakMap() +const pendingElements = new Set() +let scanTimer: number | null = null + function scan(element: ElementLike) { - cancelAnimationFrame(timers.get(element) || 0) - timers.set( - element, - requestAnimationFrame(() => { + pendingElements.add(element) + if (scanTimer != null) return + scanTimer = requestAnimationFrame(() => { + scanTimer = null + if (!dynamicElements.size) { + pendingElements.clear() + return + } + for (const el of pendingElements) { for (const tagName of dynamicElements.keys()) { const child: Element | null = - element instanceof Element && element.matches(tagName) ? element : element.querySelector(tagName) + el instanceof Element && el.matches(tagName) ? el : el.querySelector(tagName) if (customElements.get(tagName) || child) { const strategyName = (child?.getAttribute('data-load-on') || 'ready') as keyof typeof strategies const strategy = strategyName in strategies ? strategies[strategyName] : strategies.ready // eslint-disable-next-line github/no-then for (const cb of dynamicElements.get(tagName) || []) strategy(tagName).then(cb) dynamicElements.delete(tagName) - timers.delete(element) } } - }) - ) + } + pendingElements.clear() + }) } let elementLoader: MutationObserver diff --git a/test/lazy-define.ts b/test/lazy-define.ts index b2517d6..5e49aba 100644 --- a/test/lazy-define.ts +++ b/test/lazy-define.ts @@ -68,6 +68,38 @@ describe('lazyDefine', () => { expect(onDefine3).to.have.callCount(1) }) + it('coalesces multiple added elements into a single rAF callback', async () => { + const onDefine = spy() + lazyDefine('coalesce-test-element', onDefine) + + const rafSpy = spy(window, 'requestAnimationFrame') + const callsBefore = rafSpy.callCount + + await fixture(html` +
+ + + + + + + + + + +
+ `) + + await animationFrame() + + const rafCallsFromScan = rafSpy.callCount - callsBefore + rafSpy.restore() + + // Should use at most a few rAF calls, not one per element + expect(rafCallsFromScan).to.be.lessThan(5) + expect(onDefine).to.be.callCount(1) + }) + it('lazy loads elements in shadow roots', async () => { const onDefine = spy() lazyDefine('nested-shadow-element', onDefine) From 0114d5e12852beeb4754167418623a7088cf1e2f Mon Sep 17 00:00:00 2001 From: Alexander Lelidis Date: Thu, 16 Apr 2026 15:53:30 +0200 Subject: [PATCH 2/4] style: fix prettier formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/lazy-define.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lazy-define.ts b/src/lazy-define.ts index a97c616..93243ef 100644 --- a/src/lazy-define.ts +++ b/src/lazy-define.ts @@ -71,8 +71,7 @@ function scan(element: ElementLike) { } for (const el of pendingElements) { for (const tagName of dynamicElements.keys()) { - const child: Element | null = - el instanceof Element && el.matches(tagName) ? el : el.querySelector(tagName) + const child: Element | null = el instanceof Element && el.matches(tagName) ? el : el.querySelector(tagName) if (customElements.get(tagName) || child) { const strategyName = (child?.getAttribute('data-load-on') || 'ready') as keyof typeof strategies const strategy = strategyName in strategies ? strategies[strategyName] : strategies.ready From 64cb060868dff54f8af35e8326834ece43ca2c59 Mon Sep 17 00:00:00 2001 From: Alexander Lelidis Date: Mon, 20 Apr 2026 14:11:25 +0200 Subject: [PATCH 3/4] address pr comments --- src/lazy-define.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/lazy-define.ts b/src/lazy-define.ts index 93243ef..2109f5f 100644 --- a/src/lazy-define.ts +++ b/src/lazy-define.ts @@ -6,14 +6,14 @@ const ready = new Promise(resolve => { if (document.readyState !== 'loading') { resolve() } else { - document.addEventListener('readystatechange', () => resolve(), {once: true}) + document.addEventListener('readystatechange', () => resolve(), { once: true }) } }) const firstInteraction = new Promise(resolve => { const controller = new AbortController() controller.signal.addEventListener('abort', () => resolve()) - const listenerOptions = {once: true, passive: true, signal: controller.signal} + const listenerOptions = { once: true, passive: true, signal: controller.signal } const handler = () => controller.abort() document.addEventListener('mousedown', handler, listenerOptions) @@ -65,11 +65,12 @@ function scan(element: ElementLike) { if (scanTimer != null) return scanTimer = requestAnimationFrame(() => { scanTimer = null + const elements = new Set(pendingElements) + pendingElements.clear() if (!dynamicElements.size) { - pendingElements.clear() return } - for (const el of pendingElements) { + outer: for (const el of elements) { for (const tagName of dynamicElements.keys()) { const child: Element | null = el instanceof Element && el.matches(tagName) ? el : el.querySelector(tagName) if (customElements.get(tagName) || child) { @@ -78,10 +79,10 @@ function scan(element: ElementLike) { // eslint-disable-next-line github/no-then for (const cb of dynamicElements.get(tagName) || []) strategy(tagName).then(cb) dynamicElements.delete(tagName) + if (!dynamicElements.size) break outer } } } - pendingElements.clear() }) } @@ -91,7 +92,7 @@ export function lazyDefine(object: Record void>): void export function lazyDefine(tagName: string, callback: () => void): void export function lazyDefine(tagNameOrObj: string | Record void>, singleCallback?: () => void) { if (typeof tagNameOrObj === 'string' && singleCallback) { - tagNameOrObj = {[tagNameOrObj]: singleCallback} + tagNameOrObj = { [tagNameOrObj]: singleCallback } } for (const [tagName, callback] of Object.entries(tagNameOrObj)) { if (!dynamicElements.has(tagName)) dynamicElements.set(tagName, new Set<() => void>()) @@ -112,5 +113,5 @@ export function observe(target: ElementLike): void { scan(target) - elementLoader.observe(target, {subtree: true, childList: true}) + elementLoader.observe(target, { subtree: true, childList: true }) } From f52fee6d94801b54b3dda3796e1a58163b5eab80 Mon Sep 17 00:00:00 2001 From: Alexander Lelidis Date: Mon, 20 Apr 2026 14:13:41 +0200 Subject: [PATCH 4/4] lint --- src/lazy-define.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lazy-define.ts b/src/lazy-define.ts index 2109f5f..755c3e6 100644 --- a/src/lazy-define.ts +++ b/src/lazy-define.ts @@ -6,14 +6,14 @@ const ready = new Promise(resolve => { if (document.readyState !== 'loading') { resolve() } else { - document.addEventListener('readystatechange', () => resolve(), { once: true }) + document.addEventListener('readystatechange', () => resolve(), {once: true}) } }) const firstInteraction = new Promise(resolve => { const controller = new AbortController() controller.signal.addEventListener('abort', () => resolve()) - const listenerOptions = { once: true, passive: true, signal: controller.signal } + const listenerOptions = {once: true, passive: true, signal: controller.signal} const handler = () => controller.abort() document.addEventListener('mousedown', handler, listenerOptions) @@ -92,7 +92,7 @@ export function lazyDefine(object: Record void>): void export function lazyDefine(tagName: string, callback: () => void): void export function lazyDefine(tagNameOrObj: string | Record void>, singleCallback?: () => void) { if (typeof tagNameOrObj === 'string' && singleCallback) { - tagNameOrObj = { [tagNameOrObj]: singleCallback } + tagNameOrObj = {[tagNameOrObj]: singleCallback} } for (const [tagName, callback] of Object.entries(tagNameOrObj)) { if (!dynamicElements.has(tagName)) dynamicElements.set(tagName, new Set<() => void>()) @@ -113,5 +113,5 @@ export function observe(target: ElementLike): void { scan(target) - elementLoader.observe(target, { subtree: true, childList: true }) + elementLoader.observe(target, {subtree: true, childList: true}) }