diff --git a/.gitattributes b/.gitattributes index bb80784..5e7976c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,5 @@ # Built by `npm run build` (scripts/build.mjs): collapsed in pull request diffs # and left out of the language stats. -pace.min.js linguist-generated -pace-theme-default.min.css linguist-generated +/pace.min.js linguist-generated +/pace-theme-default.min.css linguist-generated themes/** linguist-generated diff --git a/README.md b/README.md index 1dde111..0b99835 100644 --- a/README.md +++ b/README.md @@ -187,11 +187,12 @@ API Pace exposes the following methods: - `Pace.start`: Show the progress bar and start updating. Called automatically if you don't use AMD or CommonJS. +While a run is in progress (not yet `done`), it only stores the options for the next run. - `Pace.restart`: Show the progress bar if it's hidden and start reporting the progress from scratch. Called automatically whenever `pushState` or `replaceState` is called by default. -- `Pace.stop`: Hide the progress bar and stop updating it. +- `Pace.stop`: Remove the progress bar and stop updating it, without firing `done` or `hide`. `` gets `pace-done`. - `Pace.track`: Explicitly track one or more requests, see Tracking below diff --git a/pace.js b/pace.js index 44bfac2..8effd30 100644 --- a/pace.js +++ b/pace.js @@ -37,19 +37,6 @@ const requestAnimationFrame = window.requestAnimationFrame; const cancelAnimationFrame = window.cancelAnimationFrame; - const runAnimation = (fn) => { - let last = now(); - const tick = () => { - const diff = now() - last; - if (diff >= 33) { - last = now(); - return fn(diff, () => requestAnimationFrame(tick)); - } - return setTimeout(tick, 33 - diff); - }; - return tick(); - }; - const result = (obj, key, ...args) => { if (typeof obj[key] === 'function') { return obj[key].apply(obj, args); @@ -126,7 +113,7 @@ // 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) => { + const reportException = (error) => { if (typeof reportError === 'function') { reportError(error); } else { @@ -171,7 +158,7 @@ try { binding.handler.apply(binding.ctx ?? this, args); } catch (error) { - reportHandlerError(error); + reportException(error); } } }, @@ -179,28 +166,37 @@ const options = (Pace.options = extend({}, defaultOptions, window.paceOptions, getFromDOM())); - for (const key of ['ajax', 'document', 'eventLag', 'elements']) { - if (options[key] === true) { - options[key] = defaultOptions[key]; + // `true` turns a collector on with its default settings. + const normalizeOptions = () => { + for (const key of ['ajax', 'document', 'eventLag', 'elements']) { + if (options[key] === true) { + options[key] = defaultOptions[key]; + } } - } + }; - class NoTargetError extends Error {} + normalizeOptions(); class Bar { constructor() { this.progress = 0; } + // The bar's element, created in the target the first time. Null while the + // target doesn't exist yet, and after destroy(). getElement() { + if (this.destroyed) { + return null; + } if (this.el == null) { const targetElement = document.querySelector(options.target); if (!targetElement) { - throw new NoTargetError(); + return null; } this.el = document.createElement('div'); this.el.className = 'pace pace-active'; - document.body.className = document.body.className.replace(/(pace-done )|/, 'pace-running '); + document.body.classList.remove('pace-done'); + document.body.classList.add('pace-running'); const progress = document.createElement('div'); progress.className = 'pace-progress' + (options.className !== '' ? ' ' + options.className : ''); const inner = document.createElement('div'); @@ -210,19 +206,15 @@ activity.className = 'pace-activity'; this.el.appendChild(progress); this.el.appendChild(activity); - if (targetElement.firstChild != null) { - targetElement.insertBefore(this.el, targetElement.firstChild); - } else { - targetElement.appendChild(this.el); - } + targetElement.insertBefore(this.el, targetElement.firstChild); } return this.el; } finish() { - const el = this.getElement(); - el.className = el.className.replace('pace-active', 'pace-inactive'); - document.body.className = document.body.className.replace('pace-running ', 'pace-done '); + this.el?.classList.replace('pace-active', 'pace-inactive'); + document.body.classList.remove('pace-running'); + document.body.classList.add('pace-done'); } update(prog) { @@ -232,12 +224,9 @@ } destroy() { - try { - this.getElement().parentNode.removeChild(this.getElement()); - } catch (error) { - NoTargetError = error; - } + this.el?.remove(); this.el = undefined; + this.destroyed = true; } render() { @@ -245,6 +234,9 @@ return; } const el = this.getElement(); + if (!el) { + return; + } el.children[0].style.transform = `translate3d(${this.progress}%, 0, 0)`; if (!this.lastRenderedProgress || this.lastRenderedProgress | 0 !== this.progress | 0) { el.children[0].setAttribute('data-progress-text', `${this.progress | 0}%`); @@ -284,6 +276,10 @@ this.bindings[name] ??= []; this.bindings[name].push(fn); } + + off(name, fn) { + this.bindings[name] = this.bindings[name]?.filter((binding) => binding !== fn); + } } const _XMLHttpRequest = window.XMLHttpRequest; @@ -413,7 +409,7 @@ if (shouldIgnoreURL(url)) { return; } - if (!Pace.running && (options.restartOnRequestAfter !== false || shouldTrack(type) === 'force')) { + if (!isActive() && (options.restartOnRequestAfter !== false || shouldTrack(type) === 'force')) { let after = options.restartOnRequestAfter || 0; if (typeof after === 'boolean') { after = 0; @@ -425,16 +421,14 @@ } else { stillActive = 0 < request.readyState && request.readyState < 4; } - if (stillActive) { + if (!stillActive) { + return; + } + // Join a run that started meanwhile, rather than restarting it. + if (!isActive()) { Pace.restart(); - for (let i = 0, n = Pace.sources.length; i < n; i++) { - const source = Pace.sources[i]; - if (source instanceof AjaxMonitor) { - source.watch(event); - break; - } - } } + run?.collectors.find((collector) => collector instanceof AjaxMonitor)?.watch(event); }, after); } }); @@ -443,13 +437,16 @@ constructor() { this.complete = this.complete.bind(this); this.elements = []; - getIntercept().on('request', (event) => this.watch(event)); + this.watched = new WeakSet(); + this.onRequest = (event) => this.watch(event); + getIntercept().on('request', this.onRequest); } watch({ type, request, url }) { - if (shouldIgnoreURL(url)) { + if (shouldIgnoreURL(url) || this.watched.has(request)) { return; } + this.watched.add(request); let tracker; if (type === 'socket') { tracker = new SocketRequestTracker(request, this.complete); @@ -462,6 +459,10 @@ complete(tracker) { this.elements = this.elements.filter((e) => e !== tracker); } + + destroy() { + getIntercept().off('request', this.onRequest); + } } class XHRRequestTracker { @@ -501,20 +502,30 @@ this.complete = this.complete.bind(this); this.elements = []; settings.selectors ??= []; + this.trackers = []; const selectors = settings.selectors; for (let i = 0, n = selectors.length; i < n; i++) { - this.elements.push(new ElementTracker(selectors[i], this.complete)); + const tracker = new ElementTracker(selectors[i], settings.checkInterval, this.complete); + this.trackers.push(tracker); + this.elements.push(tracker); } } complete(tracker) { this.elements = this.elements.filter((e) => e !== tracker); } + + destroy() { + for (const tracker of this.trackers) { + tracker.destroy(); + } + } } class ElementTracker { - constructor(selector, completeCallback) { + constructor(selector, checkInterval, completeCallback) { this.selector = selector; + this.checkInterval = checkInterval; this.completeCallback = completeCallback; this.progress = 0; this.check(); @@ -524,10 +535,14 @@ if (document.querySelector(this.selector)) { this.done(); } else { - setTimeout(() => this.check(), options.elements.checkInterval); + this.timer = setTimeout(() => this.check(), this.checkInterval); } } + destroy() { + clearTimeout(this.timer); + } + done() { this.completeCallback(this); this.completeCallback = null; @@ -538,13 +553,18 @@ class DocumentMonitor { constructor() { this.progress = this.states[document.readyState] ?? 100; - const _onreadystatechange = document.onreadystatechange; - document.onreadystatechange = (...args) => { + // A listener, not document.onreadystatechange, so a page that sets that + // property doesn't disconnect pace. + this.onChange = () => { if (this.states[document.readyState] != null) { this.progress = this.states[document.readyState]; } - return typeof _onreadystatechange === 'function' ? _onreadystatechange.apply(null, args) : undefined; }; + document.addEventListener('readystatechange', this.onChange); + } + + destroy() { + document.removeEventListener('readystatechange', this.onChange); } } @@ -561,7 +581,7 @@ const samples = []; let points = 0; let last = now(); - const interval = setInterval(() => { + const interval = (this.interval = setInterval(() => { const diff = now() - last - 50; last = now(); samples.push(diff); @@ -575,7 +595,11 @@ } else { this.progress = 100 * (3 / (avg + 3)); } - }, 50); + }, 50)); + } + + destroy() { + clearInterval(this.interval); } } @@ -619,30 +643,42 @@ } } - let sources = null; - let scalers = null; - let bar = null; - let uniScaler = null; - let animation = null; - let cancelAnimation = null; + // A run goes from waiting (for the target element) to running to finishing + // (done, about to hide). `run` holds the current one; null when idle. + let run = null; + let bar = (Pace.bar = new Bar()); + Pace.sources = []; Pace.running = false; + // Waiting or running: a new request joins it instead of restarting the bar. + const isActive = () => run != null && (run.state === 'waiting' || run.state === 'running'); + const handlePushState = () => { if (options.restartOnPushState) { Pace.restart(); } }; + // An error in pace (or in an event handler it calls) never stops the + // page's navigation. const _pushState = window.history.pushState; window.history.pushState = (...args) => { - handlePushState(); + try { + handlePushState(); + } catch (error) { + reportException(error); + } return _pushState.apply(window.history, args); }; const _replaceState = window.history.replaceState; window.history.replaceState = (...args) => { - handlePushState(); + try { + handlePushState(); + } catch (error) { + reportException(error); + } return _replaceState.apply(window.history, args); }; @@ -653,94 +689,169 @@ eventLag: EventLagMonitor, }; - const init = () => { - Pace.sources = sources = []; - for (const type of ['ajax', 'elements', 'document', 'eventLag']) { - if (options[type] !== false) { - sources.push(new SOURCE_KEYS[type](options[type])); - } - } - const extraSources = options.extraSources ?? []; - for (let i = 0, n = extraSources.length; i < n; i++) { - sources.push(new extraSources[i](options)); + // Stops the collectors that pace created (not extraSources) from watching. + const stopCollectors = (current) => { + for (const collector of current.collectors.splice(0)) { + collector.destroy(); } - Pace.bar = bar = new Bar(); - scalers = []; - uniScaler = new Scaler(); }; - init(); - - Pace.stop = () => { - Pace.trigger('stop'); - Pace.running = false; - bar.destroy(); - cancelAnimation = true; - if (animation != null) { - cancelAnimationFrame(animation); - animation = null; + // Cancels everything the current run scheduled, and removes the bar (also a + // finished run's hidden bar). + const endRun = () => { + if (run) { + const current = run; + run = null; + cancelAnimationFrame(current.frame); + clearTimeout(current.waitTimer); + clearTimeout(current.hideTimer); + stopCollectors(current); } - init(); + bar.destroy(); }; - Pace.restart = () => { - Pace.trigger('restart'); - Pace.stop(); - Pace.start(); + const finish = (current) => { + bar.update(100); + if (run !== current) { + return; // a progress handler stopped or restarted pace + } + current.state = 'finishing'; + stopCollectors(current); + Pace.trigger('done'); + if (run !== current) { + return; // a done handler stopped or restarted pace + } + const shownFor = now() - current.startedAt; + current.hideTimer = setTimeout(() => { + run = null; + Pace.running = false; + bar.finish(); + Pace.trigger('hide'); + }, Math.max(options.ghostTime, options.minTime - shownFor, 0)); }; - Pace.go = () => { - Pace.running = true; - bar.render(); - const start = now(); - cancelAnimation = false; - animation = runAnimation((frameTime, enqueueNextFrame) => { + // About 30 frames a second, on requestAnimationFrame. + const animate = (current) => { + let last = now(); + const frame = () => { + if (run !== current) { + return; + } + const frameTime = now() - last; + if (frameTime < 30) { + current.frame = requestAnimationFrame(frame); + return; + } + last = now(); let count = 0; let sum = 0; let done = true; + const sources = current.sources; for (let i = 0, n = sources.length; i < n; i++) { const source = sources[i]; - const scalerList = (scalers[i] ??= []); const elements = source.elements ?? [source]; for (let j = 0, m = elements.length; j < m; j++) { - const scaler = (scalerList[j] ??= new Scaler(elements[j])); - done &= scaler.done; + const element = elements[j]; + // Each tracked item keeps its own Scaler, even when finished items + // leave the list. + let scaler = current.scalers.get(element); + if (!scaler) { + scaler = new Scaler(element); + current.scalers.set(element, scaler); + } if (scaler.done) { continue; } + done = false; count++; sum += scaler.tick(frameTime); } } - const avg = sum / count; - bar.update(uniScaler.tick(frameTime, avg)); - if (bar.done() || done || cancelAnimation) { - bar.update(100); - Pace.trigger('done'); - return setTimeout(() => { - bar.finish(); - Pace.running = false; - Pace.trigger('hide'); - }, Math.max(options.ghostTime, Math.max(options.minTime - (now() - start), 0))); - } - return enqueueNextFrame(); - }); + bar.update(current.uniScaler.tick(frameTime, sum / count)); + if (run !== current) { + return; // a progress handler stopped or restarted pace + } + if (bar.done() || done) { + finish(current); + } else { + current.frame = requestAnimationFrame(frame); + } + }; + current.frame = requestAnimationFrame(frame); }; - Pace.start = (_options) => { - extend(options, _options); - Pace.running = true; + const beginRun = () => { + const current = { + state: 'waiting', + collectors: [], + sources: [], + scalers: new WeakMap(), + uniScaler: new Scaler(), + }; + // Built before the run counts as started: if a collector or an extra + // source throws (an invalid selector, say), pace stays idle. try { - bar.render(); + for (const type of ['ajax', 'elements', 'document', 'eventLag']) { + if (options[type] !== false) { + const collector = new SOURCE_KEYS[type](options[type]); + current.collectors.push(collector); + current.sources.push(collector); + } + } + const extraSources = options.extraSources ?? []; + for (let i = 0, n = extraSources.length; i < n; i++) { + current.sources.push(new extraSources[i](options)); + } } catch (error) { - NoTargetError = error; + stopCollectors(current); + throw error; } - if (!document.querySelector('.pace')) { - setTimeout(Pace.start, 50); - } else { + run = current; + Pace.running = true; + Pace.bar = bar = new Bar(); + Pace.sources = current.sources; + const show = () => { + if (run !== current) { + return; + } + if (!bar.getElement()) { + current.waitTimer = setTimeout(show, 50); + return; + } + current.state = 'running'; + current.startedAt = now(); + bar.render(); + if (run !== current) { + return; // a change handler stopped or restarted pace + } Pace.trigger('start'); - Pace.go(); + animate(current); + }; + show(); + }; + + Pace.start = (startOptions) => { + extend(options, startOptions); + normalizeOptions(); + if (isActive()) { + return; } + endRun(); // a run that is finishing gives way to the new one + beginRun(); + }; + + Pace.stop = () => { + endRun(); + Pace.running = false; + document.body.classList.remove('pace-running'); + document.body.classList.add('pace-done'); + Pace.trigger('stop'); + }; + + Pace.restart = () => { + Pace.trigger('restart'); + Pace.stop(); + Pace.start(); }; if (typeof define === 'function' && define.amd) { diff --git a/pace.min.js b/pace.min.js index b204306..3a6adbf 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 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=>(s??="GET",p[0]==="track"?"force":!!(!p.length&&i.ajax&&(s==="socket"&&i.ajax.trackWebSockets||Array.prototype.includes.call(i.ajax.trackMethods,s.toUpperCase())))),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,j=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()})(); + */(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(),g=window.requestAnimationFrame,X=window.cancelAnimationFrame,T=(e,t,...s)=>typeof e[t]=="function"?e[t].apply(e,s):e[t],m=(e,...t)=>{for(const s of t)if(s)for(const r in s){if(!Object.prototype.hasOwnProperty.call(s,r)||r==="__proto__"||r==="constructor"||r==="prototype")continue;const n=s[r];e[r]!=null&&typeof e[r]=="object"&&n!=null&&typeof n=="object"?m(e[r],n):e[r]=n}return e},$=e=>{let t=0,s=0;for(const r of e)t+=Math.abs(r),s++;return t/s},B=(e="options",t=!0)=>{const s=document.querySelector(`script[data-pace-${e}]`);if(!s)return;const r=s.getAttribute(`data-pace-${e}`);if(!t)return r;try{return JSON.parse(r)}catch(n){console.error("Error parsing inline pace options",n)}},o=window.Pace||{};window.Pace=o;const q=(e,t,s)=>{const r=e.bindings?.[t];r&&(e.bindings[t]=r.filter(n=>s(n)?(n.removed=!0,!1):!0))},y=e=>{typeof reportError=="function"?reportError(e):queueMicrotask(()=>{throw e})};Object.assign(o,{on(e,t,s,r){r??=!1,this.bindings??=Object.create(null),this.bindings[e]??=[],this.bindings[e].push({handler:t,ctx:s,once:r})},once(e,t,s){this.on(e,t,s,!0)},off(e,t){q(this,e,s=>t==null||s.handler===t)},trigger(e,...t){const s=this.bindings?.[e];if(s){for(const r of s.slice())if(!r.removed){r.once&&q(this,e,n=>n===r);try{r.handler.apply(r.ctx??this,t)}catch(n){y(n)}}}}});const i=o.options=m({},E,window.paceOptions,B()),P=()=>{for(const e of["ajax","document","eventLag","elements"])i[e]===!0&&(i[e]=E[e])};P();class M{constructor(){this.progress=0}getElement(){if(this.destroyed)return null;if(this.el==null){const t=document.querySelector(i.target);if(!t)return null;this.el=document.createElement("div"),this.el.className="pace pace-active",document.body.classList.remove("pace-done"),document.body.classList.add("pace-running");const s=document.createElement("div");s.className="pace-progress"+(i.className!==""?" "+i.className:"");const r=document.createElement("div");r.className="pace-progress-inner",s.appendChild(r);const n=document.createElement("div");n.className="pace-activity",this.el.appendChild(s),this.el.appendChild(n),t.insertBefore(this.el,t.firstChild)}return this.el}finish(){this.el?.classList.replace("pace-active","pace-inactive"),document.body.classList.remove("pace-running"),document.body.classList.add("pace-done")}update(t){this.progress=t,o.trigger("progress",t),this.render()}destroy(){this.el?.remove(),this.el=void 0,this.destroyed=!0}render(){if(document.querySelector(i.target)==null)return;const t=this.getElement();if(t){if(t.children[0].style.transform=`translate3d(${this.progress}%, 0, 0)`,!this.lastRenderedProgress||this.lastRenderedProgress|this.progress!==0|0){t.children[0].setAttribute("data-progress-text",`${this.progress|0}%`);let s;this.progress>=100?s="99":(s=this.progress<10?"0":"",s+=this.progress|0),t.children[0].setAttribute("data-progress",s)}o.trigger("change",this.progress),this.lastRenderedProgress=this.progress}}done(){return this.progress>=100}}class D{constructor(){this.bindings={}}trigger(t,s){if(this.bindings[t]!=null)for(const r of this.bindings[t].slice())r.call(this,s)}on(t,s){this.bindings[t]??=[],this.bindings[t].push(s)}off(t,s){this.bindings[t]=this.bindings[t]?.filter(r=>r!==s)}}const O=window.XMLHttpRequest,w=window.WebSocket,A=(e,t)=>{for(const s in t.prototype)try{e[s]==null&&typeof t[s]!="function"&&Object.defineProperty(e,s,{get:()=>t.prototype[s],configurable:!0,enumerable:!0})}catch{}},p=[];o.ignore=(e,...t)=>{p.unshift("ignore");const s=e.apply(null,t);return p.shift(),s},o.track=(e,...t)=>{p.unshift("track");const s=e.apply(null,t);return p.shift(),s};const b=e=>(e??="GET",p[0]==="track"?"force":!!(!p.length&&i.ajax&&(e==="socket"&&i.ajax.trackWebSockets||Array.prototype.includes.call(i.ajax.trackMethods,e.toUpperCase())))),G=e=>{const t=s=>{const r=s.open;s.open=(...n)=>{const[a,u]=n;return b(a)&&e.trigger("request",{type:a,url:u,request:s}),r.apply(s,n)}};window.XMLHttpRequest=function(s){const r=new O(s);return t(r),r};try{A(window.XMLHttpRequest,O)}catch{}if(i.ajax.trackWebSockets){window.WebSocket=function(s,r){let n;return r!=null?n=new w(s,r):n=new w(s),b("socket")&&e.trigger("request",{type:"socket",url:s,protocols:r,request:n}),n};try{A(window.WebSocket,w)}catch{}}};class z extends D{constructor(){super(),G(this)}}let C=null;const k=()=>(C??=new z,C),j=e=>{const t=i.ajax.ignoreURLs;for(let s=0,r=t.length;s{const{type:t,request:s,url:r}=e;if(!j(r)&&!S()&&(i.restartOnRequestAfter!==!1||b(t)==="force")){let n=i.restartOnRequestAfter||0;typeof n=="boolean"&&(n=0),setTimeout(()=>{let a;t==="socket"?a=s.readyState<1:a=0u instanceof I)?.watch(e))},n)}});class I{constructor(){this.complete=this.complete.bind(this),this.elements=[],this.watched=new WeakSet,this.onRequest=t=>this.watch(t),k().on("request",this.onRequest)}watch({type:t,request:s,url:r}){if(j(r)||this.watched.has(s))return;this.watched.add(s);let n;t==="socket"?n=new K(s,this.complete):n=new J(s,this.complete),this.elements.push(n)}complete(t){this.elements=this.elements.filter(s=>s!==t)}destroy(){k().off("request",this.onRequest)}}class J{constructor(t,s){this.progress=0,t.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"])t.addEventListener(r,()=>{s(this),this.progress=100},!1)}}class K{constructor(t,s){this.progress=0;for(const r of["error","open"])t.addEventListener(r,()=>{s(this),this.progress=100},!1)}}class Y{constructor(t){t??={},this.complete=this.complete.bind(this),this.elements=[],t.selectors??=[],this.trackers=[];const s=t.selectors;for(let r=0,n=s.length;rs!==t)}destroy(){for(const t of this.trackers)t.destroy()}}class Q{constructor(t,s,r){this.selector=t,this.checkInterval=s,this.completeCallback=r,this.progress=0,this.check()}check(){document.querySelector(this.selector)?this.done():this.timer=setTimeout(()=>this.check(),this.checkInterval)}destroy(){clearTimeout(this.timer)}done(){this.completeCallback(this),this.completeCallback=null,this.progress=100}}class _{constructor(){this.progress=this.states[document.readyState]??100,this.onChange=()=>{this.states[document.readyState]!=null&&(this.progress=this.states[document.readyState])},document.addEventListener("readystatechange",this.onChange)}destroy(){document.removeEventListener("readystatechange",this.onChange)}}_.prototype.states={loading:0,interactive:50,complete:100};class V{constructor(t){this.progress=0;let s=0;const r=[];let n=0,a=l();const u=this.interval=setInterval(()=>{const f=l()-a-50;a=l(),r.push(f),r.length>t.sampleCount&&r.shift(),s=$(r),++n>=t.minSamples&&s=100&&(this.done=!0),s===this.last?this.sinceLastUpdate+=t:(this.sinceLastUpdate&&(this.rate=(s-this.last)/this.sinceLastUpdate),this.catchup=(s-this.progress)/i.catchupTime,this.sinceLastUpdate=0,this.last=s),s>this.progress&&(this.progress+=this.catchup*t);const r=1-Math.pow(this.progress/100,i.easeFactor);return this.progress+=r*this.rate*t,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 c=null,h=o.bar=new M;o.sources=[],o.running=!1;const S=()=>c!=null&&(c.state==="waiting"||c.state==="running"),U=()=>{i.restartOnPushState&&o.restart()},Z=window.history.pushState;window.history.pushState=(...e)=>{try{U()}catch(t){y(t)}return Z.apply(window.history,e)};const ee=window.history.replaceState;window.history.replaceState=(...e)=>{try{U()}catch(t){y(t)}return ee.apply(window.history,e)};const te={ajax:I,elements:Y,document:_,eventLag:V},v=e=>{for(const t of e.collectors.splice(0))t.destroy()},N=()=>{if(c){const e=c;c=null,X(e.frame),clearTimeout(e.waitTimer),clearTimeout(e.hideTimer),v(e)}h.destroy()},se=e=>{if(h.update(100),c!==e||(e.state="finishing",v(e),o.trigger("done"),c!==e))return;const t=l()-e.startedAt;e.hideTimer=setTimeout(()=>{c=null,o.running=!1,h.finish(),o.trigger("hide")},Math.max(i.ghostTime,i.minTime-t,0))},re=e=>{let t=l();const s=()=>{if(c!==e)return;const r=l()-t;if(r<30){e.frame=g(s);return}t=l();let n=0,a=0,u=!0;const f=e.sources;for(let L=0,oe=f.length;L{const e={state:"waiting",collectors:[],sources:[],scalers:new WeakMap,uniScaler:new F};try{for(const r of["ajax","elements","document","eventLag"])if(i[r]!==!1){const n=new te[r](i[r]);e.collectors.push(n),e.sources.push(n)}const s=i.extraSources??[];for(let r=0,n=s.length;r{if(c===e){if(!h.getElement()){e.waitTimer=setTimeout(t,50);return}e.state="running",e.startedAt=l(),h.render(),c===e&&(o.trigger("start"),re(e))}};t()};o.start=e=>{m(i,e),P(),!S()&&(N(),ne())},o.stop=()=>{N(),o.running=!1,document.body.classList.remove("pace-running"),document.body.classList.add("pace-done"),o.trigger("stop")},o.restart=()=>{o.trigger("restart"),o.stop(),o.start()},typeof define=="function"&&define.amd?define(()=>o):typeof exports=="object"?module.exports=o:i.startOnPageLoad&&o.start()})(); diff --git a/tests/lifecycle.spec.mjs b/tests/lifecycle.spec.mjs new file mode 100644 index 0000000..da17cb7 --- /dev/null +++ b/tests/lifecycle.spec.mjs @@ -0,0 +1,257 @@ +// What starting, stopping, restarting and finishing a run do. Each run fires start, +// done and hide once; stop() and restart() end the current run completely. +import { test, expect, idle, request, clearEvents, RESTARTED, HOLD, NO_EVENT_LAG } from './fixtures.mjs'; + +// Like HOLD, plus other options. +const hold = (extra) => ``; + +const bodyClasses = (page) => page.evaluate(() => [...document.body.classList].filter((name) => name.startsWith('pace-'))); + +test('Pace.stop() mid-run ends the run: no done or hide, no bar, marked done', async ({ page, open }) => { + await open({ head: HOLD }); + await expect(page.locator('.pace.pace-active')).toHaveCount(1); + await page.evaluate(() => { events.length = 0; Pace.stop(); }); + await page.waitForTimeout(600); + expect(await page.evaluate(() => [events, Pace.running])).toEqual([['stop'], false]); + await expect(page.locator('.pace')).toHaveCount(0); + expect(await bodyClasses(page)).toEqual(['pace-done']); +}); + +test('Pace.restart() mid-run runs one animation and fires done and hide once', async ({ page, open }) => { + await open({ head: HOLD }); + await expect(page.locator('.pace.pace-active')).toHaveCount(1); + const progressEvents = await page.evaluate(async () => { + events.length = 0; + let count = 0; + Pace.on('progress', () => count++); + Pace.restart(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + return count; + }); + // One animation renders at most about 30 frames a second; two would render about 60. + expect(progressEvents).toBeLessThan(45); + await page.evaluate(() => { hold.progress = 100; }); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(['restart', 'stop', 'start', 'done', 'hide']); + expect(await bodyClasses(page)).toEqual(['pace-done']); +}); + +test('Pace.start() while running does nothing', async ({ page, open }) => { + await open({ head: HOLD }); + await expect(page.locator('.pace.pace-active')).toHaveCount(1); + await page.evaluate(() => { events.length = 0; Pace.start(); hold.progress = 100; }); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(['done', 'hide']); + await expect(page.locator('.pace')).toHaveCount(1); +}); + +test('pushState while the bar is finishing starts a new run that finishes once', async ({ page, open }) => { + await open({ head: hold('{ ghostTime: 1000 }') }); + await page.evaluate(() => new Promise((resolve) => { + Pace.once('done', () => setTimeout(resolve, 100)); + hold.progress = 100; + })); + await page.evaluate(() => history.pushState({}, '', '?next')); + // Past the moment the first run would have hidden the bar: the new run is still going. + await page.waitForTimeout(1200); + await expect(page.locator('.pace')).toHaveClass('pace pace-active'); + expect(await page.evaluate(() => Pace.running)).toBe(true); + await page.evaluate(() => { hold.progress = 100; }); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(['start', 'done', ...RESTARTED]); + await expect(page.locator('.pace')).toHaveCount(1); +}); + +test('Pace.stop() from a done handler: no hide and no bar', async ({ page, open }) => { + await open({ head: hold('{ ghostTime: 300 }') }); + await page.evaluate(() => { Pace.once('done', () => Pace.stop()); hold.progress = 100; }); + await page.waitForTimeout(800); + expect(await page.evaluate(() => events)).toEqual(['start', 'done', 'stop']); + await expect(page.locator('.pace')).toHaveCount(0); +}); + +test('a request that starts while the bar is finishing restarts it', async ({ page, open }) => { + await open({ head: hold('{ ghostTime: 1000 }') }); + const loaded = page.evaluate(() => new Promise((resolve) => { + Pace.once('done', () => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '/delay?ms=1500'); + xhr.onload = () => resolve(); + xhr.send(); + }); + hold.progress = 100; + })); + await expect.poll(() => page.evaluate(() => events.includes('restart'))).toBe(true); + await page.evaluate(() => { hold.progress = 100; }); + await loaded; + await idle(page); + expect(await page.evaluate(() => events)).toEqual(['start', 'done', ...RESTARTED]); +}); + +test('two slow requests at the same time restart the bar once', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await clearEvents(page); + await Promise.all([request(page, { ms: 1500 }), request(page, { ms: 1500 })]); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); +}); + +test('a slow request joins a run that started while it was open, instead of restarting it', async ({ page, open }) => { + await open({ head: HOLD }); + await page.evaluate(() => { hold.progress = 100; }); + await idle(page); + await clearEvents(page); + // The request, then a navigation that starts a run (held at 0%) before the + // request's 500 ms mark. + const loadedAt = await page.evaluate(() => new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '/delay?ms=1500'); + xhr.onload = () => resolve(performance.now()); + xhr.send(); + history.pushState({}, '', '?next'); + setTimeout(() => { hold.progress = 100; }, 800); + })); + await idle(page); + const [events, doneAt] = await page.evaluate(() => [window.events, eventTimes.done]); + expect(events).toEqual(RESTARTED); + // The run waited for the request it joined. + expect(doneAt).toBeGreaterThanOrEqual(loadedAt - 50); +}); + +test('waits for the last of several requests, whatever order they finish in', async ({ page, open }) => { + await open({ + head: NO_EVENT_LAG, + body: ``, + }); + await idle(page); + const [doneAt, slowAt] = await page.evaluate(() => [eventTimes.done, loadedAt[2000]]); + expect(doneAt).toBeGreaterThanOrEqual(slowAt - 50); +}); + +test('waits for every selector, whatever order they appear in', async ({ page, open }) => { + await open({ + head: "", + body: ``, + }); + await idle(page); + const [doneAt, secondAt] = await page.evaluate(() => [eventTimes.done, addedAt.second]); + expect(doneAt).toBeGreaterThanOrEqual(secondAt); +}); + +test('restarts do not pile up work from earlier runs', async ({ page, open }) => { + await open({ + head: ``, + }); + const lookupsPerSecond = () => page.evaluate(async () => { + const before = lookups; + await new Promise((resolve) => setTimeout(resolve, 1000)); + return lookups - before; + }); + // One tracker checks about 10 times a second (checkInterval 100 ms). + expect(await lookupsPerSecond()).toBeLessThan(20); + await page.evaluate(() => { for (let i = 0; i < 20; i++) history.pushState({}, '', '?' + i); }); + expect(await lookupsPerSecond()).toBeLessThan(20); +}); + +test('Pace.stop() cancels waiting for a missing target', async ({ page, open }) => { + await open({ head: "" }); + await page.evaluate(() => Pace.stop()); + await page.waitForTimeout(300); + expect(await page.evaluate(() => Pace.running)).toBe(false); + await page.evaluate(() => { const app = document.createElement('div'); app.id = 'app'; document.body.appendChild(app); }); + await page.waitForTimeout(300); + await expect(page.locator('.pace')).toHaveCount(0); +}); + +test('options passed to Pace.start() apply to that run', async ({ page, open }) => { + await open({ head: "" }); + await page.evaluate(() => Pace.start({ elements: { selectors: ['#late'] } })); + await page.waitForTimeout(800); + expect(await page.evaluate(() => events)).toEqual(['start']); + await page.evaluate(() => { const el = document.createElement('i'); el.id = 'late'; document.body.appendChild(el); }); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(['start', 'done', 'hide']); +}); + +test('a page that sets document.onreadystatechange does not stop the bar from finishing', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG, body: '' }); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(['start', 'done', 'hide']); +}); + +test('Pace.stop() from a progress handler leaves no bar behind', async ({ page, open }) => { + await open({ head: HOLD }); + await page.evaluate(() => new Promise((resolve) => { + let count = 0; + const stopOnFifth = () => { + if (++count === 5) { + Pace.off('progress', stopOnFifth); + Pace.stop(); + resolve(); + } + }; + Pace.on('progress', stopOnFifth); + })); + await page.waitForTimeout(300); + await expect(page.locator('.pace')).toHaveCount(0); + expect(await bodyClasses(page)).toEqual(['pace-done']); +}); + +test('a navigation from a progress handler leaves one bar', async ({ page, open }) => { + await open({ head: HOLD }); + await page.evaluate(() => new Promise((resolve) => { + Pace.once('progress', () => { history.pushState({}, '', '?next'); resolve(); }); + })); + await page.evaluate(() => { hold.progress = 100; }); + await idle(page); + await expect(page.locator('.pace')).toHaveCount(1); + expect(await page.evaluate(() => events)).toEqual(['start', ...RESTARTED]); +}); + +test('Pace.stop() while the bar first renders means no start event', async ({ page, open }) => { + await open({ head: "" }); + await page.evaluate(() => { Pace.once('change', () => Pace.stop()); Pace.start(); }); + await page.waitForTimeout(300); + expect(await page.evaluate(() => [events, Pace.running])).toEqual([['stop'], false]); +}); + +test('an invalid selector neither leaves pace stuck nor breaks navigation', async ({ page, open }) => { + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await open({ head: "" }); + const [search, running] = await page.evaluate(() => { + history.pushState({}, '', '?next'); + return [location.search, Pace.running]; + }); + expect([search, running]).toEqual(['?next', false]); + await expect.poll(() => errors.length).toBe(2); // at load, and on the navigation +});