Skip to content

Commit c95922a

Browse files
alexus37Copilot
andcommitted
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>
1 parent 6c6e1fa commit c95922a

File tree

2 files changed

+48
-9
lines changed

2 files changed

+48
-9
lines changed

src/lazy-define.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,26 +57,33 @@ const strategies: Record<string, Strategy> = {
5757

5858
type ElementLike = Element | Document | ShadowRoot
5959

60-
const timers = new WeakMap<ElementLike, number>()
60+
const pendingElements = new Set<ElementLike>()
61+
let scanTimer: number | null = null
62+
6163
function scan(element: ElementLike) {
62-
cancelAnimationFrame(timers.get(element) || 0)
63-
timers.set(
64-
element,
65-
requestAnimationFrame(() => {
64+
pendingElements.add(element)
65+
if (scanTimer != null) return
66+
scanTimer = requestAnimationFrame(() => {
67+
scanTimer = null
68+
if (!dynamicElements.size) {
69+
pendingElements.clear()
70+
return
71+
}
72+
for (const el of pendingElements) {
6673
for (const tagName of dynamicElements.keys()) {
6774
const child: Element | null =
68-
element instanceof Element && element.matches(tagName) ? element : element.querySelector(tagName)
75+
el instanceof Element && el.matches(tagName) ? el : el.querySelector(tagName)
6976
if (customElements.get(tagName) || child) {
7077
const strategyName = (child?.getAttribute('data-load-on') || 'ready') as keyof typeof strategies
7178
const strategy = strategyName in strategies ? strategies[strategyName] : strategies.ready
7279
// eslint-disable-next-line github/no-then
7380
for (const cb of dynamicElements.get(tagName) || []) strategy(tagName).then(cb)
7481
dynamicElements.delete(tagName)
75-
timers.delete(element)
7682
}
7783
}
78-
})
79-
)
84+
}
85+
pendingElements.clear()
86+
})
8087
}
8188

8289
let elementLoader: MutationObserver

test/lazy-define.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,38 @@ describe('lazyDefine', () => {
6868
expect(onDefine3).to.have.callCount(1)
6969
})
7070

71+
it('coalesces multiple added elements into a single rAF callback', async () => {
72+
const onDefine = spy()
73+
lazyDefine('coalesce-test-element', onDefine)
74+
75+
const rafSpy = spy(window, 'requestAnimationFrame')
76+
const callsBefore = rafSpy.callCount
77+
78+
await fixture(html`
79+
<div>
80+
<coalesce-test-element></coalesce-test-element>
81+
<coalesce-test-element></coalesce-test-element>
82+
<coalesce-test-element></coalesce-test-element>
83+
<coalesce-test-element></coalesce-test-element>
84+
<coalesce-test-element></coalesce-test-element>
85+
<coalesce-test-element></coalesce-test-element>
86+
<coalesce-test-element></coalesce-test-element>
87+
<coalesce-test-element></coalesce-test-element>
88+
<coalesce-test-element></coalesce-test-element>
89+
<coalesce-test-element></coalesce-test-element>
90+
</div>
91+
`)
92+
93+
await animationFrame()
94+
95+
const rafCallsFromScan = rafSpy.callCount - callsBefore
96+
rafSpy.restore()
97+
98+
// Should use at most a few rAF calls, not one per element
99+
expect(rafCallsFromScan).to.be.lessThan(5)
100+
expect(onDefine).to.be.callCount(1)
101+
})
102+
71103
it('lazy loads elements in shadow roots', async () => {
72104
const onDefine = spy()
73105
lazyDefine('nested-shadow-element', onDefine)

0 commit comments

Comments
 (0)