From d4e5c875c70b6007b8fd34e2500cf82be10a98fe Mon Sep 17 00:00:00 2001 From: Zachary Miller Date: Sat, 7 Nov 2026 17:28:12 -0500 Subject: [PATCH 1/2] Mark only the root min files as generated Without a leading slash, "pace.min.js" and "pace-theme-default.min.css" match files of that name in any folder. Anchoring them limits the attribute to the two files the build writes. themes/** already only matches the root themes folder. The same 152 files are marked. --- .gitattributes | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From db3178250e3aad1091201ae4f6f8125a50350674 Mon Sep 17 00:00:00 2001 From: Zachary Miller Date: Sat, 7 Nov 2026 22:47:00 -0500 Subject: [PATCH 2/2] Rework how runs start, stop and finish A run now goes from waiting (for the target element) to running to finishing (done, about to hide), and only one exists at a time: - Pace.stop() cancels everything the run scheduled, removes the bar and marks pace-done. Before, the animation and the pending hide kept going, so the bar came back and fired done and hide. - Pace.restart() mid-run no longer leaves a second animation running, and Pace.start() while a run is in progress only stores its options. - Each run fires start, done and hide once. A request or navigation while the bar is finishing starts a new run; before, the old run's hide timer hid the new run, and requests opened in that window were not shown. - When the restartOnRequestAfter check finds a run in progress, the request joins that run instead of restarting it. - Collectors are created when a run starts, from the options of that call, and stopped when it is done. Before, every restart added listeners and timers that were never removed, and options passed to Pace.start() only applied from the next run. - Each tracked request or element keeps its own progress state. Before, a finished request or element passed its state to the next one by list position, so the bar could finish while requests were still loading or selectors were still missing (since 1.2.0). - The document collector listens for readystatechange instead of assigning document.onreadystatechange, so a page that sets that property no longer leaves the bar stuck. - classes change through classList, without duplicates. - A collector or extra source that throws leaves pace idle, and an error during a pushState or replaceState restart is reported instead of stopping the page's navigation. The animation runs on requestAnimationFrame at about 30 frames a second. Pace.go and the internal NoTargetError are gone. --- README.md | 3 +- pace.js | 353 +++++++++++++++++++++++++-------------- pace.min.js | 2 +- tests/lifecycle.spec.mjs | 257 ++++++++++++++++++++++++++++ 4 files changed, 492 insertions(+), 123 deletions(-) create mode 100644 tests/lifecycle.spec.mjs 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 +});