Skip to content

Commit f3ae77d

Browse files
authored
Merge pull request #7452 from QwikDev/v2-custom-events
fix: custom event names and DOMContentLoaded handling
2 parents 157d68c + 2c2badb commit f3ae77d

File tree

12 files changed

+457
-277
lines changed

12 files changed

+457
-277
lines changed

Diff for: .changeset/fluffy-poets-raise.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/core': patch
3+
---
4+
5+
fix: custom event names and DOMContentLoaded handling

Diff for: packages/docs/src/routes/api/qwik-testing/api.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@
209209
}
210210
],
211211
"kind": "Function",
212-
"content": "Trigger an event in unit tests on an element.\n\nFuture deprecation candidate.\n\n\n```typescript\nexport declare function trigger(root: Element, queryOrElement: string | Element | keyof HTMLElementTagNameMap | null, eventNameCamel: string, eventPayload?: any): Promise<void>;\n```\n\n\n<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nroot\n\n\n</td><td>\n\nElement\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\nqueryOrElement\n\n\n</td><td>\n\nstring \\| Element \\| keyof HTMLElementTagNameMap \\| null\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\neventNameCamel\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\neventPayload\n\n\n</td><td>\n\nany\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n</tbody></table>\n**Returns:**\n\nPromise&lt;void&gt;",
212+
"content": "Trigger an event in unit tests on an element.\n\nFuture deprecation candidate.\n\n\n```typescript\nexport declare function trigger(root: Element, queryOrElement: string | Element | keyof HTMLElementTagNameMap | null, eventName: string, eventPayload?: any): Promise<void>;\n```\n\n\n<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nroot\n\n\n</td><td>\n\nElement\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\nqueryOrElement\n\n\n</td><td>\n\nstring \\| Element \\| keyof HTMLElementTagNameMap \\| null\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\neventName\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\neventPayload\n\n\n</td><td>\n\nany\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n</tbody></table>\n**Returns:**\n\nPromise&lt;void&gt;",
213213
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/testing/element-fixture.ts",
214214
"mdFile": "core.trigger.md"
215215
},

