diff --git a/README.md b/README.md index 640739f..4a9e431 100644 --- a/README.md +++ b/README.md @@ -212,9 +212,12 @@ Pace fires the following events: You can bind onto events using the `on`, `off` and `once` methods: - `Pace.on(event, handler, [context])`: Call `handler` (optionally with context) when `event` is triggered -- `Pace.off(event, [handler])`: Unbind the provided `event` and `handler` combination. +- `Pace.off(event, [handler])`: Unbind `handler` from `event`, or every handler of `event` if `handler` is left out. - `Pace.once(event, handler, [context])`: Bind `handler` to the next (and only the next) incidence of `event` +If a handler throws, the error is reported like an uncaught error (in the console and to `window.onerror`), and Pace +and the other handlers carry on. + Tracking -------- diff --git a/pace.js b/pace.js index c5e4770..cf6c0d3 100644 --- a/pace.js +++ b/pace.js @@ -106,10 +106,44 @@ window.Pace = Pace; + // Removes the handlers of `event` that match `test`, and marks them so an + // event that is firing right now skips them too. + const removeBindings = (target, event, test) => { + const bindings = target.bindings?.[event]; + if (!bindings) { + return; + } + target.bindings[event] = bindings.filter((binding) => { + if (!test(binding)) { + return true; + } + binding.removed = true; + return false; + }); + }; + + // Reports an error like an uncaught one, without throwing. reportError keeps + // the details for window.onerror even when pace.js is served from another + // origin; Safari before 15.4 lacks it, so there the error is thrown from a + // microtask instead. + const reportHandlerError = (error) => { + if (typeof reportError === 'function') { + reportError(error); + } else { + queueMicrotask(() => { + throw error; + }); + } + }; + + // Handlers run the way the DOM runs event listeners + // (https://dom.spec.whatwg.org/#concept-event-listener-inner-invoke): the ones + // registered when the event fires, minus any removed meanwhile; a `once` + // handler is removed before it runs; an exception is reported, not thrown. Object.assign(Pace, { on(event, handler, ctx, once) { once ??= false; - this.bindings ??= {}; + this.bindings ??= Object.create(null); this.bindings[event] ??= []; this.bindings[event].push({ handler, ctx, once }); }, @@ -119,35 +153,25 @@ }, off(event, handler) { - if (this.bindings?.[event] == null) { - return; - } - if (handler == null) { - delete this.bindings[event]; - return; - } - let i = 0; - while (i < this.bindings[event].length) { - if (this.bindings[event][i].handler === handler) { - this.bindings[event].splice(i, 1); - } else { - i++; - } - } + removeBindings(this, event, (binding) => handler == null || binding.handler === handler); }, trigger(event, ...args) { - if (!this.bindings?.[event]) { + const bindings = this.bindings?.[event]; + if (!bindings) { return; } - let i = 0; - while (i < this.bindings[event].length) { - const { handler, ctx, once } = this.bindings[event][i]; - handler.apply(ctx ?? this, args); - if (once) { - this.bindings[event].splice(i, 1); - } else { - i++; + for (const binding of bindings.slice()) { + if (binding.removed) { + continue; + } + if (binding.once) { + removeBindings(this, event, (other) => other === binding); + } + try { + binding.handler.apply(binding.ctx ?? this, args); + } catch (error) { + reportHandlerError(error); } } }, diff --git a/pace.min.js b/pace.min.js index 7fed67c..b8b27d7 100644 --- a/pace.min.js +++ b/pace.min.js @@ -2,4 +2,4 @@ * pace.js v1.3.1 * https://github.com/CodeByZach/pace/ * Licensed MIT © HubSpot, Inc. - */(function(){const E={className:"",catchupTime:100,initialRate:.03,minTime:250,ghostTime:100,maxProgressPerFrame:20,easeFactor:1.25,startOnPageLoad:!0,restartOnPushState:!0,restartOnRequestAfter:500,target:"body",elements:{checkInterval:100,selectors:["body"]},eventLag:{minSamples:10,sampleCount:3,lagThreshold:3},ajax:{trackMethods:["GET"],trackWebSockets:!0,ignoreURLs:[]}},l=()=>performance.now(),I=window.requestAnimationFrame,W=window.cancelAnimationFrame,H=s=>{let e=l();const t=()=>{const r=l()-e;return r>=33?(e=l(),s(r,()=>I(t))):setTimeout(t,33-r)};return t()},L=(s,e,...t)=>typeof s[e]=="function"?s[e].apply(s,t):s[e],y=(s,...e)=>{for(const t of e)if(t)for(const r in t){if(!Object.prototype.hasOwnProperty.call(t,r)||r==="__proto__"||r==="constructor"||r==="prototype")continue;const n=t[r];s[r]!=null&&typeof s[r]=="object"&&n!=null&&typeof n=="object"?y(s[r],n):s[r]=n}return s},X=s=>{let e=0,t=0;for(const r of s)e+=Math.abs(r),t++;return e/t},$=(s="options",e=!0)=>{const t=document.querySelector(`script[data-pace-${s}]`);if(!t)return;const r=t.getAttribute(`data-pace-${s}`);if(!e)return r;try{return JSON.parse(r)}catch(n){console.error("Error parsing inline pace options",n)}},i=window.Pace||{};window.Pace=i,Object.assign(i,{on(s,e,t,r){r??=!1,this.bindings??={},this.bindings[s]??=[],this.bindings[s].push({handler:e,ctx:t,once:r})},once(s,e,t){this.on(s,e,t,!0)},off(s,e){if(this.bindings?.[s]==null)return;if(e==null){delete this.bindings[s];return}let t=0;for(;t=100?t="99":(t=this.progress<10?"0":"",t+=this.progress|0),e.children[0].setAttribute("data-progress",t)}i.trigger("change",this.progress),this.lastRenderedProgress=this.progress}done(){return this.progress>=100}}class D{constructor(){this.bindings={}}trigger(e,t){if(this.bindings[e]!=null)for(const r of this.bindings[e].slice())r.call(this,t)}on(e,t){this.bindings[e]??=[],this.bindings[e].push(t)}}const q=window.XMLHttpRequest,b=window.WebSocket,R=(s,e)=>{for(const t in e.prototype)try{s[t]==null&&typeof e[t]!="function"&&Object.defineProperty(s,t,{get:()=>e.prototype[t],configurable:!0,enumerable:!0})}catch{}},p=[];i.ignore=(s,...e)=>{p.unshift("ignore");const t=s.apply(null,e);return p.shift(),t},i.track=(s,...e)=>{p.unshift("track");const t=s.apply(null,e);return p.shift(),t};const k=s=>{if(s??="GET",p[0]==="track")return"force";if(!p.length&&o.ajax){if(s==="socket"&&o.ajax.trackWebSockets)return!0;if(o.ajax.trackMethods.includes(s.toUpperCase()))return!0}return!1},G=s=>{const e=t=>{const r=t.open;t.open=(...n)=>{const[a,c]=n;return k(a)&&s.trigger("request",{type:a,url:c,request:t}),r.apply(t,n)}};window.XMLHttpRequest=function(t){const r=new q(t);return e(r),r};try{R(window.XMLHttpRequest,q)}catch{}if(o.ajax.trackWebSockets){window.WebSocket=function(t,r){let n;return r!=null?n=new b(t,r):n=new b(t),k("socket")&&s.trigger("request",{type:"socket",url:t,protocols:r,request:n}),n};try{R(window.WebSocket,b)}catch{}}};class J extends D{constructor(){super(),G(this)}}let P=null;const M=()=>(P??=new J,P),v=s=>{const e=o.ajax.ignoreURLs;for(let t=0,r=e.length;t{const{type:e,request:t,url:r}=s;if(!v(r)&&!i.running&&(o.restartOnRequestAfter!==!1||k(e)==="force")){let n=o.restartOnRequestAfter||0;typeof n=="boolean"&&(n=0),setTimeout(()=>{let a;if(e==="socket"?a=t.readyState<1:a=0this.watch(e))}watch({type:e,request:t,url:r}){if(v(r))return;let n;e==="socket"?n=new Y(t,this.complete):n=new K(t,this.complete),this.elements.push(n)}complete(e){this.elements=this.elements.filter(t=>t!==e)}}class K{constructor(e,t){this.progress=0,e.addEventListener("progress",r=>{r.lengthComputable?this.progress=100*r.loaded/r.total:this.progress=this.progress+(100-this.progress)/2},!1);for(const r of["load","abort","timeout","error"])e.addEventListener(r,()=>{t(this),this.progress=100},!1)}}class Y{constructor(e,t){this.progress=0;for(const r of["error","open"])e.addEventListener(r,()=>{t(this),this.progress=100},!1)}}class z{constructor(e){e??={},this.complete=this.complete.bind(this),this.elements=[],e.selectors??=[];const t=e.selectors;for(let r=0,n=t.length;rt!==e)}}class Q{constructor(e,t){this.selector=e,this.completeCallback=t,this.progress=0,this.check()}check(){document.querySelector(this.selector)?this.done():setTimeout(()=>this.check(),o.elements.checkInterval)}done(){this.completeCallback(this),this.completeCallback=null,this.progress=100}}class O{constructor(){this.progress=this.states[document.readyState]??100;const e=document.onreadystatechange;document.onreadystatechange=(...t)=>(this.states[document.readyState]!=null&&(this.progress=this.states[document.readyState]),typeof e=="function"?e.apply(null,t):void 0)}}O.prototype.states={loading:0,interactive:50,complete:100};class V{constructor(e){this.progress=0;let t=0;const r=[];let n=0,a=l();const c=setInterval(()=>{const u=l()-a-50;a=l(),r.push(u),r.length>e.sampleCount&&r.shift(),t=X(r),++n>=e.minSamples&&t=100&&(this.done=!0),t===this.last?this.sinceLastUpdate+=e:(this.sinceLastUpdate&&(this.rate=(t-this.last)/this.sinceLastUpdate),this.catchup=(t-this.progress)/o.catchupTime,this.sinceLastUpdate=0,this.last=t),t>this.progress&&(this.progress+=this.catchup*e);const r=1-Math.pow(this.progress/100,o.easeFactor);return this.progress+=r*this.rate*e,this.progress=Math.min(this.lastProgress+o.maxProgressPerFrame,this.progress),this.progress=Math.max(0,this.progress),this.progress=Math.min(100,this.progress),this.lastProgress=this.progress,this.progress}}let d=null,A=null,h=null,C=null,g=null,S=null;i.running=!1;const j=()=>{o.restartOnPushState&&i.restart()},Z=window.history.pushState;window.history.pushState=(...s)=>(j(),Z.apply(window.history,s));const ee=window.history.replaceState;window.history.replaceState=(...s)=>(j(),ee.apply(window.history,s));const te={ajax:N,elements:z,document:O,eventLag:V},_=()=>{i.sources=d=[];for(const e of["ajax","elements","document","eventLag"])o[e]!==!1&&d.push(new te[e](o[e]));const s=o.extraSources??[];for(let e=0,t=s.length;e{i.trigger("stop"),i.running=!1,h.destroy(),S=!0,g!=null&&(W(g),g=null),_()},i.restart=()=>{i.trigger("restart"),i.stop(),i.start()},i.go=()=>{i.running=!0,h.render();const s=l();S=!1,g=H((e,t)=>{let r=0,n=0,a=!0;for(let u=0,f=d.length;u{h.finish(),i.running=!1,i.trigger("hide")},Math.max(o.ghostTime,Math.max(o.minTime-(l()-s),0)))):t()})},i.start=s=>{y(o,s),i.running=!0;try{h.render()}catch(e){w=e}document.querySelector(".pace")?(i.trigger("start"),i.go()):setTimeout(i.start,50)},typeof define=="function"&&define.amd?define(()=>i):typeof exports=="object"?module.exports=i:o.startOnPageLoad&&i.start()})(); + */(function(){const x={className:"",catchupTime:100,initialRate:.03,minTime:250,ghostTime:100,maxProgressPerFrame:20,easeFactor:1.25,startOnPageLoad:!0,restartOnPushState:!0,restartOnRequestAfter:500,target:"body",elements:{checkInterval:100,selectors:["body"]},eventLag:{minSamples:10,sampleCount:3,lagThreshold:3},ajax:{trackMethods:["GET"],trackWebSockets:!0,ignoreURLs:[]}},l=()=>performance.now(),H=window.requestAnimationFrame,W=window.cancelAnimationFrame,X=s=>{let e=l();const t=()=>{const r=l()-e;return r>=33?(e=l(),s(r,()=>H(t))):setTimeout(t,33-r)};return t()},v=(s,e,...t)=>typeof s[e]=="function"?s[e].apply(s,t):s[e],y=(s,...e)=>{for(const t of e)if(t)for(const r in t){if(!Object.prototype.hasOwnProperty.call(t,r)||r==="__proto__"||r==="constructor"||r==="prototype")continue;const n=t[r];s[r]!=null&&typeof s[r]=="object"&&n!=null&&typeof n=="object"?y(s[r],n):s[r]=n}return s},$=s=>{let e=0,t=0;for(const r of s)e+=Math.abs(r),t++;return e/t},B=(s="options",e=!0)=>{const t=document.querySelector(`script[data-pace-${s}]`);if(!t)return;const r=t.getAttribute(`data-pace-${s}`);if(!e)return r;try{return JSON.parse(r)}catch(n){console.error("Error parsing inline pace options",n)}},o=window.Pace||{};window.Pace=o;const L=(s,e,t)=>{const r=s.bindings?.[e];r&&(s.bindings[e]=r.filter(n=>t(n)?(n.removed=!0,!1):!0))},D=s=>{typeof reportError=="function"?reportError(s):queueMicrotask(()=>{throw s})};Object.assign(o,{on(s,e,t,r){r??=!1,this.bindings??=Object.create(null),this.bindings[s]??=[],this.bindings[s].push({handler:e,ctx:t,once:r})},once(s,e,t){this.on(s,e,t,!0)},off(s,e){L(this,s,t=>e==null||t.handler===e)},trigger(s,...e){const t=this.bindings?.[s];if(t){for(const r of t.slice())if(!r.removed){r.once&&L(this,s,n=>n===r);try{r.handler.apply(r.ctx??this,e)}catch(n){D(n)}}}}});const i=o.options=y({},x,window.paceOptions,B());for(const s of["ajax","document","eventLag","elements"])i[s]===!0&&(i[s]=x[s]);class w extends Error{}class G{constructor(){this.progress=0}getElement(){if(this.el==null){const e=document.querySelector(i.target);if(!e)throw new w;this.el=document.createElement("div"),this.el.className="pace pace-active",document.body.className=document.body.className.replace(/(pace-done )|/,"pace-running ");const t=document.createElement("div");t.className="pace-progress"+(i.className!==""?" "+i.className:"");const r=document.createElement("div");r.className="pace-progress-inner",t.appendChild(r);const n=document.createElement("div");n.className="pace-activity",this.el.appendChild(t),this.el.appendChild(n),e.firstChild!=null?e.insertBefore(this.el,e.firstChild):e.appendChild(this.el)}return this.el}finish(){const e=this.getElement();e.className=e.className.replace("pace-active","pace-inactive"),document.body.className=document.body.className.replace("pace-running ","pace-done ")}update(e){this.progress=e,o.trigger("progress",e),this.render()}destroy(){try{this.getElement().parentNode.removeChild(this.getElement())}catch(e){w=e}this.el=void 0}render(){if(document.querySelector(i.target)==null)return;const e=this.getElement();if(e.children[0].style.transform=`translate3d(${this.progress}%, 0, 0)`,!this.lastRenderedProgress||this.lastRenderedProgress|this.progress!==0|0){e.children[0].setAttribute("data-progress-text",`${this.progress|0}%`);let t;this.progress>=100?t="99":(t=this.progress<10?"0":"",t+=this.progress|0),e.children[0].setAttribute("data-progress",t)}o.trigger("change",this.progress),this.lastRenderedProgress=this.progress}done(){return this.progress>=100}}class J{constructor(){this.bindings={}}trigger(e,t){if(this.bindings[e]!=null)for(const r of this.bindings[e].slice())r.call(this,t)}on(e,t){this.bindings[e]??=[],this.bindings[e].push(t)}}const q=window.XMLHttpRequest,b=window.WebSocket,R=(s,e)=>{for(const t in e.prototype)try{s[t]==null&&typeof e[t]!="function"&&Object.defineProperty(s,t,{get:()=>e.prototype[t],configurable:!0,enumerable:!0})}catch{}},p=[];o.ignore=(s,...e)=>{p.unshift("ignore");const t=s.apply(null,e);return p.shift(),t},o.track=(s,...e)=>{p.unshift("track");const t=s.apply(null,e);return p.shift(),t};const k=s=>{if(s??="GET",p[0]==="track")return"force";if(!p.length&&i.ajax){if(s==="socket"&&i.ajax.trackWebSockets)return!0;if(i.ajax.trackMethods.includes(s.toUpperCase()))return!0}return!1},K=s=>{const e=t=>{const r=t.open;t.open=(...n)=>{const[c,a]=n;return k(c)&&s.trigger("request",{type:c,url:a,request:t}),r.apply(t,n)}};window.XMLHttpRequest=function(t){const r=new q(t);return e(r),r};try{R(window.XMLHttpRequest,q)}catch{}if(i.ajax.trackWebSockets){window.WebSocket=function(t,r){let n;return r!=null?n=new b(t,r):n=new b(t),k("socket")&&s.trigger("request",{type:"socket",url:t,protocols:r,request:n}),n};try{R(window.WebSocket,b)}catch{}}};class Y extends J{constructor(){super(),K(this)}}let P=null;const M=()=>(P??=new Y,P),O=s=>{const e=i.ajax.ignoreURLs;for(let t=0,r=e.length;t{const{type:e,request:t,url:r}=s;if(!O(r)&&!o.running&&(i.restartOnRequestAfter!==!1||k(e)==="force")){let n=i.restartOnRequestAfter||0;typeof n=="boolean"&&(n=0),setTimeout(()=>{let c;if(e==="socket"?c=t.readyState<1:c=0this.watch(e))}watch({type:e,request:t,url:r}){if(O(r))return;let n;e==="socket"?n=new Q(t,this.complete):n=new z(t,this.complete),this.elements.push(n)}complete(e){this.elements=this.elements.filter(t=>t!==e)}}class z{constructor(e,t){this.progress=0,e.addEventListener("progress",r=>{r.lengthComputable?this.progress=100*r.loaded/r.total:this.progress=this.progress+(100-this.progress)/2},!1);for(const r of["load","abort","timeout","error"])e.addEventListener(r,()=>{t(this),this.progress=100},!1)}}class Q{constructor(e,t){this.progress=0;for(const r of["error","open"])e.addEventListener(r,()=>{t(this),this.progress=100},!1)}}class V{constructor(e){e??={},this.complete=this.complete.bind(this),this.elements=[],e.selectors??=[];const t=e.selectors;for(let r=0,n=t.length;rt!==e)}}class Z{constructor(e,t){this.selector=e,this.completeCallback=t,this.progress=0,this.check()}check(){document.querySelector(this.selector)?this.done():setTimeout(()=>this.check(),i.elements.checkInterval)}done(){this.completeCallback(this),this.completeCallback=null,this.progress=100}}class T{constructor(){this.progress=this.states[document.readyState]??100;const e=document.onreadystatechange;document.onreadystatechange=(...t)=>(this.states[document.readyState]!=null&&(this.progress=this.states[document.readyState]),typeof e=="function"?e.apply(null,t):void 0)}}T.prototype.states={loading:0,interactive:50,complete:100};class ee{constructor(e){this.progress=0;let t=0;const r=[];let n=0,c=l();const a=setInterval(()=>{const h=l()-c-50;c=l(),r.push(h),r.length>e.sampleCount&&r.shift(),t=$(r),++n>=e.minSamples&&t=100&&(this.done=!0),t===this.last?this.sinceLastUpdate+=e:(this.sinceLastUpdate&&(this.rate=(t-this.last)/this.sinceLastUpdate),this.catchup=(t-this.progress)/i.catchupTime,this.sinceLastUpdate=0,this.last=t),t>this.progress&&(this.progress+=this.catchup*e);const r=1-Math.pow(this.progress/100,i.easeFactor);return this.progress+=r*this.rate*e,this.progress=Math.min(this.lastProgress+i.maxProgressPerFrame,this.progress),this.progress=Math.max(0,this.progress),this.progress=Math.min(100,this.progress),this.lastProgress=this.progress,this.progress}}let d=null,A=null,u=null,C=null,g=null,S=null;o.running=!1;const _=()=>{i.restartOnPushState&&o.restart()},te=window.history.pushState;window.history.pushState=(...s)=>(_(),te.apply(window.history,s));const se=window.history.replaceState;window.history.replaceState=(...s)=>(_(),se.apply(window.history,s));const re={ajax:N,elements:V,document:T,eventLag:ee},U=()=>{o.sources=d=[];for(const e of["ajax","elements","document","eventLag"])i[e]!==!1&&d.push(new re[e](i[e]));const s=i.extraSources??[];for(let e=0,t=s.length;e{o.trigger("stop"),o.running=!1,u.destroy(),S=!0,g!=null&&(W(g),g=null),U()},o.restart=()=>{o.trigger("restart"),o.stop(),o.start()},o.go=()=>{o.running=!0,u.render();const s=l();S=!1,g=X((e,t)=>{let r=0,n=0,c=!0;for(let h=0,f=d.length;h{u.finish(),o.running=!1,o.trigger("hide")},Math.max(i.ghostTime,Math.max(i.minTime-(l()-s),0)))):t()})},o.start=s=>{y(i,s),o.running=!0;try{u.render()}catch(e){w=e}document.querySelector(".pace")?(o.trigger("start"),o.go()):setTimeout(o.start,50)},typeof define=="function"&&define.amd?define(()=>o):typeof exports=="object"?module.exports=o:i.startOnPageLoad&&o.start()})(); diff --git a/tests/handlers.spec.mjs b/tests/handlers.spec.mjs new file mode 100644 index 0000000..4441eb3 --- /dev/null +++ b/tests/handlers.spec.mjs @@ -0,0 +1,140 @@ +// Handlers added with Pace.on/once, following how the DOM dispatches events +// (https://dom.spec.whatwg.org/#concept-event-listener-inner-invoke): an exception +// in one handler is reported and the rest still run, and the handlers are the ones +// registered when the event fired. +import fs from 'node:fs'; +import { test, expect, idle, HOLD } from './fixtures.mjs'; + +test('a throwing handler is reported, and pace and the other handlers keep going', async ({ page, open }) => { + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await open({ head: HOLD }); + await page.evaluate(() => { + window.calls = []; + Pace.on('done', () => { throw new Error('handler failed'); }); + Pace.on('done', () => calls.push('next handler')); + hold.progress = 100; + }); + await idle(page); + expect(await page.evaluate(() => [calls, events])).toEqual([['next handler'], ['start', 'done', 'hide']]); + expect(errors).toEqual(['handler failed']); +}); + +test('a throwing once handler runs only once', async ({ page, open }) => { + page.on('pageerror', () => {}); + await open({ head: HOLD }); + await page.evaluate(() => { + window.calls = 0; + Pace.once('progress', () => { calls++; throw new Error('handler failed'); }); + hold.progress = 100; + }); + await idle(page); + expect(await page.evaluate(() => calls)).toBe(1); +}); + +test('a handler that removes itself does not skip the next handler', async ({ page, open }) => { + await open({ head: HOLD }); + await page.evaluate(() => { + window.calls = []; + const first = () => { calls.push('first'); Pace.off('done', first); }; + Pace.on('done', first); + Pace.on('done', () => calls.push('second')); + hold.progress = 100; + }); + await idle(page); + expect(await page.evaluate(() => calls)).toEqual(['first', 'second']); +}); + +test('a handler removed while the event fires is not called', async ({ page, open }) => { + await open({ head: HOLD }); + await page.evaluate(() => { + window.calls = []; + const second = () => calls.push('second'); + Pace.on('done', () => { calls.push('first'); Pace.off('done', second); }); + Pace.on('done', second); + hold.progress = 100; + }); + await idle(page); + expect(await page.evaluate(() => calls)).toEqual(['first']); +}); + +test('a handler added while the event fires waits for the next one', async ({ page, open }) => { + await open({ head: HOLD }); + const calls = await page.evaluate(() => { + const calls = []; + let added = false; + Pace.on('custom', () => { + if (!added) { + added = true; + Pace.on('custom', () => calls.push('added')); + } + }); + Pace.trigger('custom'); + calls.push('next event'); + Pace.trigger('custom'); + return calls; + }); + expect(calls).toEqual(['next event', 'added']); +}); + +test('a handler that removes an earlier one does not skip the next handler', async ({ page, open }) => { + await open({ head: HOLD }); + const calls = await page.evaluate(() => { + const calls = []; + const first = () => calls.push('first'); + Pace.on('custom', first); + Pace.on('custom', () => { calls.push('second'); Pace.off('custom', first); }); + Pace.on('custom', () => calls.push('third')); + Pace.trigger('custom'); + return calls; + }); + expect(calls).toEqual(['first', 'second', 'third']); +}); + +test('removing all handlers of an event while it fires stops the rest', async ({ page, open }) => { + await open({ head: HOLD }); + const calls = await page.evaluate(() => { + const calls = []; + Pace.on('custom', () => { calls.push('first'); Pace.off('custom'); }); + Pace.on('custom', () => calls.push('second')); + Pace.trigger('custom'); + Pace.trigger('custom'); + return calls; + }); + expect(calls).toEqual(['first']); +}); + +test('a once handler that fires its own event again runs once', async ({ page, open }) => { + await open({ head: HOLD }); + const calls = await page.evaluate(() => { + let calls = 0; + Pace.once('custom', () => { calls++; Pace.trigger('custom'); }); + Pace.trigger('custom'); + return calls; + }); + expect(calls).toBe(1); +}); + +test('errors keep their details for window.onerror when pace.js comes from another origin', async ({ page, paceFile }) => { + const pace = fs.readFileSync(new URL(paceFile, new URL('..', import.meta.url)), 'utf8'); + await page.route('http://cdn.test/pace.js', (route) => route.fulfill({ contentType: 'text/javascript', body: pace })); + await page.route('http://pace.test/', (route) => + route.fulfill({ + contentType: 'text/html', + body: ` + +`, + }), + ); + page.on('pageerror', () => {}); + await page.goto('http://pace.test/'); + await page.evaluate(() => { + Pace.on('custom', () => { throw new Error('handler failed'); }); + Pace.trigger('custom'); + }); + await expect.poll(() => page.evaluate(() => reported)).toEqual(['handler failed']); +});