diff --git a/LICENSE b/LICENSE index 61eed25..c064132 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 0b99835..2c35d8c 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 ------ @@ -325,7 +329,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 63fc342..29c93be 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 35f6051..4ac621f 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 8effd30..a8d7196 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 () { const defaultOptions = { @@ -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,183 +298,264 @@ 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(); + + // The callbacks waiting for each open request (by its event) to end. + const endCallbacks = new WeakMap(); + + // 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(event).push(callback); + }; + + const endRequest = (request) => { + const event = openRequests.get(request); + if (!event) { + return; + } + openRequests.delete(request); + const callbacks = endCallbacks.get(event); + endCallbacks.delete(event); + for (const callback of callbacks) { + callback(); + } + }; + + const beginRequest = (intercept, event) => { + endRequest(event.request); + openRequests.set(event.request, event); + endCallbacks.set(event, []); + 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 readystatechange 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. 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('readystatechange', () => { + if (this.readyState === 4 || this.readyState === 0) { + endRequest(this); + } + }); + } + beginRequest(intercept, { ...info, request: this }); + try { + 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) => { + if (isActive() || (options.restartOnRequestAfter === false && !event.force)) { + return; + } + let after = options.restartOnRequestAfter || 0; + if (typeof after === 'boolean') { + after = 0; + } + awaitingCheck.add(event); + setTimeout(() => { + awaitingCheck.delete(event); + if (!isOpen(event)) { + return; + } + if (!isActive()) { + Pace.restart(); + } + 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); + // The trackers of the watched requests that are still loading. this.elements = []; - this.watched = new WeakSet(); this.onRequest = (event) => this.watch(event); getIntercept().on('request', this.onRequest); + for (const event of awaitingCheck) { + this.watch(event); + } } - watch({ type, request, url }) { - if (shouldIgnoreURL(url) || this.watched.has(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 (!isOpen(event) || this.elements.some((tracker) => tracker.event === event)) { return; } - this.watched.add(request); - 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 is tracking that are still loading. + openEvents() { + return this.elements.map((tracker) => tracker.event); + } + destroy() { getIntercept().off('request', this.onRequest); } } 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); - for (const event of ['load', 'abort', 'timeout', 'error']) { - request.addEventListener(event, () => { - completeCallback(this); - this.progress = 100; - }, false); - } + whenRequestEnds(event, () => { + completeCallback(this); + this.progress = 100; + }); } } class SocketRequestTracker { - constructor(request, completeCallback) { + constructor(event, completeCallback) { + this.event = event; this.progress = 0; - for (const event of ['error', 'open']) { - request.addEventListener(event, () => { - completeCallback(this); - this.progress = 100; - }, false); - } + whenRequestEnds(event, () => { + completeCallback(this); + this.progress = 100; + }); } } @@ -646,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 = []; @@ -850,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 3a6adbf..fb7f6d8 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. - */(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()})(); + * 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(),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/package.json b/package.json index d5daf16..c2cb634 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "loading-indicator", "loading-animation" ], - "author": "CodeByZach ", + "author": "Zachary Miller (https://github.com/CodeByZach)", "license": "MIT", "bugs": { "url": "https://github.com/CodeByZach/pace/issues" diff --git a/tests/requests.spec.mjs b/tests/requests.spec.mjs new file mode 100644 index 0000000..0e92ba5 --- /dev/null +++ b/tests/requests.spec.mjs @@ -0,0 +1,435 @@ +// 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: '' }); + await idle(page); + 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 }) => { + 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()); + } + }; + // Long enough to restart the bar, so the same run sees both requests. + xhr.open('GET', '/delay?ms=900'); + 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('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, + body: ``, + }); + await idle(page); + // Well before the replaced 3 s request would have finished. + 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, + 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 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) => { + 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, 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(); + + test.beforeAll(async ({ paceFile }) => { + const pace = fs.readFileSync(new URL(paceFile, new URL('..', import.meta.url)), 'utf8'); + server = http.createServer((req, res) => { + 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) => { + 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('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); + }); +});