Diff for: packages/docs/src/routes/api/qwik-testing/index.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ Future deprecation candidate.
499499
export declare function trigger(
500500
root: Element,
501501
queryOrElement: string | Element | keyof HTMLElementTagNameMap | null,
502-
eventNameCamel: string,
502+
eventName: string,
503503
eventPayload?: any,
504504
): Promise<void>;
505505
```
@@ -541,7 +541,7 @@ string \| Element \| keyof HTMLElementTagNameMap \| null
541541
</td></tr>
542542
<tr><td>
543543

544-
eventNameCamel
544+
eventName
545545

546546
</td><td>
547547

Diff for: packages/qwik/src/core/client/vnode-diff.ts

+9-9
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ import {
3434
import { isPromise } from '../shared/utils/promises';
3535
import { type ValueOrPromise } from '../shared/utils/types';
3636
import {
37-
convertEventNameFromJsxPropToHtmlAttr,
38-
getEventNameFromJsxProp,
39-
getEventNameScopeFromJsxProp,
37+
getEventNameFromJsxEvent,
38+
getEventNameScopeFromJsxEvent,
4039
isHtmlAttributeAnEventName,
4140
isJsxPropertyAnEventName,
41+
jsxEventToHtmlAttribute,
4242
} from '../shared/utils/event-names';
4343
import { ChoreType } from '../shared/util-chore-type';
4444
import { hasClassAttr } from '../shared/utils/scoped-styles';
@@ -595,8 +595,8 @@ export const vnode_diff = (
595595
if (isJsxPropertyAnEventName(key)) {
596596
// So for event handlers we must add them to the vNode so that qwikloader can look them up
597597
// But we need to mark them so that they don't get pulled into the diff.
598-
const eventName = getEventNameFromJsxProp(key);
599-
const scope = getEventNameScopeFromJsxProp(key);
598+
const eventName = getEventNameFromJsxEvent(key);
599+
const scope = getEventNameScopeFromJsxEvent(key);
600600
if (eventName) {
601601
vnode_setProp(
602602
vNewNode as ElementVNode,
@@ -610,7 +610,7 @@ export const vnode_diff = (
610610
// add an event attr with empty value for qwikloader element selector.
611611
// We don't need value here. For ssr this value is a QRL,
612612
// but for CSR value should be just empty
613-
const htmlEvent = convertEventNameFromJsxPropToHtmlAttr(key);
613+
const htmlEvent = jsxEventToHtmlAttribute(key);
614614
if (htmlEvent) {
615615
vnode_setAttr(journal, vNewNode as ElementVNode, htmlEvent, '');
616616
}
@@ -828,8 +828,8 @@ export const vnode_diff = (
828828
};
829829

830830
const recordJsxEvent = (key: string, value: any) => {
831-
const eventName = getEventNameFromJsxProp(key);
832-
const scope = getEventNameScopeFromJsxProp(key);
831+
const eventName = getEventNameFromJsxEvent(key);
832+
const scope = getEventNameScopeFromJsxEvent(key);
833833
if (eventName) {
834834
record(':' + scope + ':' + eventName, value);
835835
// register an event for qwik loader
@@ -840,7 +840,7 @@ export const vnode_diff = (
840840
// add an event attr with empty value for qwikloader element selector.
841841
// We don't need value here. For ssr this value is a QRL,
842842
// but for CSR value should be just empty
843-
const htmlEvent = convertEventNameFromJsxPropToHtmlAttr(key);
843+
const htmlEvent = jsxEventToHtmlAttribute(key);
844844
if (htmlEvent) {
845845
record(htmlEvent, '');
846846
}

Diff for: packages/qwik/src/core/shared/utils/event-names.ts

+171-108
Original file line numberDiff line numberDiff line change
@@ -9,140 +9,203 @@
99
* - A `-` (not at the beginning) makes next character uppercase: `dbl-click` => `dblClick`
1010
*/
1111

12+
export const enum EventNameJSXScope {
13+
on = 'on',
14+
window = 'window:on',
15+
document = 'document:on',
16+
}
17+
18+
const enum EventNameHtmlScope {
19+
on = 'on:',
20+
window = 'on-window:',
21+
document = 'on-document:',
22+
}
23+
24+
export const EVENT_SUFFIX = '$';
25+
export const DOMContentLoadedEvent = 'DOMContentLoaded';
26+
1227
export const isJsxPropertyAnEventName = (name: string): boolean => {
1328
return (
14-
(name.startsWith('on') || name.startsWith('window:on') || name.startsWith('document:on')) &&
15-
name.endsWith('$')
29+
(name.startsWith(EventNameJSXScope.on) ||
30+
name.startsWith(EventNameJSXScope.window) ||
31+
name.startsWith(EventNameJSXScope.document)) &&
32+
name.endsWith(EVENT_SUFFIX)
1633
);
1734
};
1835

1936
export const isHtmlAttributeAnEventName = (name: string): boolean => {
20-
return name.startsWith('on:') || name.startsWith('on-window:') || name.startsWith('on-document:');
37+
return (
38+
name.startsWith(EventNameHtmlScope.on) ||
39+
name.startsWith(EventNameHtmlScope.window) ||
40+
name.startsWith(EventNameHtmlScope.document)
41+
);
2142
};
2243

23-
export const getEventNameFromJsxProp = (name: string): string | null => {
24-
if (name.endsWith('$')) {
25-
let idx = -1;
26-
if (name.startsWith('on')) {
27-
idx = 2;
28-
} else if (name.startsWith('window:on')) {
29-
idx = 9;
30-
} else if (name.startsWith('document:on')) {
31-
idx = 11;
32-
}
33-
if (idx != -1) {
34-
const isCaseSensitive = isDashAt(name, idx) && !isDashAt(name, idx + 1);
35-
if (isCaseSensitive) {
36-
idx++;
37-
}
38-
let lastIdx = idx;
39-
let eventName = '';
40-
while (true as boolean) {
41-
idx = name.indexOf('-', lastIdx);
42-
const chunk = name.substring(
43-
lastIdx,
44-
idx === -1 ? name.length - 1 /* don't include `$` */ : idx
45-
);
46-
eventName += isCaseSensitive ? chunk : chunk.toLowerCase();
47-
if (idx == -1) {
48-
return eventName;
49-
}
50-
if (isDashAt(name, idx + 1)) {
51-
eventName += '-';
52-
idx++;
53-
} else {
54-
eventName += name.charAt(idx + 1).toUpperCase();
55-
idx++;
56-
}
57-
lastIdx = idx + 1;
58-
}
44+
/**
45+
* Converts a JSX event property to an HTML attribute. Examples:
46+
*
47+
* - OnClick$ -> on:click
48+
* - On-DOMContentLoaded$ -> on:-d-o-m-content-loaded
49+
* - On-CustomEvent$ -> on:-custom-event
50+
*/
51+
export function jsxEventToHtmlAttribute(jsxEvent: string): string | null {
52+
if (jsxEvent.endsWith(EVENT_SUFFIX)) {
53+
const [prefix, idx] = getEventScopeDataFromJsxEvent(jsxEvent);
54+
55+
if (idx !== -1) {
56+
const eventName = getEventNameFromJsxEvent(jsxEvent)!;
57+
return prefix + fromCamelToKebabCase(eventName);
5958
}
6059
}
61-
return null;
62-
};
60+
return null; // Return null if not matching expected format
61+
}
6362

64-
export const getEventNameScopeFromJsxProp = (name: string): string => {
65-
const index = name.indexOf(':');
66-
return index !== -1 ? name.substring(0, index) : '';
67-
};
63+
/**
64+
* Converts an HTML attribute back to JSX event property. Examples:
65+
*
66+
* - On:click -> onClick$
67+
* - On:-d-o-m-content-loaded -> onDOMContentLoaded$
68+
* - On:-custom-event -> on-CustomEvent$
69+
*/
70+
export function htmlAttributeToJsxEvent(htmlAttr: string): string | null {
71+
const eventScopeData = getEventScopeDataFromHtmlEvent(htmlAttr);
72+
let prefix = eventScopeData[0];
73+
const idx = eventScopeData[1];
6874

69-
export const getEventNameFromHtmlAttr = (name: string): string | null => {
70-
let idx = -1;
71-
if (name.startsWith('on:')) {
72-
idx = 3; // 'on:'.length
73-
} else if (name.startsWith('on-window:')) {
74-
idx = 10; // 'on-window:'.length
75-
} else if (name.startsWith('on-document:')) {
76-
idx = 12; // 'on-document:'.length
75+
if (idx !== -1) {
76+
const isCaseSensitive = isDash(htmlAttr.charCodeAt(idx));
77+
const eventName = htmlAttrToEventName(htmlAttr, idx);
78+
if (isCaseSensitive && eventName !== DOMContentLoadedEvent) {
79+
prefix += '-'; // Add hyphen at the start if case-sensitive
80+
}
81+
return eventNameToJsxEvent(eventName, prefix, idx);
7782
}
78-
if (idx != -1) {
79-
let lastIdx = idx;
80-
let eventName = '';
81-
while (true as boolean) {
82-
idx = name.indexOf('-', lastIdx);
83-
const chunk = name.substring(lastIdx, idx === -1 ? name.length : idx);
84-
eventName += chunk;
85-
if (idx == -1) {
86-
return eventName;
87-
}
88-
eventName += name.charAt(idx + 1).toUpperCase();
89-
idx++;
90-
lastIdx = idx + 1;
83+
return null; // Return null if not matching expected format
84+
}
85+
86+
export function eventNameToJsxEvent(eventName: string, prefix: string | null, startIdx = 0) {
87+
eventName = eventName.charAt(0).toUpperCase() + eventName.substring(1);
88+
return prefix + eventName + EVENT_SUFFIX;
89+
}
90+
91+
/**
92+
* Gets the event name from a JSX event property. Examples:
93+
*
94+
* - OnClick$ -> click
95+
* - OnDOMContentLoaded$ -> DOMContentLoaded
96+
* - On-CustomEvent$ -> CustomEvent
97+
*/
98+
export function getEventNameFromJsxEvent(jsxEvent: string): string | null {
99+
if (jsxEvent.endsWith(EVENT_SUFFIX)) {
100+
const [, idx] = getEventScopeDataFromJsxEvent(jsxEvent);
101+
if (idx != -1) {
102+
return jsxEventToEventName(jsxEvent, idx);
91103
}
92104
}
93105
return null;
94-
};
106+
}
95107

96-
const isDashAt = (name: string, idx: number): boolean => name.charCodeAt(idx) === 45; /* - */
108+
function jsxEventToEventName(jsxEvent: string, startIdx: number = 0): string {
109+
const idx = startIdx;
110+
let lastIdx = idx;
111+
const isCaseSensitive = isDash(jsxEvent.charCodeAt(idx));
112+
if (isCaseSensitive) {
113+
lastIdx++;
114+
}
115+
let eventName = '';
116+
const chunk = jsxEvent.substring(lastIdx, jsxEvent.length - 1 /* don't include `$` */);
117+
if (chunk === DOMContentLoadedEvent) {
118+
return DOMContentLoadedEvent;
119+
}
120+
eventName += isCaseSensitive ? chunk : chunk.toLowerCase();
121+
return eventName;
122+
}
97123

98-
export const convertEventNameFromHtmlAttrToJsxProp = (name: string): string | null => {
99-
let prefix: string | null = null;
100-
if (name.startsWith('on:')) {
101-
prefix = 'on';
102-
} else if (name.startsWith('on-window:')) {
103-
prefix = 'window:on';
104-
} else if (name.startsWith('on-document:')) {
105-
prefix = 'document:on';
124+
/**
125+
* Gets the event name from an HTML attribute. Examples:
126+
*
127+
* - On:click -> click
128+
* - On:-d-o-m-content-loaded -> DOMContentLoaded
129+
* - On:-custom-event -> CustomEvent
130+
*/
131+
export function getEventNameFromHtmlAttribute(htmlAttr: string): string {
132+
const [, idx] = getEventScopeDataFromHtmlEvent(htmlAttr);
133+
if (idx !== -1) {
134+
return htmlAttrToEventName(htmlAttr, idx);
106135
}
107-
if (prefix !== null) {
108-
const eventName = getEventNameFromHtmlAttr(name)!;
109-
let kebabCase = fromCamelToKebabCase(eventName);
110-
if (isDashAt(kebabCase, 0) && !isDashAt(kebabCase, 1)) {
111-
// special case for events which start with a `-`
112-
// if we would just append it would be interpreted as a case sensitive event
113-
kebabCase = '-' + kebabCase.charAt(1).toUpperCase() + kebabCase.substring(2);
114-
}
115-
return prefix + kebabCase + '$';
136+
return htmlAttr; // Return as is if not matching expected format
137+
}
138+
139+
/** Helper function to convert HTML attribute name to event name. */
140+
function htmlAttrToEventName(htmlAttr: string, startIdx: number = 0): string {
141+
let idx = startIdx;
142+
let lastIdx = idx;
143+
let eventName = '';
144+
const isCaseSensitive = isDash(htmlAttr.charCodeAt(lastIdx));
145+
if (isCaseSensitive) {
146+
lastIdx++; // Skip the hyphen
147+
eventName += htmlAttr.charAt(lastIdx).toUpperCase(); // Capitalize the first letter
148+
lastIdx++; // Skip the first letter
116149
}
117-
return null;
118-
};
119150

120-
export const convertEventNameFromJsxPropToHtmlAttr = (name: string): string | null => {
121-
if (name.endsWith('$')) {
122-
let prefix: string | null = null;
123-
// let idx = -1;
124-
if (name.startsWith('on')) {
125-
prefix = 'on:';
126-
// idx = 2; // 'on'.length
127-
} else if (name.startsWith('window:on')) {
128-
prefix = 'on-window:';
129-
// idx = 9; // 'window:on'.length
130-
} else if (name.startsWith('document:on')) {
131-
prefix = 'on-document:';
132-
// idx = 11; // 'document:on'.length
133-
}
134-
if (prefix !== null) {
135-
const eventName = getEventNameFromJsxProp(name)!;
136-
return prefix + fromCamelToKebabCase(eventName);
151+
while (true as boolean) {
152+
idx = htmlAttr.indexOf('-', lastIdx); // Find the next hyphen
153+
const chunk = htmlAttr.substring(lastIdx, idx === -1 ? htmlAttr.length : idx); // Get the chunk
154+
eventName += chunk; // Add the chunk to the event name
155+
if (idx == -1) {
156+
return eventName; // Return the event name if no more hyphens
137157
}
158+
idx++; // Move to the next character after the hyphen
159+
eventName += htmlAttr.charAt(idx).toUpperCase(); // Capitalize the next letter if previous character is hyphen
160+
lastIdx = idx + 1; // Move to the next character
138161
}
139-
return null;
140-
};
162+
return eventName;
163+
}
141164

142-
export const fromCamelToKebabCase = (text: string): string => {
143-
return text.replace(/([A-Z-])/g, '-$1').toLowerCase();
165+
export function getEventScopeDataFromJsxEvent(eventName: string): [string | null, number] {
166+
let prefix: EventNameHtmlScope | null = null;
167+
let idx = -1;
168+
// set prefix and idx based on the scope
169+
if (eventName.startsWith(EventNameJSXScope.on)) {
170+
prefix = EventNameHtmlScope.on;
171+
idx = 2;
172+
} else if (eventName.startsWith(EventNameJSXScope.window)) {
173+
prefix = EventNameHtmlScope.window;
174+
idx = 9;
175+
} else if (eventName.startsWith(EventNameJSXScope.document)) {
176+
prefix = EventNameHtmlScope.document;
177+
idx = 11;
178+
}
179+
return [prefix, idx];
180+
}
181+
182+
function getEventScopeDataFromHtmlEvent(htmlAttr: string): [string | null, number] {
183+
let prefix: EventNameJSXScope | null = null;
184+
let idx = -1;
185+
if (htmlAttr.startsWith(EventNameHtmlScope.on)) {
186+
prefix = EventNameJSXScope.on;
187+
idx = 3;
188+
} else if (htmlAttr.startsWith(EventNameHtmlScope.window)) {
189+
prefix = EventNameJSXScope.window;
190+
idx = 10;
191+
} else if (htmlAttr.startsWith(EventNameHtmlScope.document)) {
192+
prefix = EventNameJSXScope.document;
193+
idx = 12;
194+
}
195+
return [prefix, idx];
196+
}
197+
198+
export const isDash = (charCode: number): boolean => charCode === 45; /* - */
199+
200+
export const getEventNameScopeFromJsxEvent = (name: string): string => {
201+
const index = name.indexOf(':');
202+
return index !== -1 ? name.substring(0, index) : '';
144203
};
145204

146205
export function isPreventDefault(key: string): boolean {
147206
return key.startsWith('preventdefault:');
148207
}
208+
209+
export const fromCamelToKebabCase = (text: string): string => {
210+
return text.replace(/([A-Z-])/g, '-$1').toLowerCase();
211+
};

0 commit comments

Comments
 (0)