Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowsingContextListener:
"chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs",
generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
Log: "chrome://remote/content/shared/Log.sys.mjs",
PromptListener:
"chrome://remote/content/shared/listeners/PromptListener.sys.mjs",
registerNavigationListenerActor:
"chrome://remote/content/shared/js-window-actors/NavigationListenerActor.sys.mjs",
TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
truncate: "chrome://remote/content/shared/Format.sys.mjs",
unregisterNavigationListenerActor:
"chrome://remote/content/shared/js-window-actors/NavigationListenerActor.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
/**
* @typedef {object} BrowsingContextDetails
* @property {string} browsingContextId - The browsing context id.
* @property {string} browserId - The id of the Browser owning the browsing
* context.
* @property {BrowsingContext=} context - The BrowsingContext itself, if
* available.
* @property {boolean} isTopBrowsingContext - Whether the browsing context is
* top level.
*/
/**
* @typedef {object} NavigationInfo
* @property {'initial-about-blank'|'registered'|'started'|'finished'} state - The navigation state.
* @property {string} navigationId - The UUID for the navigation.
* @property {string} navigable - The UUID for the navigable.
* @property {string} url - The target url for the navigation.
*/
/**
* The NavigationRegistry is responsible for monitoring all navigations happening
* in the browser.
*
* It relies on a JSWindowActor pair called NavigationListener{Parent|Child},
* found under remote/shared/js-window-actors. As a simple overview, the
* NavigationListenerChild will monitor navigations in all window globals using
* content process WebProgressListener, and will forward each relevant update to
* the NavigationListenerParent
*
* The NavigationRegistry singleton holds the map of navigations, from navigable
* to NavigationInfo. It will also be called by NavigationListenerParent
* whenever a navigation event happens.
*
* This singleton is not exported outside of this class, and consumers instead
* need to use the NavigationManager class. The NavigationRegistry keeps track
* of how many NavigationListener instances are currently listening in order to
* know if the NavigationListenerActor should be registered or not.
*
* The NavigationRegistry exposes an API to retrieve the current or last
* navigation for a given navigable, and also forwards events to notify about
* navigation updates to individual NavigationManager instances.
*
* @class NavigationRegistry
*/
class NavigationRegistry extends EventEmitter {
#contextListener;
#managers;
#navigations;
#promptListener;
constructor() {
super();
// Set of NavigationManager instances currently used.
this.#managers = new Set();
// Maps navigable id to NavigationInfo.
this.#navigations = new Map();
this.#contextListener = new lazy.BrowsingContextListener();
this.#contextListener.on("attached", this.#onContextAttached);
this.#contextListener.on("discarded", this.#onContextDiscarded);
this.#promptListener = new lazy.PromptListener();
this.#promptListener.on("closed", this.#onPromptClosed);
this.#promptListener.on("opened", this.#onPromptOpened);
}
/**
* Retrieve the last known navigation data for a given browsing context.
*
* @param {BrowsingContext} context
* The browsing context for which the navigation event was recorded.
* @returns {NavigationInfo|null}
* The last known navigation data, or null.
*/
getNavigationForBrowsingContext(context) {
if (!lazy.TabManager.isValidCanonicalBrowsingContext(context)) {
// Bail out if the provided context is not a valid CanonicalBrowsingContext
// instance.
return null;
}
const navigableId = lazy.TabManager.getIdForBrowsingContext(context);
if (!this.#navigations.has(navigableId)) {
return null;
}
return this.#navigations.get(navigableId);
}
/**
* Start monitoring navigations in all browsing contexts. This will register
* the NavigationListener JSWindowActor and will initialize them in all
* existing browsing contexts.
*/
startMonitoring(listener) {
if (this.#managers.size == 0) {
lazy.registerNavigationListenerActor();
this.#contextListener.startListening();
this.#promptListener.startListening();
}
this.#managers.add(listener);
}
/**
* Stop monitoring navigations. This will unregister the NavigationListener
* JSWindowActor and clear the information collected about navigations so far.
*/
stopMonitoring(listener) {
if (!this.#managers.has(listener)) {
return;
}
this.#managers.delete(listener);
if (this.#managers.size == 0) {
this.#contextListener.stopListening();
this.#promptListener.stopListening();
lazy.unregisterNavigationListenerActor();
// Clear the map.
this.#navigations = new Map();
}
}
/**
* Called when a fragment navigation is recorded from the
* NavigationListener actors.
*
* This entry point is only intended to be called from
* NavigationListenerParent, to avoid setting up observers or listeners,
* which are unnecessary since NavigationManager has to be a singleton.
*
* @param {object} data
* @param {BrowsingContext} data.context
* The browsing context for which the navigation event was recorded.
* @param {string} data.url
* The URL as string for the navigation.
* @returns {NavigationInfo}
* The navigation created for this hash changed navigation.
*/
notifyFragmentNavigated(data) {
const { contextDetails, url } = data;
const context = this.#getContextFromContextDetails(contextDetails);
const navigableId = lazy.TabManager.getIdForBrowsingContext(context);
const navigationId = this.#getOrCreateNavigationId(navigableId);
const navigation = { state: "finished", navigationId, url };
// Update the current navigation for the navigable only if there is no
// ongoing navigation for the navigable.
const currentNavigation = this.#navigations.get(navigableId);
if (!currentNavigation || currentNavigation.state == "finished") {
this.#navigations.set(navigableId, navigation);
}
// Hash change navigations are immediately done, fire a single event.
this.emit("fragment-navigated", { navigationId, navigableId, url });
return navigation;
}
/**
* Called when a same-document navigation is recorded from the
* NavigationListener actors.
*
* This entry point is only intended to be called from
* NavigationListenerParent, to avoid setting up observers or listeners,
* which are unnecessary since NavigationManager has to be a singleton.
*
* @param {object} data
* @param {BrowsingContext} data.context
* The browsing context for which the navigation event was recorded.
* @param {string} data.url
* The URL as string for the navigation.
* @returns {NavigationInfo}
* The navigation created for this same-document navigation.
*/
notifySameDocumentChanged(data) {
const { contextDetails, url } = data;
const context = this.#getContextFromContextDetails(contextDetails);
const navigableId = lazy.TabManager.getIdForBrowsingContext(context);
const navigationId = this.#getOrCreateNavigationId(navigableId);
const navigation = { state: "finished", navigationId, url };
// Update the current navigation for the navigable only if there is no
// ongoing navigation for the navigable.
const currentNavigation = this.#navigations.get(navigableId);
if (!currentNavigation || currentNavigation.state == "finished") {
this.#navigations.set(navigableId, navigation);
}
// Same document navigations are immediately done, fire a single event.
this.emit("same-document-changed", { navigationId, navigableId, url });
return navigation;
}
/**
* Called when a navigation-failed event is recorded from the
* NavigationListener actors.
*
* This entry point is only intended to be called from
* NavigationListenerParent, to avoid setting up observers or listeners,
* which are unnecessary since NavigationManager has to be a singleton.
*
* @param {object} data
* @param {BrowsingContextDetails} data.contextDetails
* The details about the browsing context for this navigation.
* @param {string} data.errorName
* The error message.
* @param {string} data.url
* The URL as string for the navigation.
* @returns {NavigationInfo}
* The created navigation or the ongoing navigation, if applicable.
*/
notifyNavigationFailed(data) {
const { contextDetails, errorName, url } = data;
const context = this.#getContextFromContextDetails(contextDetails);
const navigableId = lazy.TabManager.getIdForBrowsingContext(context);
const navigation = this.#navigations.get(navigableId);
if (!navigation) {
lazy.logger.trace(
lazy.truncate`[${navigableId}] No navigation found to fail for url: ${url}`
);
return null;
}
if (navigation.state === "finished") {
lazy.logger.trace(
`[${navigableId}] Navigation already marked as finished, navigationId: ${navigation.navigationId}`
);
return navigation;
}
lazy.logger.trace(
lazy.truncate`[${navigableId}] Navigation failed for url: ${url} (${navigation.navigationId})`
);
navigation.state = "finished";
this.emit("navigation-failed", {
contextId: context.id,
errorName,
navigationId: navigation.navigationId,
navigableId,
url,
});
return navigation;
}
/**
* Called when a navigation-started event is recorded from the
* NavigationListener actors.
*
* This entry point is only intended to be called from
* NavigationListenerParent, to avoid setting up observers or listeners,
* which are unnecessary since NavigationManager has to be a singleton.
*
* @param {object} data
* @param {BrowsingContextDetails} data.contextDetails
* The details about the browsing context for this navigation.
* @param {string} data.url
* The URL as string for the navigation.
* @returns {NavigationInfo}
* The created navigation or the ongoing navigation, if applicable.
*/
notifyNavigationStarted(data) {
const { contextDetails, url } = data;
const context = this.#getContextFromContextDetails(contextDetails);
const navigableId = lazy.TabManager.getIdForBrowsingContext(context);
let navigation = this.#navigations.get(navigableId);
if (navigation) {
if (navigation.state === "started") {
// prompt being open, we can remove "!navigation.url" check.
if (!navigation.url || navigation.url === url) {
// If we are already monitoring a navigation for this navigable and the same url,
// for which we did not receive a navigation-stopped event, this navigation
// is already tracked and we don't want to create another id & event.
lazy.logger.trace(
`[${navigableId}] Skipping already tracked navigation, navigationId: ${navigation.navigationId}`
);
return navigation;
}
lazy.logger.trace(
`[${navigableId}] We're going to fail the navigation for url: ${navigation.url} (${navigation.navigationId}), ` +
"since it was interrupted by a new navigation."
);
// If there is already a navigation in progress but with a different url,
// it means that this navigation was interrupted by a new navigation.
// Note: ideally we should monitor this using NS_BINDING_ABORTED,
// but due to intermittent issues, when monitoring this in content processes,
// we can't reliable use it.
notifyNavigationFailed({
contextDetails,
errorName: "A new navigation interrupted an unfinished navigation",
url: navigation.url,
});
}
// We don't want to notify that navigation for "about:blank" (or "about:blank" with parameter)
// has started if it happens when the top-level browsing context is created.
if (
navigation.state === "initial-about-blank" &&
new URL(url).pathname == "blank"
) {
lazy.logger.trace(
`[${navigableId}] Skipping this navigation for url: ${navigation.url}, since it's an initial navigation.`
);
return navigation;
}
}
const navigationId = this.#getOrCreateNavigationId(navigableId);
navigation = { state: "started", navigationId, url };
this.#navigations.set(navigableId, navigation);
lazy.logger.trace(
lazy.truncate`[${navigableId}] Navigation started for url: ${url} (${navigationId})`
);
this.emit("navigation-started", { navigationId, navigableId, url });
return navigation;
}
/**
* Called when a navigation-stopped event is recorded from the
* NavigationListener actors.
*
* @param {object} data
* @param {BrowsingContextDetails} data.contextDetails
* The details about the browsing context for this navigation.
* @param {string} data.url
* The URL as string for the navigation.
* @returns {NavigationInfo}
* The stopped navigation if any, or null.
*/
notifyNavigationStopped(data) {
const { contextDetails, url } = data;
const context = this.#getContextFromContextDetails(contextDetails);
const navigableId = lazy.TabManager.getIdForBrowsingContext(context);
const navigation = this.#navigations.get(navigableId);
if (!navigation) {
lazy.logger.trace(
lazy.truncate`[${navigableId}] No navigation found to stop for url: ${url}`
);
return null;
}
if (navigation.state === "finished") {
lazy.logger.trace(
`[${navigableId}] Navigation already marked as finished, navigationId: ${navigation.navigationId}`
);
return navigation;
}
lazy.logger.trace(
lazy.truncate`[${navigableId}] Navigation finished for url: ${url} (${navigation.navigationId})`
);
navigation.state = "finished";
this.emit("navigation-stopped", {
navigationId: navigation.navigationId,
navigableId,
url,
});
return navigation;
}
/**
* Register a navigation id to be used for the next navigation for the
* provided browsing context details.
*
* @param {object} data
* @param {BrowsingContextDetails} data.contextDetails
* The details about the browsing context for this navigation.
* @returns {string}
* The UUID created the upcoming navigation.
*/
registerNavigationId(data) {
const { contextDetails } = data;
const context = this.#getContextFromContextDetails(contextDetails);
const navigableId = lazy.TabManager.getIdForBrowsingContext(context);
let navigation = this.#navigations.get(navigableId);
if (navigation && navigation.state === "started") {
lazy.logger.trace(
`[${navigableId}] We're going to fail the navigation for url: ${navigation.url} (${navigation.navigationId}), ` +
"since it was interrupted by a new navigation."
);
// If there is already a navigation in progress but with a different url,
// it means that this navigation was interrupted by a new navigation.
// Note: ideally we should monitor this using NS_BINDING_ABORTED,
// but due to intermittent issues, when monitoring this in content processes,
// we can't reliable use it.
notifyNavigationFailed({
contextDetails,
errorName: "A new navigation interrupted an unfinished navigation",
url: navigation.url,
});
}
const navigationId = lazy.generateUUID();
this.#navigations.set(navigableId, { state: "registered", navigationId });
return navigationId;
}
#getContextFromContextDetails(contextDetails) {
if (contextDetails.context) {
return contextDetails.context;
}
return contextDetails.isTopBrowsingContext
? BrowsingContext.getCurrentTopByBrowserId(contextDetails.browserId)
: BrowsingContext.get(contextDetails.browsingContextId);
}
#getOrCreateNavigationId(navigableId) {
const navigation = this.#navigations.get(navigableId);
if (navigation !== undefined && navigation.state === "registered") {
return navigation.navigationId;
}
return lazy.generateUUID();
}
#onContextAttached = async (eventName, data) => {
const { browsingContext, why } = data;
// We only care about top-level browsing contexts.
if (browsingContext.parent !== null) {
return;
}
// Filter out top-level browsing contexts that are created because of a
// cross-group navigation.
if (why === "replace") {
return;
}
const navigableId =
lazy.TabManager.getIdForBrowsingContext(browsingContext);
let navigation = this.#navigations.get(navigableId);
if (navigation) {
return;
}
const navigationId = this.#getOrCreateNavigationId(navigableId);
navigation = {
state: "initial-about-blank",
navigationId,
url: browsingContext.currentURI.displaySpec,
};
this.#navigations.set(navigableId, navigation);
};
#onContextDiscarded = async (eventName, data = {}) => {
const { browsingContext, why } = data;
// Filter out top-level browsing contexts that are destroyed because of a
// cross-group navigation.
if (why === "replace") {
return;
}
// for DevTools frames.
// Filter out notifications for chrome context until support gets
if (!browsingContext.webProgress) {
return;
}
const navigableId =
lazy.TabManager.getIdForBrowsingContext(browsingContext);
const navigation = this.#navigations.get(navigableId);
// No need to fail navigation, if there is no navigation in progress.
if (!navigation) {
return;
}
notifyNavigationFailed({
contextDetails: {
context: browsingContext,
},
errorName: "Browsing context got discarded",
url: navigation.url,
});
// If the navigable is discarded, we can safely clean up the navigation info.
this.#navigations.delete(navigableId);
};
#onPromptClosed = (eventName, data) => {
const { contentBrowser, detail } = data;
const { accepted, promptType } = detail;
// Send navigation failed event if beforeunload prompt was rejected.
if (promptType === "beforeunload" && accepted === false) {
const browsingContext = contentBrowser.browsingContext;
notifyNavigationFailed({
contextDetails: {
context: browsingContext,
},
errorName: "Beforeunload prompt was rejected",
});
}
};
#onPromptOpened = (eventName, data) => {
const { contentBrowser, prompt } = data;
const { promptType } = prompt;
// We should start the navigation when beforeunload prompt is open.
if (promptType === "beforeunload") {
const browsingContext = contentBrowser.browsingContext;
notifyNavigationStarted({
contextDetails: {
context: browsingContext,
},
});
}
};
}
// Create a private NavigationRegistry singleton.
const navigationRegistry = new NavigationRegistry();
/**
* See NavigationRegistry.notifyHashChanged.
*
* This entry point is only intended to be called from NavigationListenerParent,
* to avoid setting up observers or listeners, which are unnecessary since
* NavigationRegistry has to be a singleton.
*/
export function notifyFragmentNavigated(data) {
return navigationRegistry.notifyFragmentNavigated(data);
}
/**
* See NavigationRegistry.notifySameDocumentChanged.
*
* This entry point is only intended to be called from NavigationListenerParent,
* to avoid setting up observers or listeners, which are unnecessary since
* NavigationRegistry has to be a singleton.
*/
export function notifySameDocumentChanged(data) {
return navigationRegistry.notifySameDocumentChanged(data);
}
/**
* See NavigationRegistry.notifyNavigationFailed.
*
* This entry point is only intended to be called from NavigationListenerParent,
* to avoid setting up observers or listeners, which are unnecessary since
* NavigationRegistry has to be a singleton.
*/
export function notifyNavigationFailed(data) {
return navigationRegistry.notifyNavigationFailed(data);
}
/**
* See NavigationRegistry.notifyNavigationStarted.
*
* This entry point is only intended to be called from NavigationListenerParent,
* to avoid setting up observers or listeners, which are unnecessary since
* NavigationRegistry has to be a singleton.
*/
export function notifyNavigationStarted(data) {
return navigationRegistry.notifyNavigationStarted(data);
}
/**
* See NavigationRegistry.notifyNavigationStopped.
*
* This entry point is only intended to be called from NavigationListenerParent,
* to avoid setting up observers or listeners, which are unnecessary since
* NavigationRegistry has to be a singleton.
*/
export function notifyNavigationStopped(data) {
return navigationRegistry.notifyNavigationStopped(data);
}
export function registerNavigationId(data) {
return navigationRegistry.registerNavigationId(data);
}
/**
* The NavigationManager exposes the NavigationRegistry data via a class which
* needs to be individually instantiated by each consumer. This allow to track
* how many consumers need navigation data at any point so that the
* NavigationRegistry can register or unregister the underlying JSWindowActors
* correctly.
*
* @fires navigation-started
* The NavigationManager emits "navigation-started" when a new navigation is
* detected, with the following object as payload:
* - {string} navigationId - The UUID for the navigation.
* - {string} navigableId - The UUID for the navigable.
* - {string} url - The target url for the navigation.
* @fires navigation-stopped
* The NavigationManager emits "navigation-stopped" when a known navigation
* is stopped, with the following object as payload:
* - {string} navigationId - The UUID for the navigation.
* - {string} navigableId - The UUID for the navigable.
* - {string} url - The target url for the navigation.
*/
export class NavigationManager extends EventEmitter {
#monitoring;
constructor() {
super();
this.#monitoring = false;
}
destroy() {
this.stopMonitoring();
}
getNavigationForBrowsingContext(context) {
return navigationRegistry.getNavigationForBrowsingContext(context);
}
startMonitoring() {
if (this.#monitoring) {
return;
}
this.#monitoring = true;
navigationRegistry.startMonitoring(this);
navigationRegistry.on("fragment-navigated", this.#onNavigationEvent);
navigationRegistry.on("navigation-failed", this.#onNavigationEvent);
navigationRegistry.on("navigation-started", this.#onNavigationEvent);
navigationRegistry.on("navigation-stopped", this.#onNavigationEvent);
navigationRegistry.on("same-document-changed", this.#onNavigationEvent);
}
stopMonitoring() {
if (!this.#monitoring) {
return;
}
this.#monitoring = false;
navigationRegistry.stopMonitoring(this);
navigationRegistry.off("fragment-navigated", this.#onNavigationEvent);
navigationRegistry.off("navigation-failed", this.#onNavigationEvent);
navigationRegistry.off("navigation-started", this.#onNavigationEvent);
navigationRegistry.off("navigation-stopped", this.#onNavigationEvent);
navigationRegistry.off("same-document-changed", this.#onNavigationEvent);
}
#onNavigationEvent = (eventName, data) => {
this.emit(eventName, data);
};
}