@@ -24,9 +24,10 @@ import {
24
24
USE_ON_LOCAL_SEQ_IDX ,
25
25
} from './utils/markers' ;
26
26
import { MAX_RETRY_ON_PROMISE_COUNT , isPromise , maybeThen , safeCall } from './utils/promises' ;
27
- import type { ValueOrPromise } from './utils/types' ;
27
+ import { isArray , isPrimitive , type ValueOrPromise } from './utils/types' ;
28
28
import { getSubscriber } from '../reactive-primitives/subscriber' ;
29
29
import { EffectProperty } from '../reactive-primitives/types' ;
30
+ import { EventNameJSXScope } from './utils/event-names' ;
30
31
31
32
/**
32
33
* Use `executeComponent` to execute a component.
@@ -130,70 +131,81 @@ export const executeComponent = (
130
131
} ;
131
132
132
133
/**
133
- * Stores the JSX output of the last execution of the component .
134
+ * Adds `useOn` events to the JSX output .
134
135
*
135
- * Component can execute multiple times because:
136
- *
137
- * - Component can have multiple tasks
138
- * - Tasks can track signals
139
- * - Task A can change signal which causes Task B to rerun.
140
- *
141
- * So when executing a component we only care about its last JSX Output.
136
+ * @param jsx The JSX output to modify.
137
+ * @param useOnEvents The `useOn` events to add.
138
+ * @returns The modified JSX output.
142
139
*/
143
-
144
140
function addUseOnEvents (
145
141
jsx : JSXOutput ,
146
142
useOnEvents : UseOnMap
147
143
) : ValueOrPromise < JSXNodeInternal < string > | null | JSXOutput > {
148
- const jsxElement = findFirstStringJSX ( jsx ) ;
144
+ const jsxElement = findFirstElementNode ( jsx ) ;
149
145
let jsxResult = jsx ;
146
+ const qVisibleEvent = 'onQvisible$' ;
150
147
return maybeThen ( jsxElement , ( jsxElement ) => {
151
- let isInvisibleComponent = false ;
152
- if ( ! jsxElement ) {
153
- /**
154
- * We did not find any jsx node with a string tag. This means that we should append:
155
- *
156
- * ```html
157
- * <script type="placeholder" hidden on-document:qinit="..."></script>
158
- * ```
159
- *
160
- * This is needed because use on events should have a node to attach them to.
161
- */
162
- isInvisibleComponent = true ;
163
- }
148
+ // headless components are components that don't render a real DOM element
149
+ const isHeadless = ! jsxElement ;
150
+ // placeholder element is a <script> element that is used to add events to the document or window
151
+ let placeholderElement : JSXNodeInternal < string > | null = null ;
164
152
for ( const key in useOnEvents ) {
165
153
if ( Object . prototype . hasOwnProperty . call ( useOnEvents , key ) ) {
166
- if ( isInvisibleComponent ) {
167
- if ( key === 'onQvisible$' ) {
168
- const [ jsxElement , jsx ] = addScriptNodeForInvisibleComponents ( jsxResult ) ;
169
- jsxResult = jsx ;
170
- if ( jsxElement ) {
171
- addUseOnEvent ( jsxElement , 'document:onQinit$' , useOnEvents [ key ] ) ;
154
+ let targetElement = jsxElement ;
155
+ let eventKey = key ;
156
+
157
+ if ( isHeadless ) {
158
+ // if the component is headless, we need to add the event to the placeholder element
159
+ if (
160
+ key === qVisibleEvent ||
161
+ key . startsWith ( EventNameJSXScope . document ) ||
162
+ key . startsWith ( EventNameJSXScope . window )
163
+ ) {
164
+ if ( ! placeholderElement ) {
165
+ const [ createdElement , newJsx ] = injectPlaceholderElement ( jsxResult ) ;
166
+ jsxResult = newJsx ;
167
+ placeholderElement = createdElement ;
172
168
}
173
- } else if ( key . startsWith ( 'document:' ) || key . startsWith ( 'window:' ) ) {
174
- const [ jsxElement , jsx ] = addScriptNodeForInvisibleComponents ( jsxResult ) ;
175
- jsxResult = jsx ;
176
- if ( jsxElement ) {
177
- addUseOnEvent ( jsxElement , key , useOnEvents [ key ] ) ;
169
+ targetElement = placeholderElement ;
170
+ } else {
171
+ if ( isDev ) {
172
+ logWarn (
173
+ 'You are trying to add an event "' +
174
+ key +
175
+ '" using `useOn` hook, ' +
176
+ 'but a node to which you can add an event is not found. ' +
177
+ 'Please make sure that the component has a valid element node. '
178
+ ) ;
178
179
}
179
- } else if ( isDev ) {
180
+ continue ;
181
+ }
182
+ }
183
+ if ( targetElement ) {
184
+ if ( targetElement . type === 'script' && key === qVisibleEvent ) {
185
+ eventKey = 'document:onQinit$' ;
180
186
logWarn (
181
187
'You are trying to add an event "' +
182
188
key +
183
- '" using `useOn ` hook, ' +
189
+ '" using `useVisibleTask$ ` hook, ' +
184
190
'but a node to which you can add an event is not found. ' +
185
- 'Please make sure that the component has a valid element node. '
191
+ 'Using document:onQinit$ instead. '
186
192
) ;
187
193
}
188
- } else if ( jsxElement ) {
189
- addUseOnEvent ( jsxElement , key , useOnEvents [ key ] ) ;
194
+ addUseOnEvent ( targetElement , eventKey , useOnEvents [ key ] ) ;
190
195
}
191
196
}
192
197
}
193
198
return jsxResult ;
194
199
} ) ;
195
200
}
196
201
202
+ /**
203
+ * Adds an event to the JSX element.
204
+ *
205
+ * @param jsxElement The JSX element to add the event to.
206
+ * @param key The event name.
207
+ * @param value The event value.
208
+ */
197
209
function addUseOnEvent (
198
210
jsxElement : JSXNodeInternal ,
199
211
key : string ,
@@ -213,7 +225,13 @@ function addUseOnEvent(
213
225
props [ key ] = propValue ;
214
226
}
215
227
216
- function findFirstStringJSX ( jsx : JSXOutput ) : ValueOrPromise < JSXNodeInternal < string > | null > {
228
+ /**
229
+ * Finds the first element node in the JSX output.
230
+ *
231
+ * @param jsx The JSX output to search.
232
+ * @returns The first element node or null if no element node is found.
233
+ */
234
+ function findFirstElementNode ( jsx : JSXOutput ) : ValueOrPromise < JSXNodeInternal < string > | null > {
217
235
const queue : any [ ] = [ jsx ] ;
218
236
while ( queue . length ) {
219
237
const jsx = queue . shift ( ) ;
@@ -222,50 +240,79 @@ function findFirstStringJSX(jsx: JSXOutput): ValueOrPromise<JSXNodeInternal<stri
222
240
return jsx as JSXNodeInternal < string > ;
223
241
}
224
242
queue . push ( jsx . children ) ;
225
- } else if ( Array . isArray ( jsx ) ) {
243
+ } else if ( isArray ( jsx ) ) {
226
244
queue . push ( ...jsx ) ;
227
245
} else if ( isPromise ( jsx ) ) {
228
246
return maybeThen < JSXOutput , JSXNodeInternal < string > | null > ( jsx , ( jsx ) =>
229
- findFirstStringJSX ( jsx )
247
+ findFirstElementNode ( jsx )
230
248
) ;
231
249
} else if ( isSignal ( jsx ) ) {
232
- return findFirstStringJSX ( untrack ( ( ) => jsx . value as JSXOutput ) ) ;
250
+ return findFirstElementNode ( untrack ( ( ) => jsx . value as JSXOutput ) ) ;
233
251
}
234
252
}
235
253
return null ;
236
254
}
237
255
238
- function addScriptNodeForInvisibleComponents (
256
+ /**
257
+ * Injects a placeholder <script> element into the JSX output.
258
+ *
259
+ * This is necessary for headless components (components that don't render a real DOM element) to
260
+ * have an anchor point for `useOn` event listeners that target the document or window.
261
+ *
262
+ * @param jsx The JSX output to modify.
263
+ * @returns A tuple containing the created placeholder element and the modified JSX output.
264
+ */
265
+ function injectPlaceholderElement (
239
266
jsx : JSXOutput
240
267
) : [ JSXNodeInternal < string > | null , JSXOutput | null ] {
268
+ // For regular JSX nodes, we can append the placeholder to its children.
241
269
if ( isJSXNode ( jsx ) ) {
242
- const jsxElement = new JSXNodeImpl (
243
- 'script' ,
244
- { } ,
245
- {
246
- type : 'placeholder' ,
247
- hidden : '' ,
248
- } ,
249
- null ,
250
- 3
251
- ) ;
270
+ const placeholder = createPlaceholderScriptNode ( ) ;
271
+ // For slots, we can't add children, so we wrap them in a fragment.
252
272
if ( jsx . type === Slot ) {
253
- return [ jsxElement , _jsxSorted ( Fragment , null , null , [ jsx , jsxElement ] , 0 , null ) ] ;
273
+ return [ placeholder , _jsxSorted ( Fragment , null , null , [ jsx , placeholder ] , 0 , null ) ] ;
254
274
}
255
275
256
276
if ( jsx . children == null ) {
257
- jsx . children = jsxElement ;
258
- } else if ( Array . isArray ( jsx . children ) ) {
259
- jsx . children . push ( jsxElement ) ;
277
+ jsx . children = placeholder ;
278
+ } else if ( isArray ( jsx . children ) ) {
279
+ jsx . children . push ( placeholder ) ;
260
280
} else {
261
- jsx . children = [ jsx . children , jsxElement ] ;
281
+ jsx . children = [ jsx . children , placeholder ] ;
262
282
}
263
- return [ jsxElement , jsx ] ;
264
- } else if ( Array . isArray ( jsx ) && jsx . length ) {
265
- // get first element
266
- const [ jsxElement , _ ] = addScriptNodeForInvisibleComponents ( jsx [ 0 ] ) ;
267
- return [ jsxElement , jsx ] ;
283
+ return [ placeholder , jsx ] ;
284
+ }
285
+
286
+ // For primitives, we can't add children, so we wrap them in a fragment.
287
+ if ( isPrimitive ( jsx ) ) {
288
+ const placeholder = createPlaceholderScriptNode ( ) ;
289
+ return [ placeholder , _jsxSorted ( Fragment , null , null , [ jsx , placeholder ] , 0 , null ) ] ;
290
+ }
291
+
292
+ // For an array of nodes, we inject the placeholder into the first element.
293
+ if ( isArray ( jsx ) && jsx . length > 0 ) {
294
+ const [ createdElement , _ ] = injectPlaceholderElement ( jsx [ 0 ] ) ;
295
+ return [ createdElement , jsx ] ;
268
296
}
269
297
270
- return [ null , null ] ;
298
+ // For anything else we do nothing.
299
+ return [ null , jsx ] ;
300
+ }
301
+
302
+ /**
303
+ * Creates a <script> element with a placeholder type.
304
+ *
305
+ * @returns A <script> element with a placeholder type.
306
+ */
307
+ function createPlaceholderScriptNode ( ) : JSXNodeInternal < string > {
308
+ return new JSXNodeImpl (
309
+ 'script' ,
310
+ { } ,
311
+ {
312
+ type : 'placeholder' ,
313
+ hidden : '' ,
314
+ } ,
315
+ null ,
316
+ 3
317
+ ) ;
271
318
}
0 commit comments