diff --git a/.changeset/dirty-meals-attack.md b/.changeset/dirty-meals-attack.md new file mode 100644 index 00000000000..102490f1839 --- /dev/null +++ b/.changeset/dirty-meals-attack.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +fix: don't trigger document and window events for normal events diff --git a/packages/qwik/src/core/client/vnode-diff.ts b/packages/qwik/src/core/client/vnode-diff.ts index c819a94ed91..fcd345a7a2b 100644 --- a/packages/qwik/src/core/client/vnode-diff.ts +++ b/packages/qwik/src/core/client/vnode-diff.ts @@ -37,6 +37,8 @@ import { _OWNER, _PROPS_HANDLER } from '../shared/utils/constants'; import { fromCamelToKebabCase, getEventDataFromHtmlAttribute, + getLoaderScopedEventName, + getScopedEventName, isHtmlAttributeAnEventName, } from '../shared/utils/event-names'; import { getFileLocationFromJsx } from '../shared/utils/jsx-filename'; @@ -660,16 +662,18 @@ export const vnode_diff = ( if (isHtmlAttributeAnEventName(key)) { const data = getEventDataFromHtmlAttribute(key); if (data) { - const scope = data[0]; - const eventName = data[1]; + const [scope, eventName] = data; + const scopedEvent = getScopedEventName(scope, eventName); + const loaderScopedEvent = getLoaderScopedEventName(scope, scopedEvent); if (eventName) { - vNewNode!.setProp(HANDLER_PREFIX + ':' + scope + ':' + eventName, value); + vNewNode!.setProp(HANDLER_PREFIX + ':' + scopedEvent, value); if (scope) { // window and document need attrs so qwik loader can find them vNewNode!.setAttr(key, '', journal); } - registerQwikLoaderEvent(eventName); + // register an event for qwik loader (window/document prefixed with '-') + registerQwikLoaderEvent(loaderScopedEvent); } } @@ -932,9 +936,11 @@ export const vnode_diff = ( const data = getEventDataFromHtmlAttribute(key); if (data) { const [scope, eventName] = data; - record(':' + scope + ':' + eventName, value); - // register an event for qwik loader - registerQwikLoaderEvent(eventName); + const scopedEvent = getScopedEventName(scope, eventName); + const loaderScopedEvent = getLoaderScopedEventName(scope, scopedEvent); + record(':' + scopedEvent, value); + // register an event for qwik loader (window/document prefixed with '-') + registerQwikLoaderEvent(loaderScopedEvent); patchEventDispatch = true; } }; diff --git a/packages/qwik/src/core/shared/utils/event-names.ts b/packages/qwik/src/core/shared/utils/event-names.ts index 01f20c790e8..96e4b4489e4 100644 --- a/packages/qwik/src/core/shared/utils/event-names.ts +++ b/packages/qwik/src/core/shared/utils/event-names.ts @@ -109,3 +109,12 @@ export const getEventDataFromHtmlAttribute = (htmlKey: string): [string, string] } return ['document', htmlKey.substring(12)]; }; + +export const getScopedEventName = (scope: string, eventName: string): string => { + const suffix = ':' + eventName; + return scope ? scope + suffix : suffix; +}; + +export const getLoaderScopedEventName = (scope: string, scopedEvent: string): string => { + return scope ? '-' + scopedEvent : scopedEvent; +}; diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts index 7669044ed3a..728d3fb544e 100644 --- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts +++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts @@ -18,6 +18,8 @@ import { DEBUG_TYPE, VirtualType } from '../shared/types'; import { isAsyncGenerator } from '../shared/utils/async-generator'; import { getEventDataFromHtmlAttribute, + getLoaderScopedEventName, + getScopedEventName, isHtmlAttributeAnEventName, isPreventDefault, } from '../shared/utils/event-names'; @@ -167,13 +169,13 @@ function processJSXNode( const innerHTML = ssr.openElement( type, - varPropsToSsrAttrs(jsx.varProps, jsx.constProps, { + toSsrAttrs(jsx.varProps, { serializationCtx: ssr.serializationCtx, styleScopedId: options.styleScoped, key: jsx.key, toSort: jsx.toSort, }), - constPropsToSsrAttrs(jsx.constProps, jsx.varProps, { + toSsrAttrs(jsx.constProps, { serializationCtx: ssr.serializationCtx, styleScopedId: options.styleScoped, }), @@ -325,22 +327,6 @@ interface SsrAttrsOptions { toSort?: boolean; } -export function varPropsToSsrAttrs( - varProps: Record, - constProps: Record | null, - options: SsrAttrsOptions -): SsrAttrs | null { - return toSsrAttrs(varProps, options); -} - -export function constPropsToSsrAttrs( - constProps: Record | null, - varProps: Record, - options: SsrAttrsOptions -): SsrAttrs | null { - return toSsrAttrs(constProps, options); -} - export function toSsrAttrs( record: Record | null | undefined, options: SsrAttrsOptions @@ -451,8 +437,10 @@ function addQwikEventToSerializationContext( // TODO extract window/document too so qwikloader can precisely listen const data = getEventDataFromHtmlAttribute(key); if (data) { - const eventName = data[1]; - serializationCtx.$eventNames$.add(eventName); + const [scope, eventName] = data; + const scopedEvent = getScopedEventName(scope, eventName); + const loaderScopedEvent = getLoaderScopedEventName(scope, scopedEvent); + serializationCtx.$eventNames$.add(loaderScopedEvent); serializationCtx.$eventQrls$.add(qrl); } } @@ -461,8 +449,8 @@ function addPreventDefaultEventToSerializationContext( serializationCtx: SerializationContext, key: string ) { - // skip first 15 chars, this is length of the `preventdefault:` - const eventName = key.substring(15); + // skip first 15 chars, this is length of the `preventdefault`, leave the ":" + const eventName = key.substring(14); if (eventName) { serializationCtx.$eventNames$.add(eventName); } diff --git a/packages/qwik/src/core/tests/container.spec.tsx b/packages/qwik/src/core/tests/container.spec.tsx index fd2c7b35463..439178e3d2e 100644 --- a/packages/qwik/src/core/tests/container.spec.tsx +++ b/packages/qwik/src/core/tests/container.spec.tsx @@ -21,8 +21,8 @@ import type { QRLInternal } from '../shared/qrl/qrl-class'; import { _qrlSync } from '../shared/qrl/qrl.public'; import { TypeIds } from '../shared/serdes/constants'; import { hasClassAttr } from '../shared/utils/scoped-styles'; -import { constPropsToSsrAttrs, varPropsToSsrAttrs } from '../ssr/ssr-render-jsx'; import { type SSRContainer } from '../ssr/ssr-types'; +import { toSsrAttrs } from '../ssr/ssr-render-jsx'; vi.hoisted(() => { vi.stubGlobal('QWIK_LOADER_DEFAULT_MINIFIED', 'min'); @@ -605,12 +605,12 @@ async function toHTML(jsx: JSXOutput): Promise { } ssrContainer.openElement( jsx.type, - varPropsToSsrAttrs(jsx.varProps as any, jsx.constProps, { + toSsrAttrs(jsx.varProps, { serializationCtx: ssrContainer.serializationCtx, styleScopedId: null, key: jsx.key, }), - constPropsToSsrAttrs(jsx.constProps as any, jsx.varProps, { + toSsrAttrs(jsx.constProps, { serializationCtx: ssrContainer.serializationCtx, styleScopedId: null, }) diff --git a/packages/qwik/src/core/tests/render-api.spec.tsx b/packages/qwik/src/core/tests/render-api.spec.tsx index a16b8f7da5e..bb1b3169aaf 100644 --- a/packages/qwik/src/core/tests/render-api.spec.tsx +++ b/packages/qwik/src/core/tests/render-api.spec.tsx @@ -99,6 +99,8 @@ const ManyEventsComponent = component$(() => { onClick$={inlinedQrl(() => {}, 's_click2')} onBlur$={inlinedQrl(() => {}, 's_blur1')} on-anotherCustom$={inlinedQrl(() => {}, 's_anotherCustom1')} + document:onFocus$={inlinedQrl(() => {}, 's_documentFocus1')} + window:onClick$={inlinedQrl(() => {}, 's_windowClick1')} > click @@ -521,7 +523,8 @@ describe('render api', () => { qwikLoader: 'module', }); expect(result.html).toContain( - '(window.qwikevents||(window.qwikevents=[])).push("focus", "-my---custom", "click", "dblclick", "another-custom", "blur")' + '(window.qwikevents||(window.qwikevents=[])).push(' + + '":focus", ":-my---custom", ":click", ":dblclick", "-document:focus", ":another-custom", ":blur", "-window:click")' ); }); }); diff --git a/packages/qwik/src/qwikloader.ts b/packages/qwik/src/qwikloader.ts index 6730576aa42..71f277ea147 100644 --- a/packages/qwik/src/qwikloader.ts +++ b/packages/qwik/src/qwikloader.ts @@ -24,6 +24,8 @@ const win = window as unknown as qWindow; const events = new Set(); const roots = new Set([doc]); const symbols: Record = {}; +const windowPrefix = '-window'; +const documentPrefix = '-document'; let hasInitialized: number; @@ -104,12 +106,12 @@ const dispatch = async ( } return; } - const attrValue = element.getAttribute(attrName); // const qDispatchEvent = (element as QElement).qDispatchEvent; if (qDispatchEvent) { return qDispatchEvent(ev, scope); } + const attrValue = element.getAttribute(attrName); if (attrValue) { const container = element.closest( '[q\\:container]:not([q\\:container=html]):not([q\\:container=text])' @@ -207,11 +209,14 @@ const camelToKebab = (str: string) => str.replace(/([A-Z-])/g, (a) => '-' + a.to * * @param ev - Browser event. */ -const processDocumentEvent = async (ev: Event) => { +const processDocumentEvent = async (ev: Event, scope: string) => { // eslint-disable-next-line prefer-const let type = camelToKebab(ev.type); let element = ev.target as Element | null; - broadcast('-document', ev, type); + if (scope === documentPrefix) { + broadcast(documentPrefix, ev, type); + return; + } while (element && element.getAttribute) { const results = dispatch(element, '', ev, type); @@ -227,7 +232,7 @@ const processDocumentEvent = async (ev: Event) => { }; const processWindowEvent = (ev: Event) => { - broadcast('-window', ev, camelToKebab(ev.type)); + broadcast(windowPrefix, ev, camelToKebab(ev.type)); }; const processReadyStateChange = () => { @@ -241,7 +246,7 @@ const processReadyStateChange = () => { const riC = win.requestIdleCallback ?? win.setTimeout; riC.bind(win)(() => emitEvent('qidle')); - if (events.has('qvisible')) { + if (events.has(':qvisible')) { const results = querySelectorAll('[on\\:qvisible]'); const observer = new IntersectionObserver((entries) => { for (const entry of entries) { @@ -268,23 +273,47 @@ const addEventListener = ( // Keep in sync with ./qwikloader.unit.ts const kebabToCamel = (eventName: string) => eventName.replace(/-./g, (a) => a[1].toUpperCase()); +const processEventName = (event: string) => { + const i = event.indexOf(':'); + let scope = ''; + let eventName = event; + if (i >= 0) { + const s = event.substring(0, i); + if (s === '' || s === windowPrefix || s === documentPrefix) { + scope = s; + eventName = event.substring(i + 1); + } + } + return { scope, eventName: kebabToCamel(eventName) }; +}; + const processEventOrNode = (...eventNames: (string | (EventTarget & ParentNode))[]) => { for (const eventNameOrNode of eventNames) { if (typeof eventNameOrNode === 'string') { // If it is string we just add the event to window and each of our roots. if (!events.has(eventNameOrNode)) { events.add(eventNameOrNode); - const eventName = kebabToCamel(eventNameOrNode); - roots.forEach((root) => addEventListener(root, eventName, processDocumentEvent, true)); + const { scope, eventName } = processEventName(eventNameOrNode); - addEventListener(win, eventName, processWindowEvent, true); + if (scope === windowPrefix) { + addEventListener(win, eventName, processWindowEvent, true); + } else { + roots.forEach((root) => + addEventListener(root, eventName, (ev) => processDocumentEvent(ev, scope), true) + ); + } } } else { // If it is a new root, we also need this root to catch up to all of the document events so far. if (!roots.has(eventNameOrNode)) { events.forEach((kebabEventName) => { - const eventName = kebabToCamel(kebabEventName); - addEventListener(eventNameOrNode, eventName, processDocumentEvent, true); + const { scope, eventName } = processEventName(kebabEventName); + addEventListener( + eventNameOrNode, + eventName, + (ev) => processDocumentEvent(ev, scope), + true + ); }); roots.add(eventNameOrNode); diff --git a/packages/qwik/src/qwikloader.unit.ts b/packages/qwik/src/qwikloader.unit.ts index a9c6b36a91b..8000f4503a6 100644 --- a/packages/qwik/src/qwikloader.unit.ts +++ b/packages/qwik/src/qwikloader.unit.ts @@ -23,13 +23,13 @@ test('qwikloader script', () => { const compressed = compress(Buffer.from(qwikLoader), { mode: 1, quality: 11 }); expect([compressed.length, qwikLoader.length]).toMatchInlineSnapshot(` [ - 1515, - 3308, + 1615, + 3555, ] `); expect(qwikLoader).toMatchInlineSnapshot( - `"const t=document,e=window,n=new Set,o=new Set([t]),r={};let s;const i=(t,e)=>Array.from(t.querySelectorAll(e)),a=t=>{const e=[];return o.forEach(n=>e.push(...i(n,t))),e},c=t=>{w(t),i(t,"[q\\\\:shadowroot]").forEach(t=>{const e=t.shadowRoot;e&&c(e)})},l=t=>t&&"function"==typeof t.then,f=(t,e,n=e.type)=>{a("[on"+t+"\\\\:"+n+"]").forEach(o=>{b(o,t,e,n)})},p=e=>{if(void 0===e._qwikjson_){let n=(e===t.documentElement?t.body:e).lastElementChild;for(;n;){if("SCRIPT"===n.tagName&&"qwik/json"===n.getAttribute("type")){e._qwikjson_=JSON.parse(n.textContent.replace(/\\\\x3C(\\/?script)/gi,"<$1"));break}n=n.previousElementSibling}}},u=(t,e)=>new CustomEvent(t,{detail:e}),b=async(e,n,o,s=o.type)=>{const i="on"+n+":"+s;e.hasAttribute("preventdefault:"+s)&&o.preventDefault(),e.hasAttribute("stoppropagation:"+s)&&o.stopPropagation();const a=e._qc_,c=a&&a.li.filter(t=>t[0]===i);if(c&&c.length>0){for(const t of c){const n=t[1].getFn([e,o],()=>e.isConnected)(o,e),r=o.cancelBubble;l(n)&&await n,r&&o.stopPropagation()}return}const f=e.getAttribute(i),u=e.qDispatchEvent;if(u)return u(o,n);if(f){const n=e.closest("[q\\\\:container]:not([q\\\\:container=html]):not([q\\\\:container=text])"),s=n.getAttribute("q:base"),i=n.getAttribute("q:version")||"unknown",a=n.getAttribute("q:manifest-hash")||"dev",c=new URL(s,t.baseURI);for(const u of f.split("\\n")){const f=new URL(u,c),b=f.href,h=f.hash.replace(/^#?([^?[|]*).*$/,"$1")||"default",_=performance.now();let d,y,g;const m=u.startsWith("#"),w={qBase:s,qManifest:a,qVersion:i,href:b,symbol:h,element:e,reqTime:_};if(m){const e=n.getAttribute("q:instance");d=(t["qFuncs_"+e]||[])[Number.parseInt(h)],d||(y="sync",g=Error("sym:"+h))}else if(h in r)d=r[h];else{q("qsymbol",w);const t=f.href.split("#")[0];try{const e=import(t);p(n),d=(await e)[h],d?r[h]=d:(y="no-symbol",g=Error(\`\${h} not in \${t}\`))}catch(t){y||(y="async"),g=t}}if(!d){q("qerror",{importError:y,error:g,...w}),console.error(g);break}const v=t.__q_context__;if(e.isConnected)try{t.__q_context__=[e,o,f];const n=d(o,e);l(n)&&await n}catch(t){q("qerror",{error:t,...w})}finally{t.__q_context__=v}}}},q=(e,n)=>{t.dispatchEvent(u(e,n))},h=t=>t.replace(/([A-Z-])/g,t=>"-"+t.toLowerCase()),_=async t=>{let e=h(t.type),n=t.target;for(f("-document",t,e);n&&n.getAttribute;){const o=b(n,"",t,e);let r=t.cancelBubble;l(o)&&await o,r||(r=r||t.cancelBubble||n.hasAttribute("stoppropagation:"+t.type)),n=t.bubbles&&!0!==r?n.parentElement:null}},d=t=>{f("-window",t,h(t.type))},y=()=>{const r=t.readyState;if(!s&&("interactive"==r||"complete"==r)&&(o.forEach(c),s=1,q("qinit"),(e.requestIdleCallback??e.setTimeout).bind(e)(()=>q("qidle")),n.has("qvisible"))){const t=a("[on\\\\:qvisible]"),e=new IntersectionObserver(t=>{for(const n of t)n.isIntersecting&&(e.unobserve(n.target),b(n.target,"",u("qvisible",n)))});t.forEach(t=>e.observe(t))}},g=(t,e,n,o=!1)=>{t.addEventListener(e,n,{capture:o,passive:!1})},m=t=>t.replace(/-./g,t=>t[1].toUpperCase()),w=(...t)=>{for(const r of t)if("string"==typeof r){if(!n.has(r)){n.add(r);const t=m(r);o.forEach(e=>g(e,t,_,!0)),g(e,t,d,!0)}}else o.has(r)||(n.forEach(t=>{const e=m(t);g(r,e,_,!0)}),o.add(r))};if(!("__q_context__"in t)){t.__q_context__=0;const r=e.qwikevents;r&&(Array.isArray(r)?w(...r):w("click","input")),e.qwikevents={events:n,roots:o,push:w},g(t,"readystatechange",y),y()}"` + `"const t=document,e=window,n=new Set,o=new Set([t]),r={},s="-window",i="-document";let a;const c=(t,e)=>Array.from(t.querySelectorAll(e)),l=t=>{const e=[];return o.forEach(n=>e.push(...c(n,t))),e},f=t=>{A(t),c(t,"[q\\\\:shadowroot]").forEach(t=>{const e=t.shadowRoot;e&&f(e)})},p=t=>t&&"function"==typeof t.then,u=(t,e,n=e.type)=>{l("[on"+t+"\\\\:"+n+"]").forEach(o=>{h(o,t,e,n)})},b=e=>{if(void 0===e._qwikjson_){let n=(e===t.documentElement?t.body:e).lastElementChild;for(;n;){if("SCRIPT"===n.tagName&&"qwik/json"===n.getAttribute("type")){e._qwikjson_=JSON.parse(n.textContent.replace(/\\\\x3C(\\/?script)/gi,"<$1"));break}n=n.previousElementSibling}}},q=(t,e)=>new CustomEvent(t,{detail:e}),h=async(e,n,o,s=o.type)=>{const i="on"+n+":"+s;e.hasAttribute("preventdefault:"+s)&&o.preventDefault(),e.hasAttribute("stoppropagation:"+s)&&o.stopPropagation();const a=e._qc_,c=a&&a.li.filter(t=>t[0]===i);if(c&&c.length>0){for(const t of c){const n=t[1].getFn([e,o],()=>e.isConnected)(o,e),r=o.cancelBubble;p(n)&&await n,r&&o.stopPropagation()}return}const l=e.qDispatchEvent;if(l)return l(o,n);const f=e.getAttribute(i);if(f){const n=e.closest("[q\\\\:container]:not([q\\\\:container=html]):not([q\\\\:container=text])"),s=n.getAttribute("q:base"),i=n.getAttribute("q:version")||"unknown",a=n.getAttribute("q:manifest-hash")||"dev",c=new URL(s,t.baseURI);for(const l of f.split("\\n")){const f=new URL(l,c),u=f.href,q=f.hash.replace(/^#?([^?[|]*).*$/,"$1")||"default",h=performance.now();let d,m,g;const y=l.startsWith("#"),v={qBase:s,qManifest:a,qVersion:i,href:u,symbol:q,element:e,reqTime:h};if(y){const e=n.getAttribute("q:instance");d=(t["qFuncs_"+e]||[])[Number.parseInt(q)],d||(m="sync",g=Error("sym:"+q))}else if(q in r)d=r[q];else{_("qsymbol",v);const t=f.href.split("#")[0];try{const e=import(t);b(n),d=(await e)[q],d?r[q]=d:(m="no-symbol",g=Error(\`\${q} not in \${t}\`))}catch(t){m||(m="async"),g=t}}if(!d){_("qerror",{importError:m,error:g,...v}),console.error(g);break}const w=t.__q_context__;if(e.isConnected)try{t.__q_context__=[e,o,f];const n=d(o,e);p(n)&&await n}catch(t){_("qerror",{error:t,...v})}finally{t.__q_context__=w}}}},_=(e,n)=>{t.dispatchEvent(q(e,n))},d=t=>t.replace(/([A-Z-])/g,t=>"-"+t.toLowerCase()),m=async(t,e)=>{let n=d(t.type),o=t.target;if(e!==i)for(;o&&o.getAttribute;){const e=h(o,"",t,n);let r=t.cancelBubble;p(e)&&await e,r||(r=r||t.cancelBubble||o.hasAttribute("stoppropagation:"+t.type)),o=t.bubbles&&!0!==r?o.parentElement:null}else u(i,t,n)},g=t=>{u(s,t,d(t.type))},y=()=>{const r=t.readyState;if(!a&&("interactive"==r||"complete"==r)&&(o.forEach(f),a=1,_("qinit"),(e.requestIdleCallback??e.setTimeout).bind(e)(()=>_("qidle")),n.has(":qvisible"))){const t=l("[on\\\\:qvisible]"),e=new IntersectionObserver(t=>{for(const n of t)n.isIntersecting&&(e.unobserve(n.target),h(n.target,"",q("qvisible",n)))});t.forEach(t=>e.observe(t))}},v=(t,e,n,o=!1)=>{t.addEventListener(e,n,{capture:o,passive:!1})},w=t=>t.replace(/-./g,t=>t[1].toUpperCase()),E=t=>{const e=t.indexOf(":");let n="",o=t;if(e>=0){const r=t.substring(0,e);""!==r&&r!==s&&r!==i||(n=r,o=t.substring(e+1))}return{scope:n,eventName:w(o)}},A=(...t)=>{for(const r of t)if("string"==typeof r){if(!n.has(r)){n.add(r);const{scope:t,eventName:i}=E(r);t===s?v(e,i,g,!0):o.forEach(e=>v(e,i,e=>m(e,t),!0))}}else o.has(r)||(n.forEach(t=>{const{scope:e,eventName:n}=E(t);v(r,n,t=>m(t,e),!0)}),o.add(r))};if(!("__q_context__"in t)){t.__q_context__=0;const r=e.qwikevents;r&&(Array.isArray(r)?A(...r):A("click","input")),e.qwikevents={events:n,roots:o,push:A},v(t,"readystatechange",y),y()}"` ); });