-
Notifications
You must be signed in to change notification settings - Fork 54
fix: coalesce scan() into a single rAF to avoid paint delay on bulk DOM mutations #344
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,26 +57,32 @@ const strategies: Record<string, Strategy> = { | |
|
|
||
| type ElementLike = Element | Document | ShadowRoot | ||
|
|
||
| const timers = new WeakMap<ElementLike, number>() | ||
| const pendingElements = new Set<ElementLike>() | ||
| 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) | ||
| 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 | ||
| // 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() | ||
| }) | ||
|
Comment on lines
63
to
+85
|
||
| } | ||
|
|
||
| let elementLoader: MutationObserver | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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` | ||
| <div> | ||
| <coalesce-test-element></coalesce-test-element> | ||
| <coalesce-test-element></coalesce-test-element> | ||
| <coalesce-test-element></coalesce-test-element> | ||
| <coalesce-test-element></coalesce-test-element> | ||
| <coalesce-test-element></coalesce-test-element> | ||
| <coalesce-test-element></coalesce-test-element> | ||
| <coalesce-test-element></coalesce-test-element> | ||
| <coalesce-test-element></coalesce-test-element> | ||
| <coalesce-test-element></coalesce-test-element> | ||
| <coalesce-test-element></coalesce-test-element> | ||
| </div> | ||
| `) | ||
|
|
||
| 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) | ||
| }) | ||
|
Comment on lines
+75
to
+101
|
||
|
|
||
| it('lazy loads elements in shadow roots', async () => { | ||
| const onDefine = spy() | ||
| lazyDefine('nested-shadow-element', onDefine) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Within the rAF callback, once
dynamicElementsbecomes empty (afterdynamicElements.delete(tagName)), the outer loop still iterates over all remainingpendingElements, doing no useful work. For large DOM mutation batches this adds avoidable work before paint. Consider short-circuiting out of the loops whendynamicElements.size === 0(e.g., a labeled break) to keep the rAF callback bounded by the number of tagNames actually pending.