Skip to content
Draft
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fix memory leak in visible() function
The visible() function had a memory leak when querySelectorAll returned
no elements - the IntersectionObserver was created but never observed
anything, so the promise never resolved and the observer/closure were
retained forever.

Fix by:
- First checking if elements exist
- If they exist, create IntersectionObserver (original behavior)
- If they don't exist, create MutationObserver to watch for DOM insertions
- When element appears, disconnect MutationObserver and create IntersectionObserver

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
  • Loading branch information
Copilot and Copilot committed Feb 17, 2026
commit bead1729d47963c900867d06f15bcc0049e595f1
81 changes: 59 additions & 22 deletions src/lazy-define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,68 @@ const firstInteraction = new Promise<void>(resolve => {
document.addEventListener('pointerdown', handler, listenerOptions)
})

const visible = (tagName: string): Promise<void> =>
new Promise<void>(resolve => {
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
resolve()
observer.disconnect()
return
const visible = async (tagName: string): Promise<void> => {
const makeViewportMonitor = (itemsToMonitor: Element[]) => {
return new Promise<void>(signalComplete => {
const viewMonitor = new IntersectionObserver(
viewEvents => {
for (const viewEvent of viewEvents) {
if (viewEvent.isIntersecting) {
signalComplete()
viewMonitor.disconnect()
return
}
}
},
{
// Currently the threshold is set to 256px from the bottom of the viewport
// with a threshold of 0.1. This means the element will not load until about
// 2 keyboard-down-arrow presses away from being visible in the viewport,
// giving us some time to fetch it before the contents are made visible
rootMargin: '0px 0px 256px 0px',
threshold: 0.01
}
},
{
// Currently the threshold is set to 256px from the bottom of the viewport
// with a threshold of 0.1. This means the element will not load until about
// 2 keyboard-down-arrow presses away from being visible in the viewport,
// giving us some time to fetch it before the contents are made visible
rootMargin: '0px 0px 256px 0px',
threshold: 0.01
)
for (const monitoredItem of itemsToMonitor) {
viewMonitor.observe(monitoredItem)
}
)
for (const el of document.querySelectorAll(tagName)) {
observer.observe(el)
}
})
})
}

const makeElementHunter = () => {
return new Promise<Element[]>(deliverCapturedElements => {
const domHunter = new MutationObserver(capturedChanges => {
for (const capturedChange of capturedChanges) {
const capturedNodes = Array.from(capturedChange.addedNodes)
for (const capturedNode of capturedNodes) {
if (!(capturedNode instanceof Element)) continue

const directHit = capturedNode.matches(tagName) ? capturedNode : null
const nestedHit = capturedNode.querySelector(tagName)
const successfulHit = directHit || nestedHit

if (successfulHit) {
domHunter.disconnect()
deliverCapturedElements(Array.from(document.querySelectorAll(tagName)))
return
}
}
}
})

domHunter.observe(document.documentElement, {childList: true, subtree: true})
})
}

const immediateFinds = Array.from(document.querySelectorAll(tagName))

if (immediateFinds.length > 0) {
return makeViewportMonitor(immediateFinds)
}

const delayedFinds = await makeElementHunter()
return makeViewportMonitor(delayedFinds)
}

const strategies: Record<string, Strategy> = {
ready: () => ready,
Expand Down