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
+});