Skip to content

Commit 570e7e4

Browse files
committed
Simplify lazy-define: idle-disconnect observer, isolate callback errors, add size budget
- Disconnect the MutationObserver once all lazy definitions resolve so it stops reacting to unrelated DOM mutations for the rest of the page's life - Run each callback independently and report errors via reportError instead of surfacing unhandled promise rejections - Restore single-frame scan batching (revert per-element rAF timers) - Add a size-limit budget for lazyDefine (768B / 0.9kb) - Add regression tests for re-observation and error isolation
1 parent 4caf23d commit 570e7e4

3 files changed

Lines changed: 92 additions & 155 deletions

File tree

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@
5757
"import": "{controller, attr, target, targets}",
5858
"limit": "2.6kb"
5959
},
60+
{
61+
"path": "lib/index.js",
62+
"import": "{lazyDefine}",
63+
"limit": "0.9kb"
64+
},
6065
{
6166
"path": "lib/abilities.js",
6267
"import": "{providable}",

src/lazy-define.ts

Lines changed: 65 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
type Strategy = (tagName: string) => Promise<void>
22

3-
const pending = new Map<string, Set<() => void>>()
4-
const triggered = new Set<string>()
3+
const dynamicElements = new Map<string, Set<() => void>>()
54

65
const ready = new Promise<void>(resolve => {
76
if (document.readyState !== 'loading') {
@@ -24,67 +23,31 @@ const firstInteraction = new Promise<void>(resolve => {
2423
document.addEventListener('pointerdown', handler, listenerOptions)
2524
})
2625

27-
const visible = async (tagName: string): Promise<void> => {
28-
const observeIntersection = (elements: Element[]) => {
29-
return new Promise<void>(resolve => {
30-
const observer = new IntersectionObserver(
31-
entries => {
32-
for (const entry of entries) {
33-
if (entry.isIntersecting) {
34-
resolve()
35-
observer.disconnect()
36-
return
37-
}
26+
const visible = (tagName: string): Promise<void> =>
27+
new Promise<void>(resolve => {
28+
const observer = new IntersectionObserver(
29+
entries => {
30+
for (const entry of entries) {
31+
if (entry.isIntersecting) {
32+
resolve()
33+
observer.disconnect()
34+
return
3835
}
39-
},
40-
{
41-
// Currently the threshold is set to 256px from the bottom of the viewport
42-
// with a threshold of 0.1. This means the element will not load until about
43-
// 2 keyboard-down-arrow presses away from being visible in the viewport,
44-
// giving us some time to fetch it before the contents are made visible
45-
rootMargin: '0px 0px 256px 0px',
46-
threshold: 0.01
4736
}
48-
)
49-
for (const element of elements) {
50-
observer.observe(element)
37+
},
38+
{
39+
// Currently the threshold is set to 256px from the bottom of the viewport
40+
// with a threshold of 0.1. This means the element will not load until about
41+
// 2 keyboard-down-arrow presses away from being visible in the viewport,
42+
// giving us some time to fetch it before the contents are made visible
43+
rootMargin: '0px 0px 256px 0px',
44+
threshold: 0.01
5145
}
52-
})
53-
}
54-
55-
const waitForElement = () => {
56-
return new Promise<Element[]>(resolve => {
57-
const observer = new MutationObserver(mutations => {
58-
for (const mutation of mutations) {
59-
const addedNodes = Array.from(mutation.addedNodes)
60-
for (const node of addedNodes) {
61-
if (!(node instanceof Element)) continue
62-
63-
const isMatch = node.matches(tagName)
64-
const descendant = node.querySelector(tagName)
65-
66-
if (isMatch || descendant) {
67-
observer.disconnect()
68-
resolve(Array.from(document.querySelectorAll(tagName)))
69-
return
70-
}
71-
}
72-
}
73-
})
74-
75-
observer.observe(document.documentElement, {childList: true, subtree: true})
76-
})
77-
}
78-
79-
const existingElements = Array.from(document.querySelectorAll(tagName))
80-
81-
if (existingElements.length > 0) {
82-
return observeIntersection(existingElements)
83-
}
84-
85-
const foundElements = await waitForElement()
86-
return observeIntersection(foundElements)
87-
}
46+
)
47+
for (const el of document.querySelectorAll(tagName)) {
48+
observer.observe(el)
49+
}
50+
})
8851

8952
const strategies: Record<string, Strategy> = {
9053
ready: () => ready,
@@ -94,121 +57,71 @@ const strategies: Record<string, Strategy> = {
9457

9558
type ElementLike = Element | Document | ShadowRoot
9659

97-
const observedTargets = new WeakSet<ElementLike>()
98-
const timers = new WeakMap<ElementLike, number>()
99-
100-
function cleanupObserver() {
101-
if (pending.size === 0 && elementLoader) {
102-
elementLoader.disconnect()
103-
elementLoader = undefined
104-
}
105-
}
60+
const pendingElements = new Set<ElementLike>()
61+
let scanTimer: number | null = null
62+
let elementLoader: MutationObserver | undefined
10663

10764
function scan(element: ElementLike) {
108-
const currentTimer = timers.get(element)
109-
if (currentTimer) cancelAnimationFrame(currentTimer)
110-
111-
const newTimer = requestAnimationFrame(() => {
112-
// FIX 7: Early return optimization
113-
if (pending.size === 0) return
114-
115-
// FIX 7: Create snapshot to prevent modification-during-iteration issues
116-
// (concurrent scans may delete tags from pending)
117-
const tagList = Array.from(pending.keys())
118-
119-
for (const tagName of tagList) {
120-
const child: Element | null =
121-
element instanceof Element && element.matches(tagName) ? element : element.querySelector(tagName)
122-
if (customElements.get(tagName) || child) {
123-
// Skip if already processed and not re-registered
124-
if (triggered.has(tagName) && !pending.has(tagName)) continue
125-
126-
triggered.add(tagName)
127-
128-
const callbackSet = pending.get(tagName)
129-
pending.delete(tagName)
130-
131-
const strategyName = (child?.getAttribute('data-load-on') || 'ready') as keyof typeof strategies
132-
const strategy = strategyName in strategies ? strategies[strategyName] : strategies.ready
133-
134-
// FIX 5: Wrap callback execution in try-catch and handle rejections
135-
const callbackList = Array.from(callbackSet || [])
136-
for (const callback of callbackList) {
137-
strategy(tagName)
138-
// eslint-disable-next-line github/no-then
139-
.then(() => {
140-
try {
141-
callback()
142-
} catch (err) {
143-
reportError(err)
144-
}
145-
})
65+
pendingElements.add(element)
66+
if (scanTimer != null) return
67+
scanTimer = requestAnimationFrame(() => {
68+
scanTimer = null
69+
const elements = new Set(pendingElements)
70+
pendingElements.clear()
71+
if (!dynamicElements.size) return
72+
outer: for (const el of elements) {
73+
for (const tagName of dynamicElements.keys()) {
74+
const child: Element | null = el instanceof Element && el.matches(tagName) ? el : el.querySelector(tagName)
75+
if (customElements.get(tagName) || child) {
76+
const strategyName = (child?.getAttribute('data-load-on') || 'ready') as keyof typeof strategies
77+
const strategy = strategyName in strategies ? strategies[strategyName] : strategies.ready
78+
const callbacks = dynamicElements.get(tagName) || []
79+
dynamicElements.delete(tagName)
80+
for (const callback of callbacks) {
81+
// Run each callback independently so one failure cannot prevent the
82+
// others, and surface errors through reportError instead of as
83+
// unhandled promise rejections.
14684
// eslint-disable-next-line github/no-then
147-
.catch(reportError)
85+
strategy(tagName).then(callback).catch(reportError)
86+
}
87+
if (!dynamicElements.size) break outer
14888
}
149-
150-
timers.delete(element)
15189
}
15290
}
153-
154-
// FIX 4: Disconnect observer when all pending tags are processed
155-
cleanupObserver()
91+
// Once every registered element has been found there is nothing left to look
92+
// for, so disconnect the observer rather than keep reacting to unrelated DOM
93+
// mutations for the rest of the page's lifetime.
94+
if (!dynamicElements.size && elementLoader) {
95+
elementLoader.disconnect()
96+
elementLoader = undefined
97+
}
15698
})
157-
158-
timers.set(element, newTimer)
15999
}
160100

161-
let elementLoader: MutationObserver | undefined
162-
163101
export function lazyDefine(object: Record<string, () => void>): void
164102
export function lazyDefine(tagName: string, callback: () => void): void
165103
export function lazyDefine(tagNameOrObj: string | Record<string, () => void>, singleCallback?: () => void) {
166104
if (typeof tagNameOrObj === 'string' && singleCallback) {
167105
tagNameOrObj = {[tagNameOrObj]: singleCallback}
168106
}
169-
170107
for (const [tagName, callback] of Object.entries(tagNameOrObj)) {
171-
// FIX 6: Late registration - execute immediately if already triggered
172-
// Check both triggered state and element existence to avoid executing for removed elements
173-
if (triggered.has(tagName) && document.querySelector(tagName)) {
174-
// eslint-disable-next-line github/no-then
175-
Promise.resolve().then(() => {
176-
try {
177-
callback()
178-
} catch (err) {
179-
reportError(err)
180-
}
181-
})
182-
} else {
183-
if (!pending.has(tagName)) {
184-
pending.set(tagName, new Set<() => void>())
185-
}
186-
pending.get(tagName)!.add(callback)
187-
}
108+
if (!dynamicElements.has(tagName)) dynamicElements.set(tagName, new Set<() => void>())
109+
dynamicElements.get(tagName)!.add(callback)
188110
}
189111
observe(document)
190112
}
191113

192114
export function observe(target: ElementLike): void {
193-
if (!elementLoader) {
194-
elementLoader = new MutationObserver(mutations => {
195-
if (!pending.size) return
196-
for (const mutation of mutations) {
197-
const nodes = mutation.addedNodes
198-
for (const node of nodes) {
199-
if (node instanceof Element) {
200-
scan(node)
201-
}
202-
}
115+
elementLoader ||= new MutationObserver(mutations => {
116+
if (!dynamicElements.size) return
117+
for (const mutation of mutations) {
118+
for (const node of mutation.addedNodes) {
119+
if (node instanceof Element) scan(node)
203120
}
204-
})
205-
}
121+
}
122+
})
206123

207124
scan(target)
208125

209-
// FIX 3: Check observedTargets to avoid redundant observe() calls
210-
if (!observedTargets.has(target)) {
211-
observedTargets.add(target)
212-
elementLoader.observe(target, {subtree: true, childList: true})
213-
}
126+
elementLoader.observe(target, {subtree: true, childList: true})
214127
}

test/lazy-define.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,25 @@ describe('lazyDefine', () => {
149149
})
150150
})
151151

152+
describe('observer lifecycle', () => {
153+
it('re-observes for definitions registered after everything has resolved', async () => {
154+
const onFirst = spy()
155+
lazyDefine('idle-first-element', onFirst)
156+
await fixture(html`<idle-first-element></idle-first-element>`)
157+
await animationFrame()
158+
// All pending definitions have resolved, so the observer disconnects here.
159+
expect(onFirst).to.be.callCount(1)
160+
161+
// A later registration must re-establish observation of newly added nodes.
162+
const onSecond = spy()
163+
lazyDefine('idle-second-element', onSecond)
164+
await fixture(html`<idle-second-element></idle-second-element>`)
165+
await animationFrame()
166+
167+
expect(onSecond).to.be.callCount(1)
168+
})
169+
})
170+
152171
describe('race condition prevention', () => {
153172
it('does not fire callbacks multiple times from concurrent scans', async () => {
154173
const onDefine = spy()
@@ -167,7 +186,7 @@ describe('lazyDefine', () => {
167186
})
168187

169188
describe('late registration', () => {
170-
it('executes callback immediately for already-triggered tags', async () => {
189+
it('runs a callback registered for a tag that already resolved', async () => {
171190
const onDefine1 = spy()
172191
const onDefine2 = spy()
173192

@@ -177,11 +196,11 @@ describe('lazyDefine', () => {
177196
await animationFrame()
178197
expect(onDefine1).to.be.callCount(1)
179198

180-
// Register second callback after element is already triggered
199+
// Register a second callback after the element already exists in the DOM
181200
lazyDefine('late-reg-element', onDefine2)
182201
await animationFrame()
183202

184-
// Second callback should be executed immediately
203+
// The late callback should still run
185204
expect(onDefine2).to.be.callCount(1)
186205
})
187206
})

0 commit comments

Comments
 (0)