From 534e621c269800033005c7daee10cdc0ddb18474 Mon Sep 17 00:00:00 2001 From: Varixo Date: Mon, 27 Apr 2026 19:14:36 +0200 Subject: [PATCH] fix(qwikloader): interactivity during streaming --- .changeset/legal-trains-show.md | 5 +++ packages/qwik/src/qwikloader.behavior.unit.ts | 40 +++++++++++++++++++ packages/qwik/src/qwikloader.ts | 34 ++++++++++++++-- packages/qwik/src/qwikloader.unit.ts | 6 +-- 4 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 .changeset/legal-trains-show.md diff --git a/.changeset/legal-trains-show.md b/.changeset/legal-trains-show.md new file mode 100644 index 00000000000..c6bf1c393ff --- /dev/null +++ b/.changeset/legal-trains-show.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: correctly handle interactivty during html streaming diff --git a/packages/qwik/src/qwikloader.behavior.unit.ts b/packages/qwik/src/qwikloader.behavior.unit.ts index 4250c459392..e8438c26ef4 100644 --- a/packages/qwik/src/qwikloader.behavior.unit.ts +++ b/packages/qwik/src/qwikloader.behavior.unit.ts @@ -29,6 +29,13 @@ function createEventTarget() { listeners.set(eventName, registrations); } ), + removeEventListener: vi.fn((eventName: string, handler: (ev: any) => unknown) => { + const registrations = listeners.get(eventName) ?? []; + listeners.set( + eventName, + registrations.filter((registration) => registration.handler !== handler) + ); + }), dispatchEvent: vi.fn(), querySelectorAll: vi.fn(() => [] as any[]), }; @@ -343,4 +350,37 @@ describe('qwikloader behavior', () => { resolveChild(); await result; }); + + test('waits for streamed container data before running qrl attributes', async () => { + const { doc } = createLoaderEnvironment(['e:click']); + const logs: string[] = []; + const container = createMockElement(null, { + 'q:container': 'paused', + 'q:base': './', + 'q:instance': 'sync', + }); + const button = createMockElement(container, { + 'q-e:click': '#0#', + }); + + getSingleListener(doc, 'click').handler(createMockEvent(button)); + + expect(logs).toEqual([]); + expect(doc.dispatchEvent).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'qerror' })); + + doc.qFuncs_sync = [ + () => { + logs.push('clicked'); + }, + ]; + doc.readyState = 'interactive'; + const listeners = getListeners(doc, 'readystatechange'); + for (let i = 0; i < listeners.length; i++) { + listeners[i]!.handler(createMockEvent(doc, 'readystatechange')); + } + await Promise.resolve(); + await Promise.resolve(); + + expect(logs).toEqual(['clicked']); + }); }); diff --git a/packages/qwik/src/qwikloader.ts b/packages/qwik/src/qwikloader.ts index 315526e23fe..9096219d30c 100644 --- a/packages/qwik/src/qwikloader.ts +++ b/packages/qwik/src/qwikloader.ts @@ -41,6 +41,8 @@ const elementPrefix = 'e'; const passiveElementPrefix = 'ep'; const capturePrefix = 'capture:'; +const readyStateChange = 'readystatechange'; + const events = new Set(); const roots = new Set([doc]); const symbols = new Map(); @@ -108,6 +110,17 @@ const resolveContainer = (containerEl: QContainerElement) => { } }; +const waitForContainerReady = (container: QContainerElement) => + container.getAttribute('q:container') === 'paused' && doc.readyState === 'loading' + ? new Promise((resolve) => { + const done = () => { + doc.removeEventListener(readyStateChange, done); + resolve(); + }; + addEventListener(doc, readyStateChange, done); + }) + : undefined; + const createEvent = (eventName: string, detail?: T['detail']) => new CustomEvent(eventName, { detail }) as T; @@ -289,6 +302,7 @@ const dispatch = ( const qBase = container.getAttribute('q:base')!; const base = new URL(qBase, doc.baseURI); const qrls = attrValue.split('|'); + const waitForReady = waitForContainerReady(container); for (let i = 0; i < qrls.length; i++) { const qrl = qrls[i]; const reqTime = performance.now(); @@ -319,11 +333,23 @@ const dispatch = ( } } }; - const handler = resolveHandler(container, element, qBase, base, chunk, symbol, reqTime); - if (defer || isPromise(handler)) { + const resolve = () => resolveHandler(container, element, qBase, base, chunk, symbol, reqTime); + const handler = waitForReady && chunk === '' ? undefined : resolve(); + if (isPromise(handler)) { defer = true; tasks.push(async () => { - await run(isPromise(handler) ? await handler : handler); + if (waitForReady) { + await waitForReady; + } + await run(await handler); + }); + } else if (defer || waitForReady) { + defer = true; + tasks.push(async () => { + if (waitForReady) { + await waitForReady; + } + await run(handler || (await resolve())); }); } else { const result = run(handler); @@ -593,6 +619,6 @@ if (!_qwikEv?.roots) { roots, push: addEventOrRoot, }; - addEventListener(doc, 'readystatechange', processReadyStateChange); + addEventListener(doc, readyStateChange, processReadyStateChange); processReadyStateChange(); } diff --git a/packages/qwik/src/qwikloader.unit.ts b/packages/qwik/src/qwikloader.unit.ts index cedcf1cdc97..b5920980a5d 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(` [ - 1943, - 4913, + 2028, + 5173, ] `); expect(qwikLoader).toMatchInlineSnapshot( - `"const e=document,t=window,r="w",n="wp",o="d",s="dp",i="e",c="ep",l="capture:",a=new Set,p=new Set([e]),q=new Map;let u,f,h;const d=(e,t)=>Array.from(e.querySelectorAll(t)),b=e=>{const t=[];return p.forEach(r=>t.push(...d(r,e))),t},m=(e,t,r,n=!1,o=!1)=>e.addEventListener(t,r,{capture:n,passive:o}),g=e=>{J(e);const t=d(e,"[q\\\\:shadowroot]");for(let e=0;ee&&"function"==typeof e.then,y=async e=>{for(let t=0;t{if(e.length){const t=()=>y(e);h=h?h.then(t,t):t()}},E=t=>{if(void 0===t._qwikjson_){let r=(t===e.documentElement?e.body:t).lastElementChild;for(;r;){if("SCRIPT"===r.tagName&&"qwik/json"===r.getAttribute("type")){t._qwikjson_=JSON.parse(r.textContent.replace(/\\\\x3C(\\/?script)/gi,"<$1"));break}r=r.previousElementSibling}}},A=(e,t)=>new CustomEvent(e,{detail:t}),C=(t,r)=>{e.dispatchEvent(A(t,r))},_=e=>e.replace(/([A-Z-])/g,e=>"-"+e.toLowerCase()),k=e=>e.replace(/-./g,e=>e[1].toUpperCase()),B=e=>{const t=e.indexOf(":");return{scope:e.slice(0,t),eventName:k(e.slice(t+1))}},S=e=>2===e.length,I=e=>e.charAt(0),N=e=>!!e&&1===e.nodeType,T=(e,t,r)=>e.hasAttribute(r)&&(!!e._qDispatch?.[t]||e.hasAttribute("q-"+t)),$=(t,r,n,o,s,i,c)=>{const l={qBase:n,symbol:i,element:r,reqTime:c};if(""===s){const r=t.getAttribute("q:instance"),n=(e["qFuncs_"+r]||[])[Number.parseInt(i)];if(!n){const e=Error("sym:"+i);C("qerror",{importError:"sync",error:e,...l}),console.error(e)}return n}const a=\`\${i}|\${n}|\${s}\`,p=q.get(a);if(p)return p;const u=new URL(s,o).href,f=import(u);return E(t),f.then(e=>{const t=e[i];if(t)q.set(a,t),C("qsymbol",l);else{const e=Error(\`\${i} not in \${u}\`);C("qerror",{importError:"no-symbol",error:e,...l}),console.error(e)}return t},e=>{C("qerror",{importError:"async",error:e,...l}),console.error(e)})},R=(t,r,n,o,s,i=!0)=>{let c=!1;s&&(i&&t.hasAttribute("preventdefault:"+s)&&r.preventDefault(),t.hasAttribute("stoppropagation:"+s)&&r.stopPropagation());const l=t._qDispatch?.[n];if(l){if("function"==typeof l){const e=()=>l(r,t);if(c)o.push(async()=>{const t=e();v(t)&&await t});else{const t=e();v(t)&&(c=!0,o.push(()=>t))}}else if(l.length)for(let e=0;en(r,t);if(c)o.push(async()=>{const t=e();v(t)&&await t});else{const t=e();v(t)&&(c=!0,o.push(()=>t))}}}return}const a=t.getAttribute("q-"+n);if(a){const n=t.closest("[q\\\\:container]:not([q\\\\:container=html]):not([q\\\\:container=text])"),s=n.getAttribute("q:base"),i=new URL(s,e.baseURI),l=a.split("|");for(let e=0;e{if(e&&t.isConnected)try{const n=e.call(f,r,t);if(v(n))return n.catch(e=>{C("qerror",{error:e,qBase:s,symbol:u,element:t,reqTime:p})})}catch(e){C("qerror",{error:e,qBase:s,symbol:u,element:t,reqTime:p})}},d=$(n,t,s,i,q,u,p);if(c||v(d))c=!0,o.push(async()=>{await h(v(d)?await d:d)});else{const e=h(d);v(e)&&(c=!0,o.push(()=>e))}}}},x=(e,t=i,r=!0)=>{const n=_(e.type),o=t+":"+n,s=l+n,c=[],a=[],p=[];let q=e.target;for(;q;)N(q)?(c.push(q),a.push(T(q,o,s)),q=q.parentElement):q=q.parentElement;for(let t=c.length-1;t>=0;t--)if(a[t]&&(R(c[t],e,o,p,n,r),e.cancelBubble||e.cancelBubble))return void w(p);for(let t=0;tx(e,c,!1),U=(e,t,r=!0)=>{const n=_(t.type),o=e+":"+n,s=b("[q-"+e+"\\\\:"+n+"]"),i=[];for(let e=0;e{U(o,e)},D=e=>{U(s,e,!1)},O=e=>{U(r,e)},P=e=>{U(n,e,!1)},F=()=>{const r=e.readyState;if("interactive"==r||"complete"==r){if(f=1,p.forEach(g),a.has("d:qinit")){a.delete("d:qinit");const e=A("qinit"),t=b("[q-d\\\\:qinit]"),r=[];for(let n=0;n{const e=A("qidle"),t=b("[q-d\\\\:qidle]"),r=[];for(let n=0;n{const t=[];for(let r=0;r{for(let n=0;nm(e,n,c===o?i?D:j:i?L:x,!0,i)),1!==f||"e:qvisible"!==s&&"d:qinit"!==s&&"d:qidle"!==s||F()}}else p.has(s)||(a.forEach(e=>{const{scope:t,eventName:n}=B(e),i=S(t),c=I(t);c!==r&&m(s,n,c===o?i?D:j:i?L:x,!0,i)}),p.add(s))}},M=t._qwikEv;M?.roots||(Array.isArray(M)?J(...M):J("e:click","e:input"),t._qwikEv={events:a,roots:p,push:J},m(e,"readystatechange",F),F());"` + `"const e=document,t=window,r="w",n="wp",o="d",s="dp",i="e",a="ep",c="capture:",l="readystatechange",p=new Set,q=new Set([e]),u=new Map;let d,f,h;const b=(e,t)=>Array.from(e.querySelectorAll(t)),m=e=>{const t=[];return q.forEach(r=>t.push(...b(r,e))),t},g=(e,t,r,n=!1,o=!1)=>e.addEventListener(t,r,{capture:n,passive:o}),v=e=>{Z(e);const t=b(e,"[q\\\\:shadowroot]");for(let e=0;ee&&"function"==typeof e.then,y=async e=>{for(let t=0;t{if(e.length){const t=()=>y(e);h=h?h.then(t,t):t()}},A=t=>{if(void 0===t._qwikjson_){let r=(t===e.documentElement?e.body:t).lastElementChild;for(;r;){if("SCRIPT"===r.tagName&&"qwik/json"===r.getAttribute("type")){t._qwikjson_=JSON.parse(r.textContent.replace(/\\\\x3C(\\/?script)/gi,"<$1"));break}r=r.previousElementSibling}}},C=t=>"paused"===t.getAttribute("q:container")&&"loading"===e.readyState?new Promise(t=>{const r=()=>{e.removeEventListener(l,r),t()};g(e,l,r)}):void 0,_=(e,t)=>new CustomEvent(e,{detail:t}),k=(t,r)=>{e.dispatchEvent(_(t,r))},S=e=>e.replace(/([A-Z-])/g,e=>"-"+e.toLowerCase()),B=e=>e.replace(/-./g,e=>e[1].toUpperCase()),I=e=>{const t=e.indexOf(":");return{scope:e.slice(0,t),eventName:B(e.slice(t+1))}},N=e=>2===e.length,T=e=>e.charAt(0),$=e=>!!e&&1===e.nodeType,L=(e,t,r)=>e.hasAttribute(r)&&(!!e._qDispatch?.[t]||e.hasAttribute("q-"+t)),R=(t,r,n,o,s,i,a)=>{const c={qBase:n,symbol:i,element:r,reqTime:a};if(""===s){const r=t.getAttribute("q:instance"),n=(e["qFuncs_"+r]||[])[Number.parseInt(i)];if(!n){const e=Error("sym:"+i);k("qerror",{importError:"sync",error:e,...c}),console.error(e)}return n}const l=\`\${i}|\${n}|\${s}\`,p=u.get(l);if(p)return p;const q=new URL(s,o).href,d=import(q);return A(t),d.then(e=>{const t=e[i];if(t)u.set(l,t),k("qsymbol",c);else{const e=Error(\`\${i} not in \${q}\`);k("qerror",{importError:"no-symbol",error:e,...c}),console.error(e)}return t},e=>{k("qerror",{importError:"async",error:e,...c}),console.error(e)})},x=(t,r,n,o,s,i=!0)=>{let a=!1;s&&(i&&t.hasAttribute("preventdefault:"+s)&&r.preventDefault(),t.hasAttribute("stoppropagation:"+s)&&r.stopPropagation());const c=t._qDispatch?.[n];if(c){if("function"==typeof c){const e=()=>c(r,t);if(a)o.push(async()=>{const t=e();w(t)&&await t});else{const t=e();w(t)&&(a=!0,o.push(()=>t))}}else if(c.length)for(let e=0;en(r,t);if(a)o.push(async()=>{const t=e();w(t)&&await t});else{const t=e();w(t)&&(a=!0,o.push(()=>t))}}}return}const l=t.getAttribute("q-"+n);if(l){const n=t.closest("[q\\\\:container]:not([q\\\\:container=html]):not([q\\\\:container=text])"),s=n.getAttribute("q:base"),i=new URL(s,e.baseURI),c=l.split("|"),p=C(n);for(let e=0;e{if(e&&t.isConnected)try{const n=e.call(f,r,t);if(w(n))return n.catch(e=>{k("qerror",{error:e,qBase:s,symbol:d,element:t,reqTime:q})})}catch(e){k("qerror",{error:e,qBase:s,symbol:d,element:t,reqTime:q})}},b=()=>R(n,t,s,i,u,d,q),m=p&&""===u?void 0:b();if(w(m))a=!0,o.push(async()=>{p&&await p,await h(await m)});else if(a||p)a=!0,o.push(async()=>{p&&await p,await h(m||await b())});else{const e=h(m);w(e)&&(a=!0,o.push(()=>e))}}}},U=(e,t=i,r=!0)=>{const n=S(e.type),o=t+":"+n,s=c+n,a=[],l=[],p=[];let q=e.target;for(;q;)$(q)?(a.push(q),l.push(L(q,o,s)),q=q.parentElement):q=q.parentElement;for(let t=a.length-1;t>=0;t--)if(l[t]&&(x(a[t],e,o,p,n,r),e.cancelBubble||e.cancelBubble))return void E(p);for(let t=0;tU(e,a,!1),D=(e,t,r=!0)=>{const n=S(t.type),o=e+":"+n,s=m("[q-"+e+"\\\\:"+n+"]"),i=[];for(let e=0;e{D(o,e)},P=e=>{D(s,e,!1)},F=e=>{D(r,e)},J=e=>{D(n,e,!1)},M=()=>{const r=e.readyState;if("interactive"==r||"complete"==r){if(f=1,q.forEach(v),p.has("d:qinit")){p.delete("d:qinit");const e=_("qinit"),t=m("[q-d\\\\:qinit]"),r=[];for(let n=0;n{const e=_("qidle"),t=m("[q-d\\\\:qidle]"),r=[];for(let n=0;n{const t=[];for(let r=0;r{for(let n=0;ng(e,n,a===o?i?P:O:i?j:U,!0,i)),1!==f||"e:qvisible"!==s&&"d:qinit"!==s&&"d:qidle"!==s||M()}}else q.has(s)||(p.forEach(e=>{const{scope:t,eventName:n}=I(e),i=N(t),a=T(t);a!==r&&g(s,n,a===o?i?P:O:i?j:U,!0,i)}),q.add(s))}},z=t._qwikEv;z?.roots||(Array.isArray(z)?Z(...z):Z("e:click","e:input"),t._qwikEv={events:p,roots:q,push:Z},g(e,l,M),M());"` ); });