Skip to content

Commit 4e9ecd3

Browse files
committed
refactor: useOn event handling
1 parent 443029e commit 4e9ecd3

File tree

4 files changed

+127
-81
lines changed

4 files changed

+127
-81
lines changed

.changeset/tiny-cows-pick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/core': patch
3+
---
4+
5+
fix: using useOn and useVisibleTask$ in component with primitive value only

packages/qwik/src/core/shared/component-execution.ts

Lines changed: 94 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -131,77 +131,81 @@ export const executeComponent = (
131131
};
132132

133133
/**
134-
* Stores the JSX output of the last execution of the component.
134+
* Adds `useOn` events to the JSX output.
135135
*
136-
* Component can execute multiple times because:
137-
*
138-
* - Component can have multiple tasks
139-
* - Tasks can track signals
140-
* - Task A can change signal which causes Task B to rerun.
141-
*
142-
* 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.
143139
*/
144-
145140
function addUseOnEvents(
146141
jsx: JSXOutput,
147142
useOnEvents: UseOnMap
148143
): ValueOrPromise<JSXNodeInternal<string> | null | JSXOutput> {
149-
const jsxElement = findFirstStringJSX(jsx);
144+
const jsxElement = findFirstElementNode(jsx);
150145
let jsxResult = jsx;
146+
const qVisibleEvent = 'onQvisible$';
151147
return maybeThen(jsxElement, (jsxElement) => {
152-
let isInvisibleComponent = false;
153-
if (!jsxElement) {
154-
/**
155-
* We did not find any jsx node with a string tag. This means that we should append:
156-
*
157-
* ```html
158-
* <script type="placeholder" hidden on-document:qinit="..."></script>
159-
* ```
160-
*
161-
* This is needed because use on events should have a node to attach them to.
162-
*/
163-
isInvisibleComponent = true;
164-
}
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;
165152
for (const key in useOnEvents) {
166153
if (Object.prototype.hasOwnProperty.call(useOnEvents, key)) {
167-
if (isInvisibleComponent) {
168-
if (key === 'onQvisible$') {
169-
const [jsxElement, jsx] = addScriptNodeForInvisibleComponents(jsxResult);
170-
jsxResult = jsx;
171-
if (jsxElement) {
172-
addUseOnEvent(jsxElement, 'document:onQinit$', useOnEvents[key]);
173-
}
174-
} else if (
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 ||
175161
key.startsWith(EventNameJSXScope.document) ||
176162
key.startsWith(EventNameJSXScope.window)
177163
) {
178-
const [jsxElement, jsx] = addScriptNodeForInvisibleComponents(jsxResult);
179-
jsxResult = jsx;
180-
if (jsxElement) {
181-
addUseOnEvent(jsxElement, key, useOnEvents[key]);
164+
if (!placeholderElement) {
165+
const [createdElement, newJsx] = injectPlaceholderElement(jsxResult);
166+
jsxResult = newJsx;
167+
placeholderElement = createdElement;
168+
}
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+
);
182179
}
183-
} else if (isDev) {
180+
continue;
181+
}
182+
}
183+
if (targetElement) {
184+
if (targetElement.type === 'script' && key === qVisibleEvent) {
185+
eventKey = 'document:onQinit$';
184186
logWarn(
185187
'You are trying to add an event "' +
186188
key +
187-
'" using `useOn` hook, ' +
189+
'" using `useVisibleTask$` hook, ' +
188190
'but a node to which you can add an event is not found. ' +
189-
'Please make sure that the component has a valid element node. '
191+
'Using document:onQinit$ instead.'
190192
);
191193
}
192-
} else if (jsxElement) {
193-
if (jsxElement.type === 'script' && key === 'onQvisible$') {
194-
addUseOnEvent(jsxElement, 'document:onQinit$', useOnEvents[key]);
195-
} else {
196-
addUseOnEvent(jsxElement, key, useOnEvents[key]);
197-
}
194+
addUseOnEvent(targetElement, eventKey, useOnEvents[key]);
198195
}
199196
}
200197
}
201198
return jsxResult;
202199
});
203200
}
204201

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+
*/
205209
function addUseOnEvent(
206210
jsxElement: JSXNodeInternal,
207211
key: string,
@@ -221,7 +225,13 @@ function addUseOnEvent(
221225
props[key] = propValue;
222226
}
223227

224-
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> {
225235
const queue: any[] = [jsx];
226236
while (queue.length) {
227237
const jsx = queue.shift();
@@ -234,45 +244,67 @@ function findFirstStringJSX(jsx: JSXOutput): ValueOrPromise<JSXNodeInternal<stri
234244
queue.push(...jsx);
235245
} else if (isPromise(jsx)) {
236246
return maybeThen<JSXOutput, JSXNodeInternal<string> | null>(jsx, (jsx) =>
237-
findFirstStringJSX(jsx)
247+
findFirstElementNode(jsx)
238248
);
239249
} else if (isSignal(jsx)) {
240-
return findFirstStringJSX(untrack(() => jsx.value as JSXOutput));
250+
return findFirstElementNode(untrack(() => jsx.value as JSXOutput));
241251
}
242252
}
243253
return null;
244254
}
245255

246-
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(
247266
jsx: JSXOutput
248267
): [JSXNodeInternal<string> | null, JSXOutput | null] {
268+
// For regular JSX nodes, we can append the placeholder to its children.
249269
if (isJSXNode(jsx)) {
250-
const jsxElement = createScriptNode();
270+
const placeholder = createPlaceholderScriptNode();
271+
// For slots, we can't add children, so we wrap them in a fragment.
251272
if (jsx.type === Slot) {
252-
return [jsxElement, _jsxSorted(Fragment, null, null, [jsx, jsxElement], 0, null)];
273+
return [placeholder, _jsxSorted(Fragment, null, null, [jsx, placeholder], 0, null)];
253274
}
254275

255276
if (jsx.children == null) {
256-
jsx.children = jsxElement;
277+
jsx.children = placeholder;
257278
} else if (isArray(jsx.children)) {
258-
jsx.children.push(jsxElement);
279+
jsx.children.push(placeholder);
259280
} else {
260-
jsx.children = [jsx.children, jsxElement];
281+
jsx.children = [jsx.children, placeholder];
261282
}
262-
return [jsxElement, jsx];
263-
} else if (isArray(jsx) && jsx.length) {
264-
// get first element
265-
const [jsxElement, _] = addScriptNodeForInvisibleComponents(jsx[0]);
266-
return [jsxElement, jsx];
267-
} else if (isPrimitive(jsx)) {
268-
const jsxElement = createScriptNode();
269-
return [jsxElement, _jsxSorted(Fragment, null, null, [jsx, jsxElement], 0, null)];
283+
return [placeholder, jsx];
270284
}
271285

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];
296+
}
297+
298+
// For anything else we do nothing.
272299
return [null, jsx];
273300
}
274301

275-
function createScriptNode(): JSXNodeInternal<string> {
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> {
276308
return new JSXNodeImpl(
277309
'script',
278310
{},

packages/qwik/src/core/shared/utils/types.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,10 @@ export const isFunction = <T extends (...args: any) => any>(v: unknown): v is T
2424
return typeof v === 'function';
2525
};
2626

27-
export const isPrimitive = (v: unknown): v is string | number | boolean | null | undefined => {
28-
return (
29-
v === null ||
30-
v === undefined ||
31-
typeof v === 'string' ||
32-
typeof v === 'number' ||
33-
typeof v === 'boolean' ||
34-
typeof v === 'symbol'
35-
);
27+
export const isPrimitive = (
28+
v: unknown
29+
): v is string | number | boolean | null | undefined | symbol => {
30+
return typeof v !== 'object' && typeof v !== 'function' && v !== null && v !== undefined;
3631
};
3732

3833
/**

packages/qwik/src/core/tests/use-on.spec.tsx

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -809,31 +809,47 @@ describe.each([
809809
});
810810

811811
describe('regression', () => {
812-
it('#7230 - when multiple useOn are used in a component that is not rendered, it should add multiple script nodes', async () => {
813-
const BreakpointProvider = component$(() => {
812+
it('#7230 - when multiple useOn are used in a component that is headless, it should still execute the events', async () => {
813+
(globalThis as any).counter = 0;
814+
815+
const Cmp = component$(() => {
814816
useOnDocument(
815817
'click',
816-
$(() => {})
818+
$(() => {
819+
(globalThis as any).counter++;
820+
})
817821
);
818822

819823
useOnWindow(
820824
'resize',
821-
$(() => {})
825+
$(() => {
826+
(globalThis as any).counter++;
827+
})
822828
);
823829

824-
useVisibleTask$(() => {});
830+
useVisibleTask$(() => {
831+
(globalThis as any).counter++;
832+
});
825833

826834
return <Slot />;
827835
});
828836

829837
const LayoutTest = component$(() => {
830838
return (
831-
<BreakpointProvider>
839+
<Cmp>
832840
<div>test</div>
833-
</BreakpointProvider>
841+
</Cmp>
834842
);
835843
});
836-
const { vNode } = await render(<LayoutTest />, { debug });
844+
const { vNode, document } = await render(<LayoutTest />, { debug });
845+
if (render === ssrRenderToDom) {
846+
await trigger(document.body, 'script', ':document:qinit');
847+
}
848+
await trigger(document.body, 'script', ':document:click');
849+
await trigger(document.body, 'script', ':window:resize');
850+
expect((globalThis as any).counter).toBe(3);
851+
852+
(globalThis as any).counter = undefined;
837853
expect(vNode).toMatchVDOM(
838854
<Component ssr-required>
839855
<Component ssr-required>
@@ -842,8 +858,6 @@ describe.each([
842858
<div>test</div>
843859
</Component>
844860
<script type="placeholder" hidden></script>
845-
<script type="placeholder" hidden></script>
846-
<script type="placeholder" hidden></script>
847861
</Component>
848862
</Component>
849863
</Component>

0 commit comments

Comments
 (0)