11type 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
65const 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
8952const strategies : Record < string , Strategy > = {
9053 ready : ( ) => ready ,
@@ -94,121 +57,71 @@ const strategies: Record<string, Strategy> = {
9457
9558type 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
10764function 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-
163101export function lazyDefine ( object : Record < string , ( ) => void > ) : void
164102export function lazyDefine ( tagName : string , callback : ( ) => void ) : void
165103export 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
192114export 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}
0 commit comments