Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5726082
Initial plan
Copilot Feb 17, 2026
c7e10be
Initial plan for lazy-define performance and correctness fixes
Copilot Feb 17, 2026
b8317cd
Fix multiple performance and correctness issues in lazy-define.ts
Copilot Feb 17, 2026
563790e
Address code review feedback
Copilot Feb 17, 2026
bead172
Fix memory leak in visible() function
Copilot Feb 17, 2026
5d75ed1
Fix all lazy-define performance and correctness issues with tests
Copilot Feb 17, 2026
3f05783
Address code review feedback - improve naming and fix race conditions
Copilot Feb 17, 2026
733f079
Final fixes: prevent observedTargets race condition and cleanup properly
Copilot Feb 17, 2026
b6f9766
Address final code review feedback - improve comments and test mocking
Copilot Feb 17, 2026
ad91c86
Fix TypeScript error in bind.ts
Copilot Mar 2, 2026
5b3bdc6
Update size-limit to accommodate lazy-define improvements (2.5kb → 2.…
Copilot Mar 2, 2026
7c5966b
Merge branch 'main' into copilot/fix-performance-issues-lazy-define
mattcosta7 Mar 27, 2026
4caf23d
Merge remote-tracking branch 'origin/main' into copilot/fix-performan…
mattcosta7 Jul 7, 2026
570e7e4
Simplify lazy-define: idle-disconnect observer, isolate callback erro…
mattcosta7 Jul 7, 2026
b8aa270
lazy-define: skip observe() work when nothing is pending
mattcosta7 Jul 7, 2026
eea8b4b
lazy-define: time-sliced TreeWalker scan for very large DOMs
mattcosta7 Jul 7, 2026
6d9085c
lazy-define: resume large scans via scheduler.postTask when available
mattcosta7 Jul 7, 2026
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
10 changes: 9 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@
{
"path": "lib/index.js",
"import": "{controller, attr, target, targets}",
"limit": "2.5kb"
"limit": "2.6kb"
},
{
"path": "lib/index.js",
"import": "{lazyDefine}",
"limit": "1kb"
},
{
"path": "lib/abilities.js",
Expand Down
2 changes: 1 addition & 1 deletion src/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ function* bindings(el: Element): Iterable<Binding> {
type: action.slice(0, eventSep),
tag: action.slice(eventSep + 1, methodSep),
method: action.slice(methodSep + 1) || 'handleEvent'
} || 'handleEvent'
}
}
}

Expand Down
134 changes: 111 additions & 23 deletions src/lazy-define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,37 +57,117 @@ const strategies: Record<string, Strategy> = {

type ElementLike = Element | Document | ShadowRoot

const pendingElements = new Set<ElementLike>()
let scanTimer: number | null = null
// Roots waiting to be scanned for pending tags.
const scanQueue: ElementLike[] = []
// A partially-processed tree walk, kept between frames so that a very large tree
// (100k+ nodes) can be scanned across multiple frames instead of in one long,
// input-blocking task.
let scanWalker: TreeWalker | null = null
let scanScheduled = false
let elementLoader: MutationObserver | undefined

// Maximum time a single scan pass may run before yielding back to the browser.
const SCAN_BUDGET_MS = 4
// How often (in nodes) to check the time budget. Checking every node would call
// performance.now() millions of times on large pages; checking periodically
// keeps that overhead negligible while still yielding promptly.
const SCAN_CHECK_INTERVAL = 1024

type PostTaskScheduler = {postTask(callback: () => void): Promise<unknown>}
const nativeScheduler = (globalThis as unknown as {scheduler?: PostTaskScheduler}).scheduler

function scan(element: ElementLike) {
pendingElements.add(element)
if (scanTimer != null) return
scanTimer = requestAnimationFrame(() => {
scanTimer = null
const elements = new Set(pendingElements)
pendingElements.clear()
if (!dynamicElements.size) {
return
scanQueue.push(element)
if (scanScheduled) return
scanScheduled = true
requestAnimationFrame(runScan)
}

// Resume an interrupted scan. Prefer `scheduler.postTask` (default 'user-visible'
// priority) so a large scan continues promptly after yielding to input rather
// than waiting a whole frame; fall back to requestAnimationFrame.
function scheduleScanContinuation() {
scanScheduled = true
if (nativeScheduler?.postTask) {
// eslint-disable-next-line github/no-then
nativeScheduler.postTask(runScan).catch(() => undefined)
} else {
requestAnimationFrame(runScan)
}
}

function resolveTag(tagName: string, el: Element | null): void {
const callbacks = dynamicElements.get(tagName)
if (!callbacks) return
dynamicElements.delete(tagName)
const strategyName = (el?.getAttribute('data-load-on') || 'ready') as keyof typeof strategies
const strategy = strategyName in strategies ? strategies[strategyName] : strategies.ready
for (const callback of callbacks) {
// Run each callback independently so one failure cannot prevent the others,
// and surface errors through reportError instead of unhandled rejections.
// eslint-disable-next-line github/no-then
strategy(tagName).then(callback).catch(reportError)
}
}

function stopScanning(): void {
// Everything registered has resolved: drop any queued work and disconnect the
// observer so it stops reacting to unrelated DOM mutations for the rest of the
// page's lifetime.
scanQueue.length = 0
scanWalker = null
if (elementLoader) {
elementLoader.disconnect()
elementLoader = undefined
}
}

function runScan(): void {
scanScheduled = false
if (!dynamicElements.size) return stopScanning()

const deadline = performance.now() + SCAN_BUDGET_MS

// Cheap O(pendingTags) pass for tags already defined elsewhere, which need no
// DOM lookup at all.
for (const tagName of [...dynamicElements.keys()]) {
if (customElements.get(tagName)) resolveTag(tagName, null)
}
if (!dynamicElements.size) return stopScanning()

let sinceCheck = 0
while (scanWalker || scanQueue.length) {
if (!scanWalker) {
const root = scanQueue.shift()!
// A TreeWalker visits descendants only, so match the root element itself.
if (root instanceof Element && dynamicElements.has(root.localName)) {
resolveTag(root.localName, root)
if (!dynamicElements.size) return stopScanning()
}
scanWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
}
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) {
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)
if (!dynamicElements.size) break outer

for (let node = scanWalker.nextNode(); node; node = scanWalker.nextNode()) {
const el = node as Element
// Membership is O(1), so total cost is O(nodes) rather than
// O(nodes × pendingTags).
if (dynamicElements.has(el.localName)) {
resolveTag(el.localName, el)
if (!dynamicElements.size) return stopScanning()
}
if (++sinceCheck >= SCAN_CHECK_INTERVAL) {
sinceCheck = 0
if (performance.now() >= deadline) {
// Out of budget — resume this same walker after yielding.
scheduleScanContinuation()
return
}
}
}
})
scanWalker = null // finished this root
}
}

let elementLoader: MutationObserver

export function lazyDefine(object: Record<string, () => void>): void
export function lazyDefine(tagName: string, callback: () => void): void
export function lazyDefine(tagNameOrObj: string | Record<string, () => void>, singleCallback?: () => void) {
Expand All @@ -102,6 +182,14 @@ export function lazyDefine(tagNameOrObj: string | Record<string, () => void>, si
}

export function observe(target: ElementLike): void {
// Nothing is waiting to be defined. Any tags that already resolved are now
// real custom elements, so the browser upgrades their instances natively and
// there is nothing for us to watch for. This keeps controllers that never use
// lazyDefine — and those connecting after every definition has resolved —
// completely free of observer cost (core.ts calls observe() for every
// shadow-root controller on connect).
if (!dynamicElements.size) return

elementLoader ||= new MutationObserver(mutations => {
if (!dynamicElements.size) return
for (const mutation of mutations) {
Expand Down
Loading
Loading