diff --git a/package-lock.json b/package-lock.json index e1b41792..af24e23d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1903,6 +1903,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.36.1.tgz", "integrity": "sha512-/IsgNGOkBi7CuDfUbwt1eOqUXF9WGVBW9dwEe1pi+L32XrTsZIgmDFIi2RxjzsvB/8i+MIf5JIoTEH8LOZ368A==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.36.1", "@typescript-eslint/types": "5.36.1", @@ -2696,6 +2697,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3972,7 +3974,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "5.0.0", @@ -4623,6 +4626,7 @@ "integrity": "sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint/eslintrc": "^1.3.1", "@humanwhocodes/config-array": "^0.10.4", @@ -8360,6 +8364,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.6.2.tgz", "integrity": "sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==", "dev": true, + "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -8796,6 +8801,7 @@ "integrity": "sha512-PggGy4dhwx5qaW+CKBilA/98Ql9keyfnb7lh4SR6shQ91QQQi1ORJ1v4UinkdP2i87OBs9AQFooQylcrrRfIcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9165,6 +9171,7 @@ "resolved": "https://registry.npmjs.org/size-limit/-/size-limit-8.0.1.tgz", "integrity": "sha512-VHrozqkQTYfcv1OlZIRIL0x6f+xhZ3TT+RTXC5AvKn/yA+3PIWERrKWqHMJPD7G/Vi0SuBtWAn3IvCGx2/UB1g==", "dev": true, + "peer": true, "dependencies": { "bytes-iec": "^3.1.1", "chokidar": "^3.5.3", @@ -9782,6 +9789,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.2.tgz", "integrity": "sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index ae1a588c..6da5e871 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/bind.ts b/src/bind.ts index 6b4dc64b..42bdcae4 100644 --- a/src/bind.ts +++ b/src/bind.ts @@ -100,7 +100,7 @@ function* bindings(el: Element): Iterable { type: action.slice(0, eventSep), tag: action.slice(eventSep + 1, methodSep), method: action.slice(methodSep + 1) || 'handleEvent' - } || 'handleEvent' + } } } diff --git a/src/lazy-define.ts b/src/lazy-define.ts index 755c3e61..3440353d 100644 --- a/src/lazy-define.ts +++ b/src/lazy-define.ts @@ -57,37 +57,117 @@ const strategies: Record = { type ElementLike = Element | Document | ShadowRoot -const pendingElements = new Set() -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} +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 void>): void export function lazyDefine(tagName: string, callback: () => void): void export function lazyDefine(tagNameOrObj: string | Record void>, singleCallback?: () => void) { @@ -102,6 +182,14 @@ export function lazyDefine(tagNameOrObj: string | Record 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) { diff --git a/test/lazy-define.ts b/test/lazy-define.ts index 5e49abaa..0a5b55f2 100644 --- a/test/lazy-define.ts +++ b/test/lazy-define.ts @@ -1,5 +1,5 @@ import {expect, fixture, html} from '@open-wc/testing' -import {spy} from 'sinon' +import {spy, stub} from 'sinon' import {lazyDefine, observe} from '../src/lazy-define.js' const animationFrame = () => new Promise(resolve => requestAnimationFrame(resolve)) @@ -148,4 +148,169 @@ describe('lazyDefine', () => { expect(onDefine).to.be.callCount(1) }) }) + + describe('observer lifecycle', () => { + it('re-observes for definitions registered after everything has resolved', async () => { + const onFirst = spy() + lazyDefine('idle-first-element', onFirst) + await fixture(html``) + await animationFrame() + // All pending definitions have resolved, so the observer disconnects here. + expect(onFirst).to.be.callCount(1) + + // A later registration must re-establish observation of newly added nodes. + const onSecond = spy() + lazyDefine('idle-second-element', onSecond) + await fixture(html``) + await animationFrame() + + expect(onSecond).to.be.callCount(1) + }) + + it('does no work when observe() is called with no pending definitions', async () => { + // Drain any pending state from prior expectations. + await animationFrame() + + const rafSpy = spy(window, 'requestAnimationFrame') + // core.ts calls observe() for every shadow-root controller on connect; it + // must be free when nothing is waiting to be lazily defined. + observe(document) + const scheduled = rafSpy.callCount + rafSpy.restore() + + expect(scheduled).to.equal(0) + }) + }) + + describe('race condition prevention', () => { + it('does not fire callbacks multiple times from concurrent scans', async () => { + const onDefine = spy() + lazyDefine('race-test-element', onDefine) + + // Create multiple elements to trigger multiple scans + await fixture(html``) + await fixture(html``) + + await animationFrame() + await animationFrame() + + // Should only be called once despite multiple elements triggering scans + expect(onDefine).to.be.callCount(1) + }) + }) + + describe('late registration', () => { + it('runs a callback registered for a tag that already resolved', async () => { + const onDefine1 = spy() + const onDefine2 = spy() + + // Register and trigger first callback + lazyDefine('late-reg-element', onDefine1) + await fixture(html``) + await animationFrame() + expect(onDefine1).to.be.callCount(1) + + // Register a second callback after the element already exists in the DOM + lazyDefine('late-reg-element', onDefine2) + await animationFrame() + + // The late callback should still run + expect(onDefine2).to.be.callCount(1) + }) + }) + + describe('error handling', () => { + it('handles callback errors without breaking other callbacks', async () => { + const onDefine1 = spy(() => { + throw new Error('Test error') + }) + const onDefine2 = spy() + + const errors: unknown[] = [] + const reportErrorStub = stub(globalThis, 'reportError').callsFake((err: unknown) => errors.push(err)) + + try { + lazyDefine('error-test-element', onDefine1) + lazyDefine('error-test-element', onDefine2) + + await fixture(html``) + await animationFrame() + + // Both callbacks should be called despite first one throwing + expect(onDefine1).to.be.callCount(1) + expect(onDefine2).to.be.callCount(1) + + // Error should have been reported + expect(errors.length).to.be.greaterThan(0) + } finally { + reportErrorStub.restore() + } + }) + }) + + describe('redundant observe calls', () => { + it('does not observe the same target multiple times', async () => { + const onDefine = spy() + const el = await fixture(html`
`) + const shadowRoot = el.attachShadow({mode: 'open'}) + + lazyDefine('redundant-test-element', onDefine) + + // Observe the same shadow root multiple times + observe(shadowRoot) + observe(shadowRoot) + observe(shadowRoot) + + // eslint-disable-next-line github/unescaped-html-literal + shadowRoot.innerHTML = '' + + await animationFrame() + + // Should still only be called once + expect(onDefine).to.be.callCount(1) + }) + }) + + describe('large scale scanning', () => { + it('resolves a tag efficiently when many are registered', async () => { + const onDefine = spy() + const defs: Record void> = {'needle-el': onDefine} + const tags = ['needle-el'] + for (let i = 0; i < 300; i++) { + const tag = `bulk-el-${i}` + defs[tag] = () => {} + tags.push(tag) + } + lazyDefine(defs) + + // Provide an element for every registered tag so all definitions resolve + // (and module state drains) instead of leaking into later tests. + const container = document.createElement('div') + for (const tag of tags) container.appendChild(document.createElement(tag)) + const host = await fixture(html`
`) + host.appendChild(container) + + await animationFrame() + await animationFrame() + + expect(onDefine).to.be.callCount(1) + }) + + it('finds a target deep inside a large subtree', async () => { + const onDefine = spy() + lazyDefine('deep-needle-el', onDefine) + + const container = document.createElement('div') + for (let i = 0; i < 5000; i++) container.appendChild(document.createElement('span')) + container.appendChild(document.createElement('deep-needle-el')) + + const host = await fixture(html`
`) + host.appendChild(container) + + // May span more than one frame if the scan is time-sliced. + for (let i = 0; i < 20 && onDefine.callCount === 0; i++) await animationFrame() + + expect(onDefine).to.be.callCount(1) + }) + }) })