From 294eaea87bc03a7fa4d51ef8f325dd42a2668198 Mon Sep 17 00:00:00 2001 From: Zachary Miller Date: Sat, 7 Nov 2026 16:52:27 -0500 Subject: [PATCH] Run event handlers the way the DOM runs listeners Before, an exception in a handler stopped the rest of the event: when it happened in a `done` or `progress` handler, the bar never hid and pace never restarted again. A handler that removed itself or an earlier handler with Pace.off made the next handler get skipped, Pace.off(event) inside a handler threw, and handlers added while an event fired ran right away. Now, as in DOM event dispatch, an event runs the handlers registered when it fires, minus any removed meanwhile. A `once` handler is removed before it runs, and an exception is reported as an uncaught error without stopping pace or the other handlers. It is reported with reportError where the browser has it, so window.onerror keeps the details even when pace.js comes from a CDN; Safari before 15.4 throws it from a microtask instead. Handlers are stored in a map without a prototype, so event names such as "constructor" work too. --- README.md | 5 +- pace.js | 74 ++++++++++++++------- pace.min.js | 2 +- tests/handlers.spec.mjs | 140 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 27 deletions(-) create mode 100644 tests/handlers.spec.mjs 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']); +});