Bug 1886451: Add missing ifdef Nightly guards. r=dminor
[gecko.git] / remote / shared / Navigate.sys.mjs
blob9b72c0dfbf32839c1556e7d2d5abb8a6244b7fde
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   clearTimeout: "resource://gre/modules/Timer.sys.mjs",
11   setTimeout: "resource://gre/modules/Timer.sys.mjs",
13   Deferred: "chrome://remote/content/shared/Sync.sys.mjs",
14   Log: "chrome://remote/content/shared/Log.sys.mjs",
15   truncate: "chrome://remote/content/shared/Format.sys.mjs",
16 });
18 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
19   lazy.Log.get(lazy.Log.TYPES.REMOTE_AGENT)
22 // Define a custom multiplier to apply to the unload timer on various platforms.
23 // This multiplier should only reflect the navigation performance of the
24 // platform and not the overall performance.
25 ChromeUtils.defineLazyGetter(lazy, "UNLOAD_TIMEOUT_MULTIPLIER", () => {
26   if (AppConstants.MOZ_CODE_COVERAGE) {
27     // Navigation on ccov platforms can be extremely slow because new processes
28     // need to be instrumented for coverage on startup.
29     return 16;
30   }
32   if (AppConstants.ASAN || AppConstants.DEBUG || AppConstants.TSAN) {
33     // Use an extended timeout on slow platforms.
34     return 8;
35   }
37   return 1;
38 });
40 export const DEFAULT_UNLOAD_TIMEOUT = 200;
42 /**
43  * Returns the multiplier used for the unload timer. Useful for tests which
44  * assert the behavior of this timeout.
45  */
46 export function getUnloadTimeoutMultiplier() {
47   return lazy.UNLOAD_TIMEOUT_MULTIPLIER;
50 // Used to keep weak references of webProgressListeners alive.
51 const webProgressListeners = new Set();
53 /**
54  * Wait until the initial load of the given WebProgress is done.
55  *
56  * @param {WebProgress} webProgress
57  *     The WebProgress instance to observe.
58  * @param {object=} options
59  * @param {boolean=} options.resolveWhenStarted
60  *     Flag to indicate that the Promise has to be resolved when the
61  *     page load has been started. Otherwise wait until the page has
62  *     finished loading. Defaults to `false`.
63  * @param {number=} options.unloadTimeout
64  *     Time to allow before the page gets unloaded. See ProgressListener options.
65  * @returns {Promise}
66  *     Promise which resolves when the page load is in the expected state.
67  *     Values as returned:
68  *       - {nsIURI} currentURI The current URI of the page
69  *       - {nsIURI} targetURI Target URI of the navigation
70  */
71 export async function waitForInitialNavigationCompleted(
72   webProgress,
73   options = {}
74 ) {
75   const { resolveWhenStarted = false, unloadTimeout } = options;
77   const browsingContext = webProgress.browsingContext;
79   // Start the listener right away to avoid race conditions.
80   const listener = new ProgressListener(webProgress, {
81     resolveWhenStarted,
82     unloadTimeout,
83   });
84   const navigated = listener.start();
86   // Right after a browsing context has been attached it could happen that
87   // no window global has been set yet. Consider this as nothing has been
88   // loaded yet.
89   let isInitial = true;
90   if (browsingContext.currentWindowGlobal) {
91     isInitial = browsingContext.currentWindowGlobal.isInitialDocument;
92   }
94   // If the current document is not the initial "about:blank" and is also
95   // no longer loading, assume the navigation is done and return.
96   if (!isInitial && !listener.isLoadingDocument) {
97     lazy.logger.trace(
98       lazy.truncate`[${browsingContext.id}] Document already finished loading: ${browsingContext.currentURI?.spec}`
99     );
101     // Will resolve the navigated promise.
102     listener.stop();
103   }
105   await navigated;
107   return {
108     currentURI: listener.currentURI,
109     targetURI: listener.targetURI,
110   };
114  * WebProgressListener to observe for page loads.
115  */
116 export class ProgressListener {
117   #expectNavigation;
118   #resolveWhenStarted;
119   #unloadTimeout;
120   #waitForExplicitStart;
121   #webProgress;
123   #deferredNavigation;
124   #seenStartFlag;
125   #targetURI;
126   #unloadTimerId;
128   /**
129    * Create a new WebProgressListener instance.
130    *
131    * @param {WebProgress} webProgress
132    *     The web progress to attach the listener to.
133    * @param {object=} options
134    * @param {boolean=} options.expectNavigation
135    *     Flag to indicate that a navigation is guaranteed to happen.
136    *     When set to `true`, the ProgressListener will ignore options.unloadTimeout
137    *     and will only resolve when the expected navigation happens.
138    *     Defaults to `false`.
139    * @param {boolean=} options.resolveWhenStarted
140    *     Flag to indicate that the Promise has to be resolved when the
141    *     page load has been started. Otherwise wait until the page has
142    *     finished loading. Defaults to `false`.
143    * @param {number=} options.unloadTimeout
144    *     Time to allow before the page gets unloaded. Defaults to 200ms on
145    *     regular platforms. A multiplier will be applied on slower platforms
146    *     (eg. debug, ccov...).
147    *     Ignored if options.expectNavigation is set to `true`
148    * @param {boolean=} options.waitForExplicitStart
149    *     Flag to indicate that the Promise can only resolve after receiving a
150    *     STATE_START state change. In other words, if the webProgress is already
151    *     navigating, the Promise will only resolve for the next navigation.
152    *     Defaults to `false`.
153    */
154   constructor(webProgress, options = {}) {
155     const {
156       expectNavigation = false,
157       resolveWhenStarted = false,
158       unloadTimeout = DEFAULT_UNLOAD_TIMEOUT,
159       waitForExplicitStart = false,
160     } = options;
162     this.#expectNavigation = expectNavigation;
163     this.#resolveWhenStarted = resolveWhenStarted;
164     this.#unloadTimeout = unloadTimeout * lazy.UNLOAD_TIMEOUT_MULTIPLIER;
165     this.#waitForExplicitStart = waitForExplicitStart;
166     this.#webProgress = webProgress;
168     this.#deferredNavigation = null;
169     this.#seenStartFlag = false;
170     this.#targetURI = null;
171     this.#unloadTimerId = null;
172   }
174   get #messagePrefix() {
175     return `[${this.browsingContext.id}] ${this.constructor.name}`;
176   }
178   get browsingContext() {
179     return this.#webProgress.browsingContext;
180   }
182   get currentURI() {
183     return this.#webProgress.browsingContext.currentURI;
184   }
186   get isLoadingDocument() {
187     return this.#webProgress.isLoadingDocument;
188   }
190   get isStarted() {
191     return !!this.#deferredNavigation;
192   }
194   get targetURI() {
195     return this.#targetURI;
196   }
198   #checkLoadingState(request, options = {}) {
199     const { isStart = false, isStop = false, status = 0 } = options;
201     this.#trace(`Check loading state: isStart=${isStart} isStop=${isStop}`);
202     if (isStart && !this.#seenStartFlag) {
203       this.#seenStartFlag = true;
205       this.#targetURI = this.#getTargetURI(request);
207       this.#trace(`state=start: ${this.targetURI?.spec}`);
209       if (this.#unloadTimerId !== null) {
210         lazy.clearTimeout(this.#unloadTimerId);
211         this.#trace("Cleared the unload timer");
212         this.#unloadTimerId = null;
213       }
215       if (this.#resolveWhenStarted) {
216         this.#trace("Request to stop listening when navigation started");
217         this.stop();
218         return;
219       }
220     }
222     if (isStop && this.#seenStartFlag) {
223       // Treat NS_ERROR_PARSED_DATA_CACHED as a success code
224       // since navigation happened and content has been loaded.
225       if (
226         !Components.isSuccessCode(status) &&
227         status != Cr.NS_ERROR_PARSED_DATA_CACHED
228       ) {
229         if (
230           status == Cr.NS_BINDING_ABORTED &&
231           this.browsingContext.currentWindowGlobal.isInitialDocument
232         ) {
233           this.#trace(
234             "Ignore aborted navigation error to the initial document, real document will be loaded."
235           );
236           return;
237         }
239         // The navigation request caused an error.
240         const errorName = ChromeUtils.getXPCOMErrorName(status);
241         this.#trace(
242           `state=stop: error=0x${status.toString(16)} (${errorName})`
243         );
244         this.stop({ error: new Error(errorName) });
245         return;
246       }
248       this.#trace(`state=stop: ${this.currentURI.spec}`);
250       // If a non initial page finished loading the navigation is done.
251       if (!this.browsingContext.currentWindowGlobal.isInitialDocument) {
252         this.stop();
253         return;
254       }
256       // Otherwise wait for a potential additional page load.
257       this.#trace(
258         "Initial document loaded. Wait for a potential further navigation."
259       );
260       this.#seenStartFlag = false;
261       this.#setUnloadTimer();
262     }
263   }
265   #getTargetURI(request) {
266     try {
267       return request.QueryInterface(Ci.nsIChannel).originalURI;
268     } catch (e) {}
270     return null;
271   }
273   #setUnloadTimer() {
274     if (this.#expectNavigation) {
275       this.#trace("Skip setting the unload timer");
276     } else {
277       this.#trace(`Setting unload timer (${this.#unloadTimeout}ms)`);
279       this.#unloadTimerId = lazy.setTimeout(() => {
280         this.#trace(`No navigation detected: ${this.currentURI?.spec}`);
281         // Assume the target is the currently loaded URI.
282         this.#targetURI = this.currentURI;
283         this.stop();
284       }, this.#unloadTimeout);
285     }
286   }
288   #trace(message) {
289     lazy.logger.trace(lazy.truncate`${this.#messagePrefix} ${message}`);
290   }
292   onStateChange(progress, request, flag, status) {
293     this.#checkLoadingState(request, {
294       isStart: flag & Ci.nsIWebProgressListener.STATE_START,
295       isStop: flag & Ci.nsIWebProgressListener.STATE_STOP,
296       status,
297     });
298   }
300   onLocationChange(progress, request, location, flag) {
301     // If an error page has been loaded abort the navigation.
302     if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
303       this.#trace(`location=errorPage: ${location.spec}`);
304       this.stop({ error: new Error("Address restricted") });
305       return;
306     }
308     // If location has changed in the same document the navigation is done.
309     if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
310       this.#targetURI = location;
311       this.#trace(`location=sameDocument: ${this.targetURI?.spec}`);
312       this.stop();
313     }
314   }
316   /**
317    * Start observing web progress changes.
318    *
319    * @returns {Promise}
320    *     A promise that will resolve when the navigation has been finished.
321    */
322   start() {
323     if (this.#deferredNavigation) {
324       throw new Error(`Progress listener already started`);
325     }
327     this.#trace(
328       `Start: expectNavigation=${this.#expectNavigation} resolveWhenStarted=${
329         this.#resolveWhenStarted
330       } unloadTimeout=${this.#unloadTimeout} waitForExplicitStart=${
331         this.#waitForExplicitStart
332       }`
333     );
335     if (this.#webProgress.isLoadingDocument) {
336       this.#targetURI = this.#getTargetURI(this.#webProgress.documentRequest);
337       this.#trace(`Document already loading ${this.#targetURI?.spec}`);
339       if (this.#resolveWhenStarted && !this.#waitForExplicitStart) {
340         this.#trace(
341           "Resolve on document loading if not waiting for a load or a new navigation"
342         );
343         return Promise.resolve();
344       }
345     }
347     this.#deferredNavigation = new lazy.Deferred();
349     // Enable all location change and state notifications to get informed about an upcoming load
350     // as early as possible.
351     this.#webProgress.addProgressListener(
352       this,
353       Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_ALL
354     );
356     webProgressListeners.add(this);
358     if (this.#webProgress.isLoadingDocument && !this.#waitForExplicitStart) {
359       this.#checkLoadingState(this.#webProgress.documentRequest, {
360         isStart: true,
361       });
362     } else {
363       // If the document is not loading yet wait some time for the navigation
364       // to be started.
365       this.#setUnloadTimer();
366     }
368     return this.#deferredNavigation.promise;
369   }
371   /**
372    * Stop observing web progress changes.
373    *
374    * @param {object=} options
375    * @param {Error=} options.error
376    *     If specified the navigation promise will be rejected with this error.
377    */
378   stop(options = {}) {
379     const { error } = options;
381     this.#trace(`Stop: has error=${!!error}`);
383     if (!this.#deferredNavigation) {
384       throw new Error("Progress listener not yet started");
385     }
387     lazy.clearTimeout(this.#unloadTimerId);
388     this.#unloadTimerId = null;
390     this.#webProgress.removeProgressListener(
391       this,
392       Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_ALL
393     );
394     webProgressListeners.delete(this);
396     if (!this.#targetURI) {
397       // If no target URI has been set yet it should be the current URI
398       this.#targetURI = this.browsingContext.currentURI;
399     }
401     if (error) {
402       this.#deferredNavigation.reject(error);
403     } else {
404       this.#deferredNavigation.resolve();
405     }
407     this.#deferredNavigation = null;
408   }
410   /**
411    * Stop the progress listener if and only if we already detected a navigation
412    * start.
413    *
414    * @param {object=} options
415    * @param {Error=} options.error
416    *     If specified the navigation promise will be rejected with this error.
417    */
418   stopIfStarted(options) {
419     this.#trace(`Stop if started: seenStartFlag=${this.#seenStartFlag}`);
420     if (this.#seenStartFlag) {
421       this.stop(options);
422     }
423   }
425   toString() {
426     return `[object ${this.constructor.name}]`;
427   }
429   get QueryInterface() {
430     return ChromeUtils.generateQI([
431       "nsIWebProgressListener",
432       "nsISupportsWeakReference",
433     ]);
434   }