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";
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",
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.
32 if (AppConstants.ASAN || AppConstants.DEBUG || AppConstants.TSAN) {
33 // Use an extended timeout on slow platforms.
40 export const DEFAULT_UNLOAD_TIMEOUT = 200;
43 * Returns the multiplier used for the unload timer. Useful for tests which
44 * assert the behavior of this timeout.
46 export function getUnloadTimeoutMultiplier() {
47 return lazy.UNLOAD_TIMEOUT_MULTIPLIER;
50 // Used to keep weak references of webProgressListeners alive.
51 const webProgressListeners = new Set();
54 * Wait until the initial load of the given WebProgress is done.
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.
66 * Promise which resolves when the page load is in the expected state.
68 * - {nsIURI} currentURI The current URI of the page
69 * - {nsIURI} targetURI Target URI of the navigation
71 export async function waitForInitialNavigationCompleted(
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, {
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
90 if (browsingContext.currentWindowGlobal) {
91 isInitial = browsingContext.currentWindowGlobal.isInitialDocument;
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) {
98 lazy.truncate`[${browsingContext.id}] Document already finished loading: ${browsingContext.currentURI?.spec}`
101 // Will resolve the navigated promise.
108 currentURI: listener.currentURI,
109 targetURI: listener.targetURI,
114 * WebProgressListener to observe for page loads.
116 export class ProgressListener {
120 #waitForExplicitStart;
129 * Create a new WebProgressListener instance.
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`.
154 constructor(webProgress, options = {}) {
156 expectNavigation = false,
157 resolveWhenStarted = false,
158 unloadTimeout = DEFAULT_UNLOAD_TIMEOUT,
159 waitForExplicitStart = false,
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;
174 get #messagePrefix() {
175 return `[${this.browsingContext.id}] ${this.constructor.name}`;
178 get browsingContext() {
179 return this.#webProgress.browsingContext;
183 return this.#webProgress.browsingContext.currentURI;
186 get isLoadingDocument() {
187 return this.#webProgress.isLoadingDocument;
191 return !!this.#deferredNavigation;
195 return this.#targetURI;
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;
215 if (this.#resolveWhenStarted) {
216 this.#trace("Request to stop listening when navigation started");
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.
226 !Components.isSuccessCode(status) &&
227 status != Cr.NS_ERROR_PARSED_DATA_CACHED
230 status == Cr.NS_BINDING_ABORTED &&
231 this.browsingContext.currentWindowGlobal.isInitialDocument
234 "Ignore aborted navigation error to the initial document, real document will be loaded."
239 // The navigation request caused an error.
240 const errorName = ChromeUtils.getXPCOMErrorName(status);
242 `state=stop: error=0x${status.toString(16)} (${errorName})`
244 this.stop({ error: new Error(errorName) });
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) {
256 // Otherwise wait for a potential additional page load.
258 "Initial document loaded. Wait for a potential further navigation."
260 this.#seenStartFlag = false;
261 this.#setUnloadTimer();
265 #getTargetURI(request) {
267 return request.QueryInterface(Ci.nsIChannel).originalURI;
274 if (this.#expectNavigation) {
275 this.#trace("Skip setting the unload timer");
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;
284 }, this.#unloadTimeout);
289 lazy.logger.trace(lazy.truncate`${this.#messagePrefix} ${message}`);
292 onStateChange(progress, request, flag, status) {
293 this.#checkLoadingState(request, {
294 isStart: flag & Ci.nsIWebProgressListener.STATE_START,
295 isStop: flag & Ci.nsIWebProgressListener.STATE_STOP,
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") });
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}`);
317 * Start observing web progress changes.
320 * A promise that will resolve when the navigation has been finished.
323 if (this.#deferredNavigation) {
324 throw new Error(`Progress listener already started`);
328 `Start: expectNavigation=${this.#expectNavigation} resolveWhenStarted=${
329 this.#resolveWhenStarted
330 } unloadTimeout=${this.#unloadTimeout} waitForExplicitStart=${
331 this.#waitForExplicitStart
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) {
341 "Resolve on document loading if not waiting for a load or a new navigation"
343 return Promise.resolve();
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(
353 Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_ALL
356 webProgressListeners.add(this);
358 if (this.#webProgress.isLoadingDocument && !this.#waitForExplicitStart) {
359 this.#checkLoadingState(this.#webProgress.documentRequest, {
363 // If the document is not loading yet wait some time for the navigation
365 this.#setUnloadTimer();
368 return this.#deferredNavigation.promise;
372 * Stop observing web progress changes.
374 * @param {object=} options
375 * @param {Error=} options.error
376 * If specified the navigation promise will be rejected with this error.
379 const { error } = options;
381 this.#trace(`Stop: has error=${!!error}`);
383 if (!this.#deferredNavigation) {
384 throw new Error("Progress listener not yet started");
387 lazy.clearTimeout(this.#unloadTimerId);
388 this.#unloadTimerId = null;
390 this.#webProgress.removeProgressListener(
392 Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_ALL
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;
402 this.#deferredNavigation.reject(error);
404 this.#deferredNavigation.resolve();
407 this.#deferredNavigation = null;
411 * Stop the progress listener if and only if we already detected a navigation
414 * @param {object=} options
415 * @param {Error=} options.error
416 * If specified the navigation promise will be rejected with this error.
418 stopIfStarted(options) {
419 this.#trace(`Stop if started: seenStartFlag=${this.#seenStartFlag}`);
420 if (this.#seenStartFlag) {
426 return `[object ${this.constructor.name}]`;
429 get QueryInterface() {
430 return ChromeUtils.generateQI([
431 "nsIWebProgressListener",
432 "nsISupportsWeakReference",