From bbef1303bf8b7194e6ef8486c0cf7fb8e1cf792e Mon Sep 17 00:00:00 2001 From: Zachary Miller Date: Sun, 8 Nov 2026 11:05:16 -0500 Subject: [PATCH 1/4] Add the current maintainer's copyright notice LICENSE keeps HubSpot's notice and adds one for Zachary Miller, who has maintained pace since 2019. The headers of pace.js and pace-theme-default.css name both, package.json's author field uses npm's "name (url)" form, and the README credit shows the name. --- LICENSE | 1 + README.md | 2 +- pace-theme-default.css | 2 +- pace-theme-default.min.css | 2 +- pace.js | 2 +- pace.min.js | 2 +- package.json | 2 +- 7 files changed, 7 insertions(+), 6 deletions(-) diff --git a/LICENSE b/LICENSE index 61eed257..c064132b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ Copyright (c) 2013 HubSpot, Inc. +Copyright (c) 2019 Zachary Miller Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 94fa5621..a07367cb 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,7 @@ Credits [HubSpot](http://dev.hubspot.com) -[CodeByZach](https://github.com/CodeByZach) +[Zachary Miller](https://github.com/CodeByZach) Javascript by [Zack Bloom](http://twitter.com/zackbloom) CSS by [Adam Schwartz](http://twitter.com/adamfschwartz) diff --git a/pace-theme-default.css b/pace-theme-default.css index 63fc3425..29c93be8 100644 --- a/pace-theme-default.css +++ b/pace-theme-default.css @@ -1,7 +1,7 @@ /*! * pace.js v1.3.1 | Default theme * https://github.com/CodeByZach/pace/ - * Licensed MIT © HubSpot, Inc. + * Licensed MIT © HubSpot, Inc. and Zachary Miller */ .pace { -webkit-pointer-events: none; diff --git a/pace-theme-default.min.css b/pace-theme-default.min.css index 35f60517..4ac621fb 100644 --- a/pace-theme-default.min.css +++ b/pace-theme-default.min.css @@ -1,5 +1,5 @@ /*! * pace.js v1.3.1 | Default theme * https://github.com/CodeByZach/pace/ - * Licensed MIT © HubSpot, Inc. + * Licensed MIT © HubSpot, Inc. and Zachary Miller */.pace{-webkit-pointer-events:none;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.pace-inactive{display:none}.pace .pace-progress{background:#d72630;position:fixed;z-index:2000;top:0;right:100%;width:100%;height:2px} diff --git a/pace.js b/pace.js index 5c60305d..49b42ed4 100644 --- a/pace.js +++ b/pace.js @@ -1,7 +1,7 @@ /*! * pace.js v1.3.1 * https://github.com/CodeByZach/pace/ - * Licensed MIT © HubSpot, Inc. + * Licensed MIT © HubSpot, Inc. and Zachary Miller */ (function() { var AjaxMonitor, Bar, DocumentMonitor, ElementMonitor, ElementTracker, EventLagMonitor, Evented, Events, NoTargetError, Pace, RequestIntercept, SOURCE_KEYS, Scaler, SocketRequestTracker, XHRRequestTracker, addEventListener, animation, avgAmplitude, bar, cancelAnimation, cancelAnimationFrame, defaultOptions, extend, extendNative, getFromDOM, getIntercept, handlePushState, ignoreStack, init, now, options, requestAnimationFrame, result, runAnimation, scalers, shouldIgnoreURL, shouldTrack, source, sources, uniScaler, _WebSocket, _XDomainRequest, _XMLHttpRequest, _i, _intercept, _len, _pushState, _ref, _ref1, _replaceState, diff --git a/pace.min.js b/pace.min.js index 6b6be08b..a2466404 100644 --- a/pace.min.js +++ b/pace.min.js @@ -1,5 +1,5 @@ /*! * pace.js v1.3.1 * https://github.com/CodeByZach/pace/ - * Licensed MIT © HubSpot, Inc. + * Licensed MIT © HubSpot, Inc. and Zachary Miller */(function(){var O,Y,Q,V,Z,$,ee,C,q,u,te,re,F,ne,se,E,w,ie,g,R,x,X,v,L,oe,U,D,y,ue,d,l,T,I,ae,b,H,P,p,_,W,k,M,z,A,j,le,ce,B,fe,pe,S=[].slice,he={}.hasOwnProperty,ge=function(t,e){for(var r in e)he.call(e,r)&&(t[r]=e[r]);function n(){this.constructor=t}return n.prototype=e.prototype,t.prototype=new n,t.__super__=e.prototype,t},we=[].indexOf||function(t){for(var e=0,r=this.length;e=33?(e=d(),t(n,function(){return T(r)})):setTimeout(r,33-n)},r()},I=function(){var t,e,r;return r=arguments[0],e=arguments[1],t=3<=arguments.length?S.call(arguments,2):[],typeof r[e]=="function"?r[e].apply(r,t):r[e]},v=function(){var t,e,r,n,s,i,o;for(e=arguments[0],n=2<=arguments.length?S.call(arguments,1):[],i=0,o=n.length;i=100?n="99":(n=this.progress<10?"0":"",n+=this.progress|0),e.children[0].setAttribute("data-progress",""+n)),u.trigger("change",this.progress),this.lastRenderedProgress=this.progress},t.prototype.done=function(){return this.progress>=100},t})(),C=(function(){function t(){this.bindings={}}return t.prototype.trigger=function(e,r){var n,s,i,o,a;if(this.bindings[e]!=null){for(o=this.bindings[e],a=[],s=0,i=o.length;s=0)return!0}return!1},te=(function(t){ge(e,t);function e(){var r,n=this;e.__super__.constructor.apply(this,arguments),r=function(s){var i;return i=s.open,s.open=function(o,a,f){return P(o)&&n.trigger("request",{type:o,url:a,request:s}),i.apply(s,arguments)}},window.XMLHttpRequest=function(s){var i;return i=new z(s),r(i),i};try{L(window.XMLHttpRequest,z)}catch(s){}if(M!=null){window.XDomainRequest=function(){var s;return s=new M,r(s),s};try{L(window.XDomainRequest,M)}catch(s){}}if(k!=null&&l.ajax.trackWebSockets){window.WebSocket=function(s,i){var o;return i!=null?o=new k(s,i):o=new k(s),P("socket")&&n.trigger("request",{type:"socket",url:s,protocols:i,request:o}),o};try{L(window.WebSocket,k)}catch(s){}}}return e})(C),j=null,U=function(){return j==null&&(j=new te),j},H=function(t){var e,r,n,s;for(s=l.ajax.ignoreURLs,r=0,n=s.length;re.sampleCount&&o.shift(),r=ie(o),++i>=e.minSamples&&r=100&&(this.done=!0),r===this.last?this.sinceLastUpdate+=e:(this.sinceLastUpdate&&(this.rate=(r-this.last)/this.sinceLastUpdate),this.catchup=(r-this.progress)/l.catchupTime,this.sinceLastUpdate=0,this.last=r),r>this.progress&&(this.progress+=this.catchup*e),n=1-Math.pow(this.progress/100,l.easeFactor),this.progress+=n*this.rate*e,this.progress=Math.min(this.lastProgress+l.maxProgressPerFrame,this.progress),this.progress=Math.max(0,this.progress),this.progress=Math.min(100,this.progress),this.lastProgress=this.progress,this.progress},t})(),_=null,b=null,g=null,W=null,w=null,R=null,u.running=!1,D=function(){if(l.restartOnPushState)return u.restart()},window.history.pushState!=null&&(ce=window.history.pushState,window.history.pushState=function(){return D(),ce.apply(window.history,arguments)}),window.history.replaceState!=null&&(pe=window.history.replaceState,window.history.replaceState=function(){return D(),pe.apply(window.history,arguments)}),re={ajax:O,elements:V,document:Q,eventLag:$},(ue=function(){var t,e,r,n,s,i,o,a;for(u.sources=_=[],i=["ajax","elements","document","eventLag"],e=0,n=i.length;e", + "author": "Zachary Miller (https://github.com/CodeByZach)", "license": "MIT", "bugs": { "url": "https://github.com/CodeByZach/pace/issues" From 6a2c31b45d181632a133a590aba5e2ae12eb31d9 Mon Sep 17 00:00:00 2001 From: Zachary Miller Date: Sun, 8 Nov 2026 12:58:57 -0500 Subject: [PATCH 2/4] Track requests without replacing XMLHttpRequest Pace used to replace window.XMLHttpRequest with a function returning native objects, and WebSocket the same way. Pages then got a different global: instanceof checks failed, copying or enumerating the constructor's properties (Object.assign({}, XMLHttpRequest)) threw, subclasses lost their methods, and libraries that patch XMLHttpRequest.prototype after pace (Sentry, zone.js) never saw requests, because pace gave every request its own open(). The wrapper was installed even with `ajax: false`. The idea of patching the prototype instead comes from a2intl's work on sharing XMLHttpRequest's prototype. Now: - Pace wraps XMLHttpRequest.prototype.open and send, and replaces WebSocket with a subclass of the native one, so the page keeps the native classes. Nothing is installed until a run first tracks ajax, and nothing at all with `ajax: false`, where Pace.track no longer throws either. - A request is tracked from send() until it ends, instead of from open() until load, abort, timeout or error. A request that was opened but never sent, aborted before sending, or whose open() threw no longer holds the bar forever. Opening a request again ends the one it replaces, and a request sent again from its own load handler is tracked until the second one ends. - When a run is stopped or restarted (by a navigation, say), the next run keeps tracking the requests it was tracking that are still loading. Before, they were dropped and the new run could finish early. - ajax.ignoreURLs is checked once per request: a URL object no longer throws, a RegExp with the g flag no longer matches only every other time, and entries that are neither strings nor RegExps are skipped. The README notes that requests sent before Pace first starts aren't tracked, and that Pace should load before a library that replaces window.XMLHttpRequest. --- README.md | 6 +- pace.js | 308 +++++++++++++++++++++++++--------------- pace.min.js | 2 +- tests/requests.spec.mjs | 293 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 489 insertions(+), 120 deletions(-) create mode 100644 tests/requests.spec.mjs diff --git a/README.md b/README.md index 93c64526..2c35d8c6 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,10 @@ A few things Pace can't track: - Requests sent with `async: false` block the page until they finish, so the bar can't animate during them. - Downloads started by a plain link aren't ajax requests. +- Requests sent before Pace first starts. When it doesn't start on page load (`startOnPageLoad: false`, or with a + bundler), call `Pace.start()` early. +- Requests made through a library that replaces `window.XMLHttpRequest` with its own version (such as xhook), if that + library loads before Pace. Load Pace first. Avoid calling `Pace.restart()` for every request, for example from jQuery's `ajaxStart`. It restarts the bar even for ignored requests, and parallel requests restart it several times. To show the bar for `POST` requests, add `POST` to @@ -312,7 +316,7 @@ If you run into a compatibility issue, please create an issue. Size ---- -pace.min.js is under 4 kB gzipped. The themes are between 0.2 and 3.2 kB gzipped. +pace.min.js is about 4 kB gzipped. The themes are between 0.2 and 3.2 kB gzipped. Issues ------ diff --git a/pace.js b/pace.js index 56df2657..ee2ef825 100644 --- a/pace.js +++ b/pace.js @@ -282,24 +282,6 @@ } } - const _XMLHttpRequest = window.XMLHttpRequest; - - const _WebSocket = window.WebSocket; - - const extendNative = (to, from) => { - for (const key in from.prototype) { - try { - if (to[key] == null && typeof from[key] !== 'function') { - Object.defineProperty(to, key, { - get: () => from.prototype[key], - configurable: true, - enumerable: true, - }); - } - } catch {} - } - }; - const ignoreStack = []; Pace.ignore = (fn, ...args) => { @@ -316,137 +298,225 @@ return ret; }; + // true, false, or 'force' (inside Pace.track). With `ajax: false`, nothing is + // tracked, not even inside Pace.track. const shouldTrack = (method) => { + if (!options.ajax) { + return false; + } method ??= 'GET'; if (ignoreStack[0] === 'track') { return 'force'; } - if (!ignoreStack.length && options.ajax) { - if (method === 'socket' && options.ajax.trackWebSockets) { - return true; - } - // Array.prototype.includes, so a string or number from a misconfiguration - // matches nothing instead of matching substrings or throwing. - if (Array.prototype.includes.call(options.ajax.trackMethods, method.toUpperCase())) { - return true; + if (ignoreStack.length) { + return false; + } + if (method === 'socket') { + return Boolean(options.ajax.trackWebSockets); + } + // Array.prototype.includes, so a string or number from a misconfiguration + // matches nothing instead of matching substrings or throwing. + return Array.prototype.includes.call(options.ajax.trackMethods, String(method).toUpperCase()); + }; + + const shouldIgnoreURL = (url) => { + const patterns = options.ajax?.ignoreURLs ?? []; + for (let i = 0, n = patterns.length; i < n; i++) { + const pattern = patterns[i]; + if (typeof pattern === 'string') { + if (url.includes(pattern)) { + return true; + } + } else if (pattern instanceof RegExp) { + // A pattern with the g or y flag keeps state between test() calls. + pattern.lastIndex = 0; + if (pattern.test(url)) { + return true; + } } } return false; }; - // Outside the class on purpose: class bodies are strict mode, where assigning - // to a read-only property (a page may lock window.XMLHttpRequest) throws. + // Tracked requests that have started and not yet ended, each with its + // 'request' event. + const openRequests = new Map(); + + // Requests a run was tracking when it was stopped or restarted, for the next + // run to keep tracking if they're still loading. + let handedOver = []; + const endCallbacks = new WeakMap(); + + // Calls `callback` once `request` ends (right away if it already has). + const whenRequestEnds = (request, callback) => { + if (!openRequests.has(request)) { + callback(); + return; + } + endCallbacks.get(request).push(callback); + }; + + const endRequest = (request) => { + if (!openRequests.delete(request)) { + return; + } + const callbacks = endCallbacks.get(request); + endCallbacks.delete(request); + for (const callback of callbacks) { + callback(); + } + }; + + const beginRequest = (intercept, event) => { + endRequest(event.request); + openRequests.set(event.request, event); + endCallbacks.set(event.request, []); + intercept.trigger('request', event); + }; + + // What open() decided about each XMLHttpRequest, used when it is sent. + const opened = new WeakMap(); + + // Wraps XMLHttpRequest.prototype.open and send, and replaces WebSocket with a + // subclass, so the page keeps the native classes: instanceof, constants, + // subclasses and other libraries' prototype patches keep working. Outside the + // class on purpose: class bodies are strict mode, where assigning to a + // read-only property (a page may lock these) throws. const installIntercepts = (intercept) => { - const monitorXHR = (req) => { - const _open = req.open; - req.open = (...args) => { - const [type, url] = args; - if (shouldTrack(type)) { - intercept.trigger('request', { type, url, request: req }); - } - return _open.apply(req, args); - }; + const proto = window.XMLHttpRequest.prototype; + const open = proto.open; + const send = proto.send; + // XMLHttpRequests that already have pace's loadend listener. + const listening = new WeakSet(); + proto.open = function (...args) { + const [method, url] = args; + const decision = shouldTrack(method); + const result = open.apply(this, args); + // Opening a request again cancels the one it was making (only once open() + // has succeeded: when it throws, the earlier request keeps going). + endRequest(this); + opened.delete(this); + if (decision) { + opened.set(this, { type: method, url: String(url), force: decision === 'force' }); + } + return result; }; - // Plain functions, not arrow functions: pages call these with `new`. - window.XMLHttpRequest = function (flags) { - const req = new _XMLHttpRequest(flags); - monitorXHR(req); - return req; + proto.send = function (...args) { + const info = opened.get(this); + opened.delete(this); + if (!info || shouldIgnoreURL(info.url)) { + return send.apply(this, args); + } + // One listener per XMLHttpRequest, added before send() because a + // synchronous request ends inside it. A request ends with readyState 4; + // a page that reuses the object from its load handler gets the first + // request's loadend after already opening the next one. + if (!listening.has(this)) { + listening.add(this); + this.addEventListener('loadend', () => { + if (this.readyState === 4) { + endRequest(this); + } + }); + } + try { + beginRequest(intercept, { ...info, request: this }); + return send.apply(this, args); + } catch (error) { + endRequest(this); + throw error; + } }; - try { - extendNative(window.XMLHttpRequest, _XMLHttpRequest); - } catch {} if (options.ajax.trackWebSockets) { - window.WebSocket = function (url, protocols) { - let req; - if (protocols != null) { - req = new _WebSocket(url, protocols); - } else { - req = new _WebSocket(url); - } - if (shouldTrack('socket')) { - intercept.trigger('request', { type: 'socket', url, protocols, request: req }); + const NativeWebSocket = window.WebSocket; + window.WebSocket = class WebSocket extends NativeWebSocket { + constructor(url, protocols) { + if (protocols != null) { + super(url, protocols); + } else { + super(url); + } + const decision = shouldTrack('socket'); + if (decision && !shouldIgnoreURL(String(url))) { + for (const name of ['open', 'error', 'close']) { + this.addEventListener(name, () => endRequest(this), { once: true }); + } + beginRequest(intercept, { type: 'socket', url: String(url), protocols, force: decision === 'force', request: this }); + } } - return req; }; - try { - extendNative(window.WebSocket, _WebSocket); - } catch {} } }; + // A request that is still open after restartOnRequestAfter ms shows the bar: + // it joins a run in progress, or restarts pace. + const onRequest = (event) => { + const { type, request, force } = event; + if (isActive() || (options.restartOnRequestAfter === false && !force)) { + return; + } + let after = options.restartOnRequestAfter || 0; + if (typeof after === 'boolean') { + after = 0; + } + setTimeout(() => { + let stillActive; + if (type === 'socket') { + stillActive = request.readyState < 1; + } else { + stillActive = 0 < request.readyState && request.readyState < 4; + } + if (!stillActive) { + return; + } + if (!isActive()) { + Pace.restart(); + } + run?.collectors.find((collector) => collector instanceof AjaxMonitor)?.watch(event); + }, after); + }; + class RequestIntercept extends Events { constructor() { super(); installIntercepts(this); + this.on('request', onRequest); } } let _intercept = null; + // Hooks into XMLHttpRequest and WebSocket the first time a run tracks ajax. const getIntercept = () => { _intercept ??= new RequestIntercept(); return _intercept; }; - const shouldIgnoreURL = (url) => { - const patterns = options.ajax.ignoreURLs; - for (let i = 0, n = patterns.length; i < n; i++) { - const pattern = patterns[i]; - if (typeof pattern === 'string') { - if (url.indexOf(pattern) !== -1) { - return true; - } - } else if (pattern.test(url)) { - return true; - } - } - return false; - }; - - getIntercept().on('request', (event) => { - const { type, request, url } = event; - if (shouldIgnoreURL(url)) { - return; - } - if (!isActive() && (options.restartOnRequestAfter !== false || shouldTrack(type) === 'force')) { - let after = options.restartOnRequestAfter || 0; - if (typeof after === 'boolean') { - after = 0; - } - setTimeout(() => { - let stillActive; - if (type === 'socket') { - stillActive = request.readyState < 1; - } else { - stillActive = 0 < request.readyState && request.readyState < 4; - } - if (!stillActive) { - return; - } - // Join a run that started meanwhile, rather than restarting it. - if (!isActive()) { - Pace.restart(); - } - run?.collectors.find((collector) => collector instanceof AjaxMonitor)?.watch(event); - }, after); - } - }); - class AjaxMonitor { constructor() { this.complete = this.complete.bind(this); this.elements = []; - this.watched = new WeakSet(); + this.watched = new Set(); this.onRequest = (event) => this.watch(event); getIntercept().on('request', this.onRequest); + // Requests the previous run was tracking, if they're still loading. + const previous = handedOver; + handedOver = []; + for (const event of previous) { + if (openRequests.get(event.request) === event) { + this.watch(event); + } + } } - watch({ type, request, url }) { - if (shouldIgnoreURL(url) || this.watched.has(request)) { + // Each request event once (a reused XMLHttpRequest sends a new one per + // request). + watch(event) { + if (this.watched.has(event)) { return; } - this.watched.add(request); + this.watched.add(event); + const { type, request } = event; let tracker; if (type === 'socket') { tracker = new SocketRequestTracker(request, this.complete); @@ -460,6 +530,11 @@ this.elements = this.elements.filter((e) => e !== tracker); } + // The requests this monitor watched that are still loading. + openEvents() { + return [...this.watched].filter((event) => openRequests.get(event.request) === event); + } + destroy() { getIntercept().off('request', this.onRequest); } @@ -475,24 +550,20 @@ this.progress = this.progress + (100 - this.progress) / 2; } }, false); - for (const event of ['load', 'abort', 'timeout', 'error']) { - request.addEventListener(event, () => { - completeCallback(this); - this.progress = 100; - }, false); - } + whenRequestEnds(request, () => { + completeCallback(this); + this.progress = 100; + }); } } class SocketRequestTracker { constructor(request, completeCallback) { this.progress = 0; - for (const event of ['error', 'open']) { - request.addEventListener(event, () => { - completeCallback(this); - this.progress = 100; - }, false); - } + whenRequestEnds(request, () => { + completeCallback(this); + this.progress = 100; + }); } } @@ -705,6 +776,7 @@ cancelAnimationFrame(current.frame); clearTimeout(current.waitTimer); clearTimeout(current.hideTimer); + handedOver = current.collectors.find((collector) => collector instanceof AjaxMonitor)?.openEvents() ?? []; stopCollectors(current); } bar.destroy(); diff --git a/pace.min.js b/pace.min.js index 9f1b15dc..029d0a9e 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. and Zachary Miller - */(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()})(); + */(function(){const O={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:[]}},p=()=>performance.now(),v=window.requestAnimationFrame,z=window.cancelAnimationFrame,A=(t,e,...s)=>typeof t[e]=="function"?t[e].apply(t,s):t[e],q=(t,...e)=>{for(const s of e)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];t[r]!=null&&typeof t[r]=="object"&&n!=null&&typeof n=="object"?q(t[r],n):t[r]=n}return t},J=t=>{let e=0,s=0;for(const r of t)e+=Math.abs(r),s++;return e/s},K=(t="options",e=!0)=>{const s=document.querySelector(`script[data-pace-${t}]`);if(!s)return;const r=s.getAttribute(`data-pace-${t}`);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 C=(t,e,s)=>{const r=t.bindings?.[e];r&&(t.bindings[e]=r.filter(n=>s(n)?(n.removed=!0,!1):!0))},E=t=>{typeof reportError=="function"?reportError(t):queueMicrotask(()=>{throw t})};Object.assign(o,{on(t,e,s,r){r??=!1,this.bindings??=Object.create(null),this.bindings[t]??=[],this.bindings[t].push({handler:e,ctx:s,once:r})},once(t,e,s){this.on(t,e,s,!0)},off(t,e){C(this,t,s=>e==null||s.handler===e)},trigger(t,...e){const s=this.bindings?.[t];if(s){for(const r of s.slice())if(!r.removed){r.once&&C(this,t,n=>n===r);try{r.handler.apply(r.ctx??this,e)}catch(n){E(n)}}}}});const i=o.options=q({},O,window.paceOptions,K()),j=()=>{for(const t of["ajax","document","eventLag","elements"])i[t]===!0&&(i[t]=O[t])};j();class I{constructor(){this.progress=0}getElement(){if(this.destroyed)return null;if(this.el==null){const e=document.querySelector(i.target);if(!e)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),e.insertBefore(this.el,e.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(e){this.progress=e,o.trigger("progress",e),this.render()}destroy(){this.el?.remove(),this.el=void 0,this.destroyed=!0}render(){if(document.querySelector(i.target)==null)return;const e=this.getElement();if(e){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 s;this.progress>=100?s="99":(s=this.progress<10?"0":"",s+=this.progress|0),e.children[0].setAttribute("data-progress",s)}o.trigger("change",this.progress),this.lastRenderedProgress=this.progress}}done(){return this.progress>=100}}class Y{constructor(){this.bindings={}}trigger(e,s){if(this.bindings[e]!=null)for(const r of this.bindings[e].slice())r.call(this,s)}on(e,s){this.bindings[e]??=[],this.bindings[e].push(s)}off(e,s){this.bindings[e]=this.bindings[e]?.filter(r=>r!==s)}}const f=[];o.ignore=(t,...e)=>{f.unshift("ignore");const s=t.apply(null,e);return f.shift(),s},o.track=(t,...e)=>{f.unshift("track");const s=t.apply(null,e);return f.shift(),s};const W=t=>i.ajax?(t??="GET",f[0]==="track"?"force":f.length?!1:t==="socket"?!!i.ajax.trackWebSockets:Array.prototype.includes.call(i.ajax.trackMethods,String(t).toUpperCase())):!1,F=t=>{const e=i.ajax?.ignoreURLs??[];for(let s=0,r=e.length;s{if(!m.has(t)){e();return}b.get(t).push(e)},y=t=>{if(!m.delete(t))return;const e=b.get(t);b.delete(t);for(const s of e)s()},N=(t,e)=>{y(e.request),m.set(e.request,e),b.set(e.request,[]),t.trigger("request",e)},k=new WeakMap,Q=t=>{const e=window.XMLHttpRequest.prototype,s=e.open,r=e.send,n=new WeakSet;if(e.open=function(...c){const[l,h]=c,u=W(l),g=s.apply(this,c);return y(this),k.delete(this),u&&k.set(this,{type:l,url:String(h),force:u==="force"}),g},e.send=function(...c){const l=k.get(this);if(k.delete(this),!l||F(l.url))return r.apply(this,c);n.has(this)||(n.add(this),this.addEventListener("loadend",()=>{this.readyState===4&&y(this)}));try{return N(t,{...l,request:this}),r.apply(this,c)}catch(h){throw y(this),h}},i.ajax.trackWebSockets){const c=window.WebSocket;window.WebSocket=class extends c{constructor(h,u){u!=null?super(h,u):super(h);const g=W("socket");if(g&&!F(String(h))){for(const S of["open","error","close"])this.addEventListener(S,()=>y(this),{once:!0});N(t,{type:"socket",url:String(h),protocols:u,force:g==="force",request:this})}}}}},V=t=>{const{type:e,request:s,force:r}=t;if(x()||i.restartOnRequestAfter===!1&&!r)return;let n=i.restartOnRequestAfter||0;typeof n=="boolean"&&(n=0),setTimeout(()=>{let c;e==="socket"?c=s.readyState<1:c=0l instanceof R)?.watch(t))},n)};class Z extends Y{constructor(){super(),Q(this),this.on("request",V)}}let _=null;const B=()=>(_??=new Z,_);class R{constructor(){this.complete=this.complete.bind(this),this.elements=[],this.watched=new Set,this.onRequest=s=>this.watch(s),B().on("request",this.onRequest);const e=L;L=[];for(const s of e)m.get(s.request)===s&&this.watch(s)}watch(e){if(this.watched.has(e))return;this.watched.add(e);const{type:s,request:r}=e;let n;s==="socket"?n=new te(r,this.complete):n=new ee(r,this.complete),this.elements.push(n)}complete(e){this.elements=this.elements.filter(s=>s!==e)}openEvents(){return[...this.watched].filter(e=>m.get(e.request)===e)}destroy(){B().off("request",this.onRequest)}}class ee{constructor(e,s){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),U(e,()=>{s(this),this.progress=100})}}class te{constructor(e,s){this.progress=0,U(e,()=>{s(this),this.progress=100})}}class se{constructor(e){e??={},this.complete=this.complete.bind(this),this.elements=[],e.selectors??=[],this.trackers=[];const s=e.selectors;for(let r=0,n=s.length;rs!==e)}destroy(){for(const e of this.trackers)e.destroy()}}class re{constructor(e,s,r){this.selector=e,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 ne{constructor(e){this.progress=0;let s=0;const r=[];let n=0,c=p();const l=this.interval=setInterval(()=>{const h=p()-c-50;c=p(),r.push(h),r.length>e.sampleCount&&r.shift(),s=J(r),++n>=e.minSamples&&s=100&&(this.done=!0),s===this.last?this.sinceLastUpdate+=e:(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*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 a=null,d=o.bar=new I;o.sources=[],o.running=!1;const x=()=>a!=null&&(a.state==="waiting"||a.state==="running"),G=()=>{i.restartOnPushState&&o.restart()},oe=window.history.pushState;window.history.pushState=(...t)=>{try{G()}catch(e){E(e)}return oe.apply(window.history,t)};const ie=window.history.replaceState;window.history.replaceState=(...t)=>{try{G()}catch(e){E(e)}return ie.apply(window.history,t)};const ce={ajax:R,elements:se,document:$,eventLag:ne},T=t=>{for(const e of t.collectors.splice(0))e.destroy()},H=()=>{if(a){const t=a;a=null,z(t.frame),clearTimeout(t.waitTimer),clearTimeout(t.hideTimer),L=t.collectors.find(e=>e instanceof R)?.openEvents()??[],T(t)}d.destroy()},ae=t=>{if(d.update(100),a!==t||(t.state="finishing",T(t),o.trigger("done"),a!==t))return;const e=p()-t.startedAt;t.hideTimer=setTimeout(()=>{a=null,o.running=!1,d.finish(),o.trigger("hide")},Math.max(i.ghostTime,i.minTime-e,0))},le=t=>{let e=p();const s=()=>{if(a!==t)return;const r=p()-e;if(r<30){t.frame=v(s);return}e=p();let n=0,c=0,l=!0;const h=t.sources;for(let u=0,g=h.length;u{const t={state:"waiting",collectors:[],sources:[],scalers:new WeakMap,uniScaler:new D};try{for(const r of["ajax","elements","document","eventLag"])if(i[r]!==!1){const n=new ce[r](i[r]);t.collectors.push(n),t.sources.push(n)}const s=i.extraSources??[];for(let r=0,n=s.length;r{if(a===t){if(!d.getElement()){t.waitTimer=setTimeout(e,50);return}t.state="running",t.startedAt=p(),d.render(),a===t&&(o.trigger("start"),le(t))}};e()};o.start=t=>{q(i,t),j(),!x()&&(H(),he())},o.stop=()=>{H(),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/requests.spec.mjs b/tests/requests.spec.mjs new file mode 100644 index 00000000..8c9f5b2a --- /dev/null +++ b/tests/requests.spec.mjs @@ -0,0 +1,293 @@ +// How pace hooks into XMLHttpRequest and WebSocket: it wraps +// XMLHttpRequest.prototype.open/send and subclasses WebSocket, so pages and other +// libraries keep the native objects, and it tracks a request from send() until it +// ends. +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import { test, expect, idle, request, clearEvents, RESTARTED, HOLD, NO_EVENT_LAG } from './fixtures.mjs'; + +// Saved before pace loads, to compare with afterwards. +const SAVE_NATIVES = ``; + +test('XMLHttpRequest stays the native constructor', async ({ page, open }) => { + await open({ head: SAVE_NATIVES + NO_EVENT_LAG }); + const result = await page.evaluate(() => { + class Subclass extends XMLHttpRequest { + hello() { return 'hi'; } + } + const sub = new Subclass(); + return { + same: XMLHttpRequest === NativeXHR, + instance: new XMLHttpRequest() instanceof XMLHttpRequest, + done: XMLHttpRequest.DONE, + subclass: sub instanceof Subclass && sub.hello(), + }; + }); + expect(result).toEqual({ same: true, instance: true, done: 4, subclass: 'hi' }); +}); + +test('a library that patches XMLHttpRequest.prototype after pace still sees requests, and pace still tracks them', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await clearEvents(page); + await page.evaluate(() => { + window.seen = []; + const open = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (method, url, ...rest) { + seen.push(url); + return open.call(this, method, url, ...rest); + }; + }); + await request(page, { ms: 900 }); + await idle(page); + expect(await page.evaluate(() => [seen, events])).toEqual([['/delay?ms=900'], RESTARTED]); +}); + +test('WebSocket keeps its prototype and constants', async ({ page, open }) => { + await open({ head: SAVE_NATIVES + NO_EVENT_LAG }); + const result = await page.evaluate(() => { + class Subclass extends WebSocket { + hello() { return 'hi'; } + } + const socket = new WebSocket('ws://127.0.0.1:9/'); + const sub = new Subclass('ws://127.0.0.1:9/'); + socket.onerror = sub.onerror = () => {}; + return { + instance: socket instanceof WebSocket && socket instanceof NativeWebSocket, + open: WebSocket.OPEN, + url: socket.url, + subclass: sub instanceof Subclass && sub.hello(), + }; + }); + expect(result).toEqual({ instance: true, open: 1, url: 'ws://127.0.0.1:9/', subclass: 'hi' }); +}); + +test('with ajax: false, pace leaves XMLHttpRequest and WebSocket alone, and Pace.track does not throw', async ({ page, open }) => { + await open({ head: SAVE_NATIVES + '' }); + await idle(page); + const result = await page.evaluate(() => new Promise((resolve) => { + const untouched = XMLHttpRequest === NativeXHR && XMLHttpRequest.prototype.open === nativeOpen && WebSocket === NativeWebSocket; + const xhr = new XMLHttpRequest(); + Pace.track(() => xhr.open('GET', '/delay?ms=10')); + xhr.onload = () => resolve(untouched); + xhr.send(); + })); + expect(result).toBe(true); +}); + +test('pace hooks into XMLHttpRequest only once it starts', async ({ page, open }) => { + await open({ head: SAVE_NATIVES + '' }); + expect(await page.evaluate(() => XMLHttpRequest.prototype.open === nativeOpen)).toBe(true); + await page.evaluate(() => Pace.start()); + expect(await page.evaluate(() => XMLHttpRequest.prototype.open === nativeOpen)).toBe(false); +}); + +for (const [name, script] of [ + ['opened but never sent', "new XMLHttpRequest().open('GET', '/delay?ms=10');"], + ['aborted before it is sent', "var xhr = new XMLHttpRequest(); xhr.open('GET', '/delay?ms=10'); xhr.abort();"], + ['whose open() throws', "try { new XMLHttpRequest().open('GET', 'http://[invalid'); } catch (e) {}"], +]) { + test(`a request ${name} does not hold the bar`, async ({ page, open }) => { + await open({ head: NO_EVENT_LAG, body: `` }); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(['start', 'done', 'hide']); + }); +} + +test('a request opened but never sent on an idle page does not restart the bar', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await clearEvents(page); + await page.evaluate(() => new XMLHttpRequest().open('GET', '/delay?ms=10')); + await page.waitForTimeout(800); + expect(await page.evaluate(() => events)).toEqual([]); +}); + +test('a URL object works with ajax.ignoreURLs', async ({ page, open }) => { + await open({ head: "" }); + await idle(page); + await clearEvents(page); + await page.evaluate(() => new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', new URL('/delay?ms=900&ignored', location.href)); + xhr.onload = resolve; + xhr.onerror = reject; + xhr.send(); + })); + expect(await page.evaluate(() => events)).toEqual([]); +}); + +test('an ajax.ignoreURLs pattern with the g flag ignores every matching request', async ({ page, open }) => { + await open({ + head: '', + body: ``, + }); + await idle(page); + // Neither ignored request held the page-load run. + expect(await page.evaluate(() => loaded)).toBe(0); +}); + +test('an ajax.ignoreURLs entry that is neither a string nor a RegExp is skipped', async ({ page, open }) => { + await open({ head: '' }); + await idle(page); + await request(page, { ms: 10 }); // rejects if send() throws +}); + +test('a request sent again from its own load handler is tracked until the second one ends', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await clearEvents(page); + const secondLoadedAt = await page.evaluate(() => new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + let count = 0; + xhr.onload = () => { + if (++count === 1) { + xhr.open('GET', '/delay?ms=2000'); + xhr.send(); + } else { + resolve(performance.now()); + } + }; + xhr.open('GET', '/delay?ms=100'); + xhr.send(); + })); + await idle(page); + const [events, doneAt] = await page.evaluate(() => [window.events, eventTimes.done]); + expect(events).toEqual(RESTARTED); + expect(doneAt).toBeGreaterThanOrEqual(secondLoadedAt - 50); +}); + +test('opening a request again stops tracking the request it replaces', async ({ page, open }) => { + await open({ + head: NO_EVENT_LAG, + body: ``, + }); + await idle(page); + // Well before the replaced 3 s request would have finished. + expect(await page.evaluate(() => eventTimes.done)).toBeLessThan(2500); +}); + +test('an open() that throws on a loading request does not stop tracking it', async ({ page, open }) => { + await open({ + head: NO_EVENT_LAG, + body: ``, + }); + await idle(page); + const [doneAt, loadedAt] = await page.evaluate(() => [eventTimes.done, window.loadedAt]); + expect(loadedAt).not.toBeNull(); + expect(doneAt).toBeGreaterThanOrEqual(loadedAt - 50); +}); + +test('a background request that no run was tracking does not hold a navigation', async ({ page, open }) => { + await open({ head: '' }); + await idle(page); + await clearEvents(page); + const loaded = request(page, { ms: 3000 }); + await page.evaluate(() => history.pushState({}, '', '?next')); + await idle(page); + const loadedAt = await loaded; + const [events, doneAt] = await page.evaluate(() => [window.events, eventTimes.done]); + expect(events).toEqual(RESTARTED); + expect(doneAt).toBeLessThan(loadedAt - 1000); // finished well before the request did +}); + +test('a navigation keeps tracking the requests of the run it replaces', async ({ page, open }) => { + await open({ head: HOLD }); + const loadedAt = await page.evaluate(() => new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '/delay?ms=2000'); + xhr.onload = () => resolve(performance.now()); + xhr.send(); + // A new run starts while the request is loading; it isn't held by anything else. + setTimeout(() => { + history.pushState({}, '', '?next'); + hold.progress = 100; + }, 200); + })); + await idle(page); + expect(await page.evaluate(() => eventTimes.done)).toBeGreaterThanOrEqual(loadedAt - 50); +}); + +test('with trackWebSockets: false, WebSocket stays native', async ({ page, open }) => { + await open({ head: SAVE_NATIVES + '' }); + await idle(page); + expect(await page.evaluate(() => WebSocket === NativeWebSocket)).toBe(true); +}); + +// A real server on 127.0.0.1 (Chrome doesn't let the fake public origin of the other tests +// connect to it). It answers the WebSocket handshake after 1.5 s, so the socket stays +// connecting until then. +test.describe('a WebSocket that is still connecting', () => { + let server; + let origin; + const sockets = new Set(); + + test.beforeAll(async ({ paceFile }) => { + const pace = fs.readFileSync(new URL(paceFile, new URL('..', import.meta.url)), 'utf8'); + server = http.createServer((req, res) => { + if (req.url === '/pace.js') { + res.writeHead(200, { 'content-type': 'text/javascript' }); + return res.end(pace); + } + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(` + + +`); + }); + server.on('upgrade', (req, socket) => { + sockets.add(socket); + socket.on('error', () => {}); + const accept = crypto.createHash('sha1').update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); + setTimeout(() => { + socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); + }, 1500); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + origin = `http://127.0.0.1:${server.address().port}`; + }); + + test.afterAll(() => new Promise((resolve) => { + for (const socket of sockets) socket.destroy(); + server.closeAllConnections(); + server.close(resolve); + })); + + test('holds the bar until it opens', async ({ page }) => { + await page.goto(origin); + await page.waitForFunction(() => window.doneAt != null, null, { timeout: 10000 }); + const [openedAt, doneAt] = await page.evaluate(() => [window.openedAt, window.doneAt]); + expect(openedAt).not.toBeNull(); + expect(doneAt).toBeGreaterThanOrEqual(openedAt); + }); +}); From 03f0e3e9e1caa0fd6d9e85dccb5b5a51e669f5ac Mon Sep 17 00:00:00 2001 From: Zachary Miller Date: Sun, 8 Nov 2026 16:15:04 -0500 Subject: [PATCH 3/4] Test the g-flag ignoreURLs case on an idle page The test sent its requests during page load. In WebKit under Playwright's request routing, document.readyState stays "interactive" until those requests finish, so the document collector held the bar even though the requests were correctly ignored. On an idle page, two matching requests must not restart the bar; the test still fails without the lastIndex reset in all three engines. --- tests/requests.spec.mjs | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/tests/requests.spec.mjs b/tests/requests.spec.mjs index 8c9f5b2a..1ddfe04a 100644 --- a/tests/requests.spec.mjs +++ b/tests/requests.spec.mjs @@ -123,21 +123,12 @@ test('a URL object works with ajax.ignoreURLs', async ({ page, open }) => { }); test('an ajax.ignoreURLs pattern with the g flag ignores every matching request', async ({ page, open }) => { - await open({ - head: '', - body: ``, - }); + await open({ head: '' }); await idle(page); - // Neither ignored request held the page-load run. - expect(await page.evaluate(() => loaded)).toBe(0); + await clearEvents(page); + // Two requests, so a pattern that kept its lastIndex from the first would miss the second. + await Promise.all([request(page, { ms: 900 }), request(page, { ms: 900 })]); + expect(await page.evaluate(() => events)).toEqual([]); }); test('an ajax.ignoreURLs entry that is neither a string nor a RegExp is skipped', async ({ page, open }) => { From 7601d2e174dd6547a686e37b117f2c7ab90e3f8e Mon Sep 17 00:00:00 2001 From: Zachary Miller Date: Sun, 8 Nov 2026 17:43:21 -0500 Subject: [PATCH 4/4] Fix request endings and hand-over found in review - A request ends when its readystatechange reaches readyState 4, or 0 when the page aborts it from that handler, instead of at a loadend with readyState 4. Every way a request ends passes one of these, and a request in flight is always at 1 to 3. Before, abort() after a request finished (from onload or onreadystatechange) left it open, so every later run waited for it. - Only Pace.restart hands a run's still-loading requests to the next run, and it gives them to whichever run is going afterwards, even when stop or start handlers replaced it. Before, Pace.stop also handed them over, so an unrelated run much later waited for requests the page had stopped showing, and a stop handler that started pace again lost them. - A run that starts while a request waits for its restartOnRequestAfter check picks it up, so a navigation right after a request (as Turbolinks does) shows one bar instead of two. - That check now asks whether the request itself is still loading, rather than reading its readyState, so a request reopened from its load handler without being sent no longer restarts the bar. - The ajax collector keeps only the trackers of requests still loading, so finished requests can be garbage collected, and end callbacks belong to a request's event rather than its XMLHttpRequest. Tests cover each case; the ones that depend on a request's end use a real local server with responses of unknown length, because with a known length the request's progress reaches 100% and hides a request that never ends. --- pace.js | 111 +++++++++++++------------ pace.min.js | 2 +- tests/requests.spec.mjs | 177 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 220 insertions(+), 70 deletions(-) diff --git a/pace.js b/pace.js index ee2ef825..a8d71969 100644 --- a/pace.js +++ b/pace.js @@ -342,26 +342,33 @@ // 'request' event. const openRequests = new Map(); - // Requests a run was tracking when it was stopped or restarted, for the next - // run to keep tracking if they're still loading. - let handedOver = []; + // The callbacks waiting for each open request (by its event) to end. const endCallbacks = new WeakMap(); - // Calls `callback` once `request` ends (right away if it already has). - const whenRequestEnds = (request, callback) => { - if (!openRequests.has(request)) { + // Requests waiting for their restartOnRequestAfter check. A request sent just + // before a run starts (Turbolinks sends one, then calls pushState) belongs to + // that run, so a run that starts meanwhile picks them up. + const awaitingCheck = new Set(); + + const isOpen = (event) => openRequests.get(event.request) === event; + + // Calls `callback` once the request of `event` ends (right away if it has). + const whenRequestEnds = (event, callback) => { + if (!isOpen(event)) { callback(); return; } - endCallbacks.get(request).push(callback); + endCallbacks.get(event).push(callback); }; const endRequest = (request) => { - if (!openRequests.delete(request)) { + const event = openRequests.get(request); + if (!event) { return; } - const callbacks = endCallbacks.get(request); - endCallbacks.delete(request); + openRequests.delete(request); + const callbacks = endCallbacks.get(event); + endCallbacks.delete(event); for (const callback of callbacks) { callback(); } @@ -370,7 +377,7 @@ const beginRequest = (intercept, event) => { endRequest(event.request); openRequests.set(event.request, event); - endCallbacks.set(event.request, []); + endCallbacks.set(event, []); intercept.trigger('request', event); }; @@ -386,7 +393,7 @@ const proto = window.XMLHttpRequest.prototype; const open = proto.open; const send = proto.send; - // XMLHttpRequests that already have pace's loadend listener. + // XMLHttpRequests that already have pace's readystatechange listener. const listening = new WeakSet(); proto.open = function (...args) { const [method, url] = args; @@ -408,19 +415,19 @@ return send.apply(this, args); } // One listener per XMLHttpRequest, added before send() because a - // synchronous request ends inside it. A request ends with readyState 4; - // a page that reuses the object from its load handler gets the first - // request's loadend after already opening the next one. + // synchronous request ends inside it. Every way a request ends passes + // readyState 4 (done) or, when the page aborts it from that handler, 0; + // a request in flight is always at 1 to 3. if (!listening.has(this)) { listening.add(this); - this.addEventListener('loadend', () => { - if (this.readyState === 4) { + this.addEventListener('readystatechange', () => { + if (this.readyState === 4 || this.readyState === 0) { endRequest(this); } }); } + beginRequest(intercept, { ...info, request: this }); try { - beginRequest(intercept, { ...info, request: this }); return send.apply(this, args); } catch (error) { endRequest(this); @@ -451,28 +458,23 @@ // A request that is still open after restartOnRequestAfter ms shows the bar: // it joins a run in progress, or restarts pace. const onRequest = (event) => { - const { type, request, force } = event; - if (isActive() || (options.restartOnRequestAfter === false && !force)) { + if (isActive() || (options.restartOnRequestAfter === false && !event.force)) { return; } let after = options.restartOnRequestAfter || 0; if (typeof after === 'boolean') { after = 0; } + awaitingCheck.add(event); setTimeout(() => { - let stillActive; - if (type === 'socket') { - stillActive = request.readyState < 1; - } else { - stillActive = 0 < request.readyState && request.readyState < 4; - } - if (!stillActive) { + awaitingCheck.delete(event); + if (!isOpen(event)) { return; } if (!isActive()) { Pace.restart(); } - run?.collectors.find((collector) => collector instanceof AjaxMonitor)?.watch(event); + ajaxMonitor()?.watch(event); }, after); }; @@ -495,44 +497,32 @@ class AjaxMonitor { constructor() { this.complete = this.complete.bind(this); + // The trackers of the watched requests that are still loading. this.elements = []; - this.watched = new Set(); this.onRequest = (event) => this.watch(event); getIntercept().on('request', this.onRequest); - // Requests the previous run was tracking, if they're still loading. - const previous = handedOver; - handedOver = []; - for (const event of previous) { - if (openRequests.get(event.request) === event) { - this.watch(event); - } + for (const event of awaitingCheck) { + this.watch(event); } } - // Each request event once (a reused XMLHttpRequest sends a new one per - // request). + // Tracks the request of `event`, unless it has ended or is already tracked + // (a reused XMLHttpRequest sends a new event per request). watch(event) { - if (this.watched.has(event)) { + if (!isOpen(event) || this.elements.some((tracker) => tracker.event === event)) { return; } - this.watched.add(event); - const { type, request } = event; - let tracker; - if (type === 'socket') { - tracker = new SocketRequestTracker(request, this.complete); - } else { - tracker = new XHRRequestTracker(request, this.complete); - } - this.elements.push(tracker); + const Tracker = event.type === 'socket' ? SocketRequestTracker : XHRRequestTracker; + this.elements.push(new Tracker(event, this.complete)); } complete(tracker) { this.elements = this.elements.filter((e) => e !== tracker); } - // The requests this monitor watched that are still loading. + // The requests this monitor is tracking that are still loading. openEvents() { - return [...this.watched].filter((event) => openRequests.get(event.request) === event); + return this.elements.map((tracker) => tracker.event); } destroy() { @@ -541,16 +531,17 @@ } class XHRRequestTracker { - constructor(request, completeCallback) { + constructor(event, completeCallback) { + this.event = event; this.progress = 0; - request.addEventListener('progress', (evt) => { + event.request.addEventListener('progress', (evt) => { if (evt.lengthComputable) { this.progress = (100 * evt.loaded) / evt.total; } else { this.progress = this.progress + (100 - this.progress) / 2; } }, false); - whenRequestEnds(request, () => { + whenRequestEnds(event, () => { completeCallback(this); this.progress = 100; }); @@ -558,9 +549,10 @@ } class SocketRequestTracker { - constructor(request, completeCallback) { + constructor(event, completeCallback) { + this.event = event; this.progress = 0; - whenRequestEnds(request, () => { + whenRequestEnds(event, () => { completeCallback(this); this.progress = 100; }); @@ -717,6 +709,8 @@ // 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; + + const ajaxMonitor = () => run?.collectors.find((collector) => collector instanceof AjaxMonitor); let bar = (Pace.bar = new Bar()); Pace.sources = []; @@ -776,7 +770,6 @@ cancelAnimationFrame(current.frame); clearTimeout(current.waitTimer); clearTimeout(current.hideTimer); - handedOver = current.collectors.find((collector) => collector instanceof AjaxMonitor)?.openEvents() ?? []; stopCollectors(current); } bar.destroy(); @@ -922,8 +915,14 @@ Pace.restart = () => { Pace.trigger('restart'); + const carried = ajaxMonitor()?.openEvents() ?? []; Pace.stop(); Pace.start(); + // Whichever run is going now (stop and start handlers may have replaced + // it) keeps tracking the requests the replaced run was tracking. + for (const event of carried) { + ajaxMonitor()?.watch(event); + } }; if (typeof define === 'function' && define.amd) { diff --git a/pace.min.js b/pace.min.js index 029d0a9e..fb7f6d8d 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. and Zachary Miller - */(function(){const O={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:[]}},p=()=>performance.now(),v=window.requestAnimationFrame,z=window.cancelAnimationFrame,A=(t,e,...s)=>typeof t[e]=="function"?t[e].apply(t,s):t[e],q=(t,...e)=>{for(const s of e)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];t[r]!=null&&typeof t[r]=="object"&&n!=null&&typeof n=="object"?q(t[r],n):t[r]=n}return t},J=t=>{let e=0,s=0;for(const r of t)e+=Math.abs(r),s++;return e/s},K=(t="options",e=!0)=>{const s=document.querySelector(`script[data-pace-${t}]`);if(!s)return;const r=s.getAttribute(`data-pace-${t}`);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 C=(t,e,s)=>{const r=t.bindings?.[e];r&&(t.bindings[e]=r.filter(n=>s(n)?(n.removed=!0,!1):!0))},E=t=>{typeof reportError=="function"?reportError(t):queueMicrotask(()=>{throw t})};Object.assign(o,{on(t,e,s,r){r??=!1,this.bindings??=Object.create(null),this.bindings[t]??=[],this.bindings[t].push({handler:e,ctx:s,once:r})},once(t,e,s){this.on(t,e,s,!0)},off(t,e){C(this,t,s=>e==null||s.handler===e)},trigger(t,...e){const s=this.bindings?.[t];if(s){for(const r of s.slice())if(!r.removed){r.once&&C(this,t,n=>n===r);try{r.handler.apply(r.ctx??this,e)}catch(n){E(n)}}}}});const i=o.options=q({},O,window.paceOptions,K()),j=()=>{for(const t of["ajax","document","eventLag","elements"])i[t]===!0&&(i[t]=O[t])};j();class I{constructor(){this.progress=0}getElement(){if(this.destroyed)return null;if(this.el==null){const e=document.querySelector(i.target);if(!e)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),e.insertBefore(this.el,e.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(e){this.progress=e,o.trigger("progress",e),this.render()}destroy(){this.el?.remove(),this.el=void 0,this.destroyed=!0}render(){if(document.querySelector(i.target)==null)return;const e=this.getElement();if(e){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 s;this.progress>=100?s="99":(s=this.progress<10?"0":"",s+=this.progress|0),e.children[0].setAttribute("data-progress",s)}o.trigger("change",this.progress),this.lastRenderedProgress=this.progress}}done(){return this.progress>=100}}class Y{constructor(){this.bindings={}}trigger(e,s){if(this.bindings[e]!=null)for(const r of this.bindings[e].slice())r.call(this,s)}on(e,s){this.bindings[e]??=[],this.bindings[e].push(s)}off(e,s){this.bindings[e]=this.bindings[e]?.filter(r=>r!==s)}}const f=[];o.ignore=(t,...e)=>{f.unshift("ignore");const s=t.apply(null,e);return f.shift(),s},o.track=(t,...e)=>{f.unshift("track");const s=t.apply(null,e);return f.shift(),s};const W=t=>i.ajax?(t??="GET",f[0]==="track"?"force":f.length?!1:t==="socket"?!!i.ajax.trackWebSockets:Array.prototype.includes.call(i.ajax.trackMethods,String(t).toUpperCase())):!1,F=t=>{const e=i.ajax?.ignoreURLs??[];for(let s=0,r=e.length;s{if(!m.has(t)){e();return}b.get(t).push(e)},y=t=>{if(!m.delete(t))return;const e=b.get(t);b.delete(t);for(const s of e)s()},N=(t,e)=>{y(e.request),m.set(e.request,e),b.set(e.request,[]),t.trigger("request",e)},k=new WeakMap,Q=t=>{const e=window.XMLHttpRequest.prototype,s=e.open,r=e.send,n=new WeakSet;if(e.open=function(...c){const[l,h]=c,u=W(l),g=s.apply(this,c);return y(this),k.delete(this),u&&k.set(this,{type:l,url:String(h),force:u==="force"}),g},e.send=function(...c){const l=k.get(this);if(k.delete(this),!l||F(l.url))return r.apply(this,c);n.has(this)||(n.add(this),this.addEventListener("loadend",()=>{this.readyState===4&&y(this)}));try{return N(t,{...l,request:this}),r.apply(this,c)}catch(h){throw y(this),h}},i.ajax.trackWebSockets){const c=window.WebSocket;window.WebSocket=class extends c{constructor(h,u){u!=null?super(h,u):super(h);const g=W("socket");if(g&&!F(String(h))){for(const S of["open","error","close"])this.addEventListener(S,()=>y(this),{once:!0});N(t,{type:"socket",url:String(h),protocols:u,force:g==="force",request:this})}}}}},V=t=>{const{type:e,request:s,force:r}=t;if(x()||i.restartOnRequestAfter===!1&&!r)return;let n=i.restartOnRequestAfter||0;typeof n=="boolean"&&(n=0),setTimeout(()=>{let c;e==="socket"?c=s.readyState<1:c=0l instanceof R)?.watch(t))},n)};class Z extends Y{constructor(){super(),Q(this),this.on("request",V)}}let _=null;const B=()=>(_??=new Z,_);class R{constructor(){this.complete=this.complete.bind(this),this.elements=[],this.watched=new Set,this.onRequest=s=>this.watch(s),B().on("request",this.onRequest);const e=L;L=[];for(const s of e)m.get(s.request)===s&&this.watch(s)}watch(e){if(this.watched.has(e))return;this.watched.add(e);const{type:s,request:r}=e;let n;s==="socket"?n=new te(r,this.complete):n=new ee(r,this.complete),this.elements.push(n)}complete(e){this.elements=this.elements.filter(s=>s!==e)}openEvents(){return[...this.watched].filter(e=>m.get(e.request)===e)}destroy(){B().off("request",this.onRequest)}}class ee{constructor(e,s){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),U(e,()=>{s(this),this.progress=100})}}class te{constructor(e,s){this.progress=0,U(e,()=>{s(this),this.progress=100})}}class se{constructor(e){e??={},this.complete=this.complete.bind(this),this.elements=[],e.selectors??=[],this.trackers=[];const s=e.selectors;for(let r=0,n=s.length;rs!==e)}destroy(){for(const e of this.trackers)e.destroy()}}class re{constructor(e,s,r){this.selector=e,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 ne{constructor(e){this.progress=0;let s=0;const r=[];let n=0,c=p();const l=this.interval=setInterval(()=>{const h=p()-c-50;c=p(),r.push(h),r.length>e.sampleCount&&r.shift(),s=J(r),++n>=e.minSamples&&s=100&&(this.done=!0),s===this.last?this.sinceLastUpdate+=e:(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*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 a=null,d=o.bar=new I;o.sources=[],o.running=!1;const x=()=>a!=null&&(a.state==="waiting"||a.state==="running"),G=()=>{i.restartOnPushState&&o.restart()},oe=window.history.pushState;window.history.pushState=(...t)=>{try{G()}catch(e){E(e)}return oe.apply(window.history,t)};const ie=window.history.replaceState;window.history.replaceState=(...t)=>{try{G()}catch(e){E(e)}return ie.apply(window.history,t)};const ce={ajax:R,elements:se,document:$,eventLag:ne},T=t=>{for(const e of t.collectors.splice(0))e.destroy()},H=()=>{if(a){const t=a;a=null,z(t.frame),clearTimeout(t.waitTimer),clearTimeout(t.hideTimer),L=t.collectors.find(e=>e instanceof R)?.openEvents()??[],T(t)}d.destroy()},ae=t=>{if(d.update(100),a!==t||(t.state="finishing",T(t),o.trigger("done"),a!==t))return;const e=p()-t.startedAt;t.hideTimer=setTimeout(()=>{a=null,o.running=!1,d.finish(),o.trigger("hide")},Math.max(i.ghostTime,i.minTime-e,0))},le=t=>{let e=p();const s=()=>{if(a!==t)return;const r=p()-e;if(r<30){t.frame=v(s);return}e=p();let n=0,c=0,l=!0;const h=t.sources;for(let u=0,g=h.length;u{const t={state:"waiting",collectors:[],sources:[],scalers:new WeakMap,uniScaler:new D};try{for(const r of["ajax","elements","document","eventLag"])if(i[r]!==!1){const n=new ce[r](i[r]);t.collectors.push(n),t.sources.push(n)}const s=i.extraSources??[];for(let r=0,n=s.length;r{if(a===t){if(!d.getElement()){t.waitTimer=setTimeout(e,50);return}t.state="running",t.startedAt=p(),d.render(),a===t&&(o.trigger("start"),le(t))}};e()};o.start=t=>{q(i,t),j(),!x()&&(H(),he())},o.stop=()=>{H(),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()})(); + */(function(){const O={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:[]}},p=()=>performance.now(),q=window.requestAnimationFrame,K=window.cancelAnimationFrame,A=(t,e,...s)=>typeof t[e]=="function"?t[e].apply(t,s):t[e],E=(t,...e)=>{for(const s of e)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];t[r]!=null&&typeof t[r]=="object"&&n!=null&&typeof n=="object"?E(t[r],n):t[r]=n}return t},Y=t=>{let e=0,s=0;for(const r of t)e+=Math.abs(r),s++;return e/s},Q=(t="options",e=!0)=>{const s=document.querySelector(`script[data-pace-${t}]`);if(!s)return;const r=s.getAttribute(`data-pace-${t}`);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 j=(t,e,s)=>{const r=t.bindings?.[e];r&&(t.bindings[e]=r.filter(n=>s(n)?(n.removed=!0,!1):!0))},L=t=>{typeof reportError=="function"?reportError(t):queueMicrotask(()=>{throw t})};Object.assign(o,{on(t,e,s,r){r??=!1,this.bindings??=Object.create(null),this.bindings[t]??=[],this.bindings[t].push({handler:e,ctx:s,once:r})},once(t,e,s){this.on(t,e,s,!0)},off(t,e){j(this,t,s=>e==null||s.handler===e)},trigger(t,...e){const s=this.bindings?.[t];if(s){for(const r of s.slice())if(!r.removed){r.once&&j(this,t,n=>n===r);try{r.handler.apply(r.ctx??this,e)}catch(n){L(n)}}}}});const i=o.options=E({},O,window.paceOptions,Q()),I=()=>{for(const t of["ajax","document","eventLag","elements"])i[t]===!0&&(i[t]=O[t])};I();class W{constructor(){this.progress=0}getElement(){if(this.destroyed)return null;if(this.el==null){const e=document.querySelector(i.target);if(!e)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),e.insertBefore(this.el,e.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(e){this.progress=e,o.trigger("progress",e),this.render()}destroy(){this.el?.remove(),this.el=void 0,this.destroyed=!0}render(){if(document.querySelector(i.target)==null)return;const e=this.getElement();if(e){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 s;this.progress>=100?s="99":(s=this.progress<10?"0":"",s+=this.progress|0),e.children[0].setAttribute("data-progress",s)}o.trigger("change",this.progress),this.lastRenderedProgress=this.progress}}done(){return this.progress>=100}}class V{constructor(){this.bindings={}}trigger(e,s){if(this.bindings[e]!=null)for(const r of this.bindings[e].slice())r.call(this,s)}on(e,s){this.bindings[e]??=[],this.bindings[e].push(s)}off(e,s){this.bindings[e]=this.bindings[e]?.filter(r=>r!==s)}}const f=[];o.ignore=(t,...e)=>{f.unshift("ignore");const s=t.apply(null,e);return f.shift(),s},o.track=(t,...e)=>{f.unshift("track");const s=t.apply(null,e);return f.shift(),s};const F=t=>i.ajax?(t??="GET",f[0]==="track"?"force":f.length?!1:t==="socket"?!!i.ajax.trackWebSockets:Array.prototype.includes.call(i.ajax.trackMethods,String(t).toUpperCase())):!1,U=t=>{const e=i.ajax?.ignoreURLs??[];for(let s=0,r=e.length;sw.get(t.request)===t,N=(t,e)=>{if(!v(t)){e();return}b.get(t).push(e)},m=t=>{const e=w.get(t);if(!e)return;w.delete(t);const s=b.get(e);b.delete(e);for(const r of s)r()},_=(t,e)=>{m(e.request),w.set(e.request,e),b.set(e,[]),t.trigger("request",e)},k=new WeakMap,Z=t=>{const e=window.XMLHttpRequest.prototype,s=e.open,r=e.send,n=new WeakSet;if(e.open=function(...c){const[h,l]=c,u=F(h),g=s.apply(this,c);return m(this),k.delete(this),u&&k.set(this,{type:h,url:String(l),force:u==="force"}),g},e.send=function(...c){const h=k.get(this);if(k.delete(this),!h||U(h.url))return r.apply(this,c);n.has(this)||(n.add(this),this.addEventListener("readystatechange",()=>{(this.readyState===4||this.readyState===0)&&m(this)})),_(t,{...h,request:this});try{return r.apply(this,c)}catch(l){throw m(this),l}},i.ajax.trackWebSockets){const c=window.WebSocket;window.WebSocket=class extends c{constructor(l,u){u!=null?super(l,u):super(l);const g=F("socket");if(g&&!U(String(l))){for(const S of["open","error","close"])this.addEventListener(S,()=>m(this),{once:!0});_(t,{type:"socket",url:String(l),protocols:u,force:g==="force",request:this})}}}}},ee=t=>{if(T()||i.restartOnRequestAfter===!1&&!t.force)return;let e=i.restartOnRequestAfter||0;typeof e=="boolean"&&(e=0),R.add(t),setTimeout(()=>{R.delete(t),v(t)&&(T()||o.restart(),x()?.watch(t))},e)};class te extends V{constructor(){super(),Z(this),this.on("request",ee)}}let B=null;const $=()=>(B??=new te,B);class D{constructor(){this.complete=this.complete.bind(this),this.elements=[],this.onRequest=e=>this.watch(e),$().on("request",this.onRequest);for(const e of R)this.watch(e)}watch(e){if(!v(e)||this.elements.some(r=>r.event===e))return;const s=e.type==="socket"?re:se;this.elements.push(new s(e,this.complete))}complete(e){this.elements=this.elements.filter(s=>s!==e)}openEvents(){return this.elements.map(e=>e.event)}destroy(){$().off("request",this.onRequest)}}class se{constructor(e,s){this.event=e,this.progress=0,e.request.addEventListener("progress",r=>{r.lengthComputable?this.progress=100*r.loaded/r.total:this.progress=this.progress+(100-this.progress)/2},!1),N(e,()=>{s(this),this.progress=100})}}class re{constructor(e,s){this.event=e,this.progress=0,N(e,()=>{s(this),this.progress=100})}}class ne{constructor(e){e??={},this.complete=this.complete.bind(this),this.elements=[],e.selectors??=[],this.trackers=[];const s=e.selectors;for(let r=0,n=s.length;rs!==e)}destroy(){for(const e of this.trackers)e.destroy()}}class oe{constructor(e,s,r){this.selector=e,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 G{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)}}G.prototype.states={loading:0,interactive:50,complete:100};class ie{constructor(e){this.progress=0;let s=0;const r=[];let n=0,c=p();const h=this.interval=setInterval(()=>{const l=p()-c-50;c=p(),r.push(l),r.length>e.sampleCount&&r.shift(),s=Y(r),++n>=e.minSamples&&s=100&&(this.done=!0),s===this.last?this.sinceLastUpdate+=e:(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*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 a=null;const x=()=>a?.collectors.find(t=>t instanceof D);let d=o.bar=new W;o.sources=[],o.running=!1;const T=()=>a!=null&&(a.state==="waiting"||a.state==="running"),X=()=>{i.restartOnPushState&&o.restart()},ce=window.history.pushState;window.history.pushState=(...t)=>{try{X()}catch(e){L(e)}return ce.apply(window.history,t)};const ae=window.history.replaceState;window.history.replaceState=(...t)=>{try{X()}catch(e){L(e)}return ae.apply(window.history,t)};const le={ajax:D,elements:ne,document:G,eventLag:ie},M=t=>{for(const e of t.collectors.splice(0))e.destroy()},z=()=>{if(a){const t=a;a=null,K(t.frame),clearTimeout(t.waitTimer),clearTimeout(t.hideTimer),M(t)}d.destroy()},he=t=>{if(d.update(100),a!==t||(t.state="finishing",M(t),o.trigger("done"),a!==t))return;const e=p()-t.startedAt;t.hideTimer=setTimeout(()=>{a=null,o.running=!1,d.finish(),o.trigger("hide")},Math.max(i.ghostTime,i.minTime-e,0))},ue=t=>{let e=p();const s=()=>{if(a!==t)return;const r=p()-e;if(r<30){t.frame=q(s);return}e=p();let n=0,c=0,h=!0;const l=t.sources;for(let u=0,g=l.length;u{const t={state:"waiting",collectors:[],sources:[],scalers:new WeakMap,uniScaler:new H};try{for(const r of["ajax","elements","document","eventLag"])if(i[r]!==!1){const n=new le[r](i[r]);t.collectors.push(n),t.sources.push(n)}const s=i.extraSources??[];for(let r=0,n=s.length;r{if(a===t){if(!d.getElement()){t.waitTimer=setTimeout(e,50);return}t.state="running",t.startedAt=p(),d.render(),a===t&&(o.trigger("start"),ue(t))}};e()};o.start=t=>{E(i,t),I(),!T()&&(z(),pe())},o.stop=()=>{z(),o.running=!1,document.body.classList.remove("pace-running"),document.body.classList.add("pace-done"),o.trigger("stop")},o.restart=()=>{o.trigger("restart");const t=x()?.openEvents()??[];o.stop(),o.start();for(const e of t)x()?.watch(e)},typeof define=="function"&&define.amd?define(()=>o):typeof exports=="object"?module.exports=o:i.startOnPageLoad&&o.start()})(); diff --git a/tests/requests.spec.mjs b/tests/requests.spec.mjs index 1ddfe04a..0e92ba56 100644 --- a/tests/requests.spec.mjs +++ b/tests/requests.spec.mjs @@ -152,7 +152,8 @@ test('a request sent again from its own load handler is tracked until the second resolve(performance.now()); } }; - xhr.open('GET', '/delay?ms=100'); + // Long enough to restart the bar, so the same run sees both requests. + xhr.open('GET', '/delay?ms=900'); xhr.send(); })); await idle(page); @@ -161,6 +162,20 @@ test('a request sent again from its own load handler is tracked until the second expect(doneAt).toBeGreaterThanOrEqual(secondLoadedAt - 50); }); +test('a request opened again from its own load handler, without being sent, does not restart the bar', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await clearEvents(page); + await page.evaluate(() => new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.onload = () => { xhr.open('GET', '/delay?ms=10'); resolve(); }; + xhr.open('GET', '/delay?ms=10'); + xhr.send(); + })); + await page.waitForTimeout(800); + expect(await page.evaluate(() => events)).toEqual([]); +}); + test('opening a request again stops tracking the request it replaces', async ({ page, open }) => { await open({ head: NO_EVENT_LAG, @@ -176,6 +191,20 @@ test('opening a request again stops tracking the request it replaces', async ({ expect(await page.evaluate(() => eventTimes.done)).toBeLessThan(2500); }); +test('opening a loading request again without sending it stops tracking it', async ({ page, open }) => { + await open({ + head: NO_EVENT_LAG, + body: ``, + }); + await idle(page); + expect(await page.evaluate(() => eventTimes.done)).toBeLessThan(2500); +}); + test('an open() that throws on a loading request does not stop tracking it', async ({ page, open }) => { await open({ head: NO_EVENT_LAG, @@ -207,6 +236,52 @@ test('a background request that no run was tracking does not hold a navigation', expect(doneAt).toBeLessThan(loadedAt - 1000); // finished well before the request did }); +test('a navigation right after a request, in the same task, shows one bar that waits for it', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await clearEvents(page); + 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'); + })); + await idle(page); + const [events, doneAt] = await page.evaluate(() => [window.events, eventTimes.done]); + expect(events).toEqual(RESTARTED); + expect(doneAt).toBeGreaterThanOrEqual(loadedAt - 50); +}); + +test('a stop handler that starts pace again keeps the requests of the run being replaced', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await clearEvents(page); + const loaded = request(page, { ms: 2500 }); + await expect.poll(() => page.evaluate(() => events.includes('start'))).toBe(true); // it restarted the bar + await page.evaluate(() => { + Pace.once('stop', () => Pace.start()); + history.pushState({}, '', '?next'); + }); + const loadedAt = await loaded; + await idle(page); + expect(await page.evaluate(() => eventTimes.done)).toBeGreaterThanOrEqual(loadedAt - 50); +}); + +test('after Pace.stop(), a later unrelated run does not wait for the requests that were stopped', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await clearEvents(page); + const stoppedLoaded = request(page, { ms: 3500 }); + await expect.poll(() => page.evaluate(() => events.includes('start'))).toBe(true); // it restarted the bar + await page.evaluate(() => Pace.stop()); + await clearEvents(page); + await request(page, { ms: 900 }); + await idle(page); + const doneAt = await page.evaluate(() => eventTimes.done); + expect(doneAt).toBeLessThan((await stoppedLoaded) - 1000); +}); + test('a navigation keeps tracking the requests of the run it replaces', async ({ page, open }) => { await open({ head: HOLD }); const loadedAt = await page.evaluate(() => new Promise((resolve) => { @@ -230,10 +305,10 @@ test('with trackWebSockets: false, WebSocket stays native', async ({ page, open expect(await page.evaluate(() => WebSocket === NativeWebSocket)).toBe(true); }); -// A real server on 127.0.0.1 (Chrome doesn't let the fake public origin of the other tests -// connect to it). It answers the WebSocket handshake after 1.5 s, so the socket stays -// connecting until then. -test.describe('a WebSocket that is still connecting', () => { +// A real server on 127.0.0.1, for cases Playwright's routed responses don't reproduce. (Chrome +// doesn't let the fake public origin of the other tests connect to 127.0.0.1.) It serves a page, +// pace, /delay?ms=N, and a WebSocket whose handshake is answered after 1.5 s. +test.describe('with a real server', () => { let server; let origin; const sockets = new Set(); @@ -241,19 +316,37 @@ test.describe('a WebSocket that is still connecting', () => { test.beforeAll(async ({ paceFile }) => { const pace = fs.readFileSync(new URL(paceFile, new URL('..', import.meta.url)), 'utf8'); server = http.createServer((req, res) => { - if (req.url === '/pace.js') { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/pace.js') { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(pace); } + if (url.pathname === '/drop') { + req.socket.destroy(); // a network error + return; + } + if (url.pathname === '/delay') { + // No Content-Length (chunked), so the request's progress never reports 100% on its own + // and only its end can finish the bar. + setTimeout(() => { + res.write('ok'); + res.end(); + }, Number(url.searchParams.get('ms'))); + return; + } res.writeHead(200, { 'content-type': 'text/html' }); res.end(` `); }); server.on('upgrade', (req, socket) => { @@ -274,11 +367,69 @@ test.describe('a WebSocket that is still connecting', () => { server.close(resolve); })); - test('holds the bar until it opens', async ({ page }) => { - await page.goto(origin); - await page.waitForFunction(() => window.doneAt != null, null, { timeout: 10000 }); - const [openedAt, doneAt] = await page.evaluate(() => [window.openedAt, window.doneAt]); + test('a WebSocket that is still connecting holds the bar until it opens', async ({ page }) => { + await page.goto(`${origin}/?socket`); + await idle(page); + const [openedAt, doneAt] = await page.evaluate(() => [window.openedAt, eventTimes.done]); expect(openedAt).not.toBeNull(); expect(doneAt).toBeGreaterThanOrEqual(openedAt); }); + + test('a request aborted in its own load handler ends', async ({ page }) => { + await page.goto(origin); + await idle(page); + await clearEvents(page); + await page.evaluate(() => new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.onload = () => { xhr.abort(); resolve(); }; + xhr.open('GET', '/delay?ms=900'); + xhr.send(); + })); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + // Nothing is left open for the next run to wait on. + await clearEvents(page); + await page.evaluate(() => history.pushState({}, '', '?next')); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + }); + + test('a request aborted from its readystatechange handler when done ends', async ({ page }) => { + await page.goto(origin); + await idle(page); + await clearEvents(page); + await page.evaluate(() => new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.onreadystatechange = () => { + if (xhr.readyState === 4) { + xhr.abort(); + resolve(); + } + }; + xhr.open('GET', '/delay?ms=900'); + xhr.send(); + })); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + await clearEvents(page); + await page.evaluate(() => history.pushState({}, '', '?next')); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + }); + + test('a synchronous request that fails leaves nothing open', async ({ page }) => { + await page.goto(origin); + await idle(page); + await page.evaluate(() => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '/drop', false); + try { + xhr.send(); + } catch {} + }); + await clearEvents(page); + await page.evaluate(() => history.pushState({}, '', '?next')); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + }); });