Skip to content

Commit 299a865

Browse files
authored
Merge pull request #7746 from QwikDev/v2-fix-use-on-events-headless
fix: use on events for headless components
2 parents 358cec6 + 4e9ecd3 commit 299a865

File tree

5 files changed

+186
-76
lines changed

5 files changed

+186
-76
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: 113 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ import {
2424
USE_ON_LOCAL_SEQ_IDX,
2525
} from './utils/markers';
2626
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';
2828
import { getSubscriber } from '../reactive-primitives/subscriber';
2929
import { EffectProperty } from '../reactive-primitives/types';
30+
import { EventNameJSXScope } from './utils/event-names';
3031

3132
/**
3233
* Use `executeComponent` to execute a component.
@@ -130,70 +131,81 @@ export const executeComponent = (
130131
};
131132

132133
/**
133-
* Stores the JSX output of the last execution of the component.
134+
* Adds `useOn` events to the JSX output.
134135
*
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.
142139
*/
143-
144140
function addUseOnEvents(
145141
jsx: JSXOutput,
146142
useOnEvents: UseOnMap
147143
): ValueOrPromise<JSXNodeInternal<string> | null | JSXOutput> {
148-
const jsxElement = findFirstStringJSX(jsx);
144+
const jsxElement = findFirstElementNode(jsx);
149145
let jsxResult = jsx;
146+
const qVisibleEvent = 'onQvisible$';
150147
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;
164152
for (const key in useOnEvents) {
165153
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;
172168
}
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+
);
178179
}
179-
} else if (isDev) {
180+
continue;
181+
}
182+
}
183+
if (targetElement) {
184+
if (targetElement.type === 'script' && key === qVisibleEvent) {
185+
eventKey = 'document:onQinit$';
180186
logWarn(
181187
'You are trying to add an event "' +
182188
key +
183-
'" using `useOn` hook, ' +
189+
'" using `useVisibleTask$` hook, ' +
184190
'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.'
186192
);
187193
}
188-
} else if (jsxElement) {
189-
addUseOnEvent(jsxElement, key, useOnEvents[key]);
194+
addUseOnEvent(targetElement, eventKey, useOnEvents[key]);
190195
}
191196
}
192197
}
193198
return jsxResult;
194199
});
195200
}
196201

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+
*/
197209
function addUseOnEvent(
198210
jsxElement: JSXNodeInternal,
199211
key: string,
@@ -213,7 +225,13 @@ function addUseOnEvent(
213225
props[key] = propValue;
214226
}
215227

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> {
217235
const queue: any[] = [jsx];
218236
while (queue.length) {
219237
const jsx = queue.shift();
@@ -222,50 +240,79 @@ function findFirstStringJSX(jsx: JSXOutput): ValueOrPromise<JSXNodeInternal<stri
222240
return jsx as JSXNodeInternal<string>;
223241
}
224242
queue.push(jsx.children);
225-
} else if (Array.isArray(jsx)) {
243+
} else if (isArray(jsx)) {
226244
queue.push(...jsx);
227245
} else if (isPromise(jsx)) {
228246
return maybeThen<JSXOutput, JSXNodeInternal<string> | null>(jsx, (jsx) =>
229-
findFirstStringJSX(jsx)
247+
findFirstElementNode(jsx)
230248
);
231249
} else if (isSignal(jsx)) {
232-
return findFirstStringJSX(untrack(() => jsx.value as JSXOutput));
250+
return findFirstElementNode(untrack(() => jsx.value as JSXOutput));
233251
}
234252
}
235253
return null;
236254
}
237255

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(
239266
jsx: JSXOutput
240267
): [JSXNodeInternal<string> | null, JSXOutput | null] {
268+
// For regular JSX nodes, we can append the placeholder to its children.
241269
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.
252272
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)];
254274
}
255275

256276
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);
260280
} else {
261-
jsx.children = [jsx.children, jsxElement];
281+
jsx.children = [jsx.children, placeholder];
262282
}
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];
268296
}
269297

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+
);
271318
}

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

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

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;
31+
};
32+
2733
/**
2834
* Type representing a value which is either resolve or a promise.
2935
*

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>

packages/qwik/src/core/tests/use-visible-task.spec.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,44 @@ describe.each([
367367
});
368368
});
369369

370+
it('should add q:visible event if only script tag is present', async () => {
371+
(globalThis as any).counter = 0;
372+
const Cmp = component$(() => {
373+
useVisibleTask$(() => {
374+
(globalThis as any).counter++;
375+
});
376+
return <script />;
377+
});
378+
379+
const { document } = await render(<Cmp />, { debug });
380+
if (render === ssrRenderToDom) {
381+
await trigger(document.body, 'script', ':document:qinit');
382+
}
383+
384+
expect((globalThis as any).counter).toBe(1);
385+
386+
(globalThis as any).counter = undefined;
387+
});
388+
389+
it('should add script tag for visible task if only primitive child is present', async () => {
390+
(globalThis as any).counter = 0;
391+
const Cmp = component$(() => {
392+
useVisibleTask$(() => {
393+
(globalThis as any).counter++;
394+
});
395+
return 123;
396+
});
397+
398+
const { document } = await render(<Cmp />, { debug });
399+
if (render === ssrRenderToDom) {
400+
await trigger(document.body, 'script[hidden]', ':document:qinit');
401+
}
402+
403+
expect((globalThis as any).counter).toBe(1);
404+
405+
(globalThis as any).counter = undefined;
406+
});
407+
370408
describe(render.name + ': queue', () => {
371409
it('should execute dependant visible tasks', async () => {
372410
(globalThis as any).log = [] as string[];

0 commit comments

Comments
 (0)