summaryrefslogtreecommitdiffstats
path: root/browser/components/sessionstore/ContentSessionStore.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/sessionstore/ContentSessionStore.sys.mjs')
-rw-r--r--browser/components/sessionstore/ContentSessionStore.sys.mjs686
1 files changed, 686 insertions, 0 deletions
diff --git a/browser/components/sessionstore/ContentSessionStore.sys.mjs b/browser/components/sessionstore/ContentSessionStore.sys.mjs
new file mode 100644
index 0000000000..61aa911f0b
--- /dev/null
+++ b/browser/components/sessionstore/ContentSessionStore.sys.mjs
@@ -0,0 +1,686 @@
+/* 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
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import {
+ clearTimeout,
+ setTimeoutWithTarget,
+} from "resource://gre/modules/Timer.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContentRestore: "resource:///modules/sessionstore/ContentRestore.sys.mjs",
+ SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs",
+});
+
+// This pref controls whether or not we send updates to the parent on a timeout
+// or not, and should only be used for tests or debugging.
+const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates";
+
+const PREF_INTERVAL = "browser.sessionstore.interval";
+
+const kNoIndex = Number.MAX_SAFE_INTEGER;
+const kLastIndex = Number.MAX_SAFE_INTEGER - 1;
+
+class Handler {
+ constructor(store) {
+ this.store = store;
+ }
+
+ get contentRestore() {
+ return this.store.contentRestore;
+ }
+
+ get contentRestoreInitialized() {
+ return this.store.contentRestoreInitialized;
+ }
+
+ get mm() {
+ return this.store.mm;
+ }
+
+ get messageQueue() {
+ return this.store.messageQueue;
+ }
+}
+
+/**
+ * Listens for and handles content events that we need for the
+ * session store service to be notified of state changes in content.
+ */
+class EventListener extends Handler {
+ constructor(store) {
+ super(store);
+
+ SessionStoreUtils.addDynamicFrameFilteredListener(
+ this.mm,
+ "load",
+ this,
+ true
+ );
+ }
+
+ handleEvent(event) {
+ let { content } = this.mm;
+
+ // Ignore load events from subframes.
+ if (event.target != content.document) {
+ return;
+ }
+
+ if (content.document.documentURI.startsWith("about:reader")) {
+ if (
+ event.type == "load" &&
+ !content.document.body.classList.contains("loaded")
+ ) {
+ // Don't restore the scroll position of an about:reader page at this
+ // point; listen for the custom event dispatched from AboutReader.sys.mjs.
+ content.addEventListener("AboutReaderContentReady", this);
+ return;
+ }
+
+ content.removeEventListener("AboutReaderContentReady", this);
+ }
+
+ if (this.contentRestoreInitialized) {
+ // Restore the form data and scroll position.
+ this.contentRestore.restoreDocument();
+ }
+ }
+}
+
+/**
+ * Listens for changes to the session history. Whenever the user navigates
+ * we will collect URLs and everything belonging to session history.
+ *
+ * Causes a SessionStore:update message to be sent that contains the current
+ * session history.
+ *
+ * Example:
+ * {entries: [{url: "about:mozilla", ...}, ...], index: 1}
+ */
+class SessionHistoryListener extends Handler {
+ constructor(store) {
+ super(store);
+
+ this._fromIdx = kNoIndex;
+
+ // By adding the SHistoryListener immediately, we will unfortunately be
+ // notified of every history entry as the tab is restored. We don't bother
+ // waiting to add the listener later because these notifications are cheap.
+ // We will likely only collect once since we are batching collection on
+ // a delay.
+ this.mm.docShell
+ .QueryInterface(Ci.nsIWebNavigation)
+ .sessionHistory.legacySHistory.addSHistoryListener(this); // OK in non-geckoview
+
+ let webProgress = this.mm.docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+
+ webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+
+ // Collect data if we start with a non-empty shistory.
+ if (!lazy.SessionHistory.isEmpty(this.mm.docShell)) {
+ this.collect();
+ // When a tab is detached from the window, for the new window there is a
+ // new SessionHistoryListener created. Normally it is empty at this point
+ // but in a test env. the initial about:blank might have a children in which
+ // case we fire off a history message here with about:blank in it. If we
+ // don't do it ASAP then there is going to be a browser swap and the parent
+ // will be all confused by that message.
+ this.store.messageQueue.send();
+ }
+
+ // Listen for page title changes.
+ this.mm.addEventListener("DOMTitleChanged", this);
+ }
+
+ get mm() {
+ return this.store.mm;
+ }
+
+ uninit() {
+ let sessionHistory = this.mm.docShell.QueryInterface(
+ Ci.nsIWebNavigation
+ ).sessionHistory;
+ if (sessionHistory) {
+ sessionHistory.legacySHistory.removeSHistoryListener(this); // OK in non-geckoview
+ }
+ }
+
+ collect() {
+ // We want to send down a historychange even for full collects in case our
+ // session history is a partial session history, in which case we don't have
+ // enough information for a full update. collectFrom(-1) tells the collect
+ // function to collect all data avaliable in this process.
+ if (this.mm.docShell) {
+ this.collectFrom(-1);
+ }
+ }
+
+ // History can grow relatively big with the nested elements, so if we don't have to, we
+ // don't want to send the entire history all the time. For a simple optimization
+ // we keep track of the smallest index from after any change has occured and we just send
+ // the elements from that index. If something more complicated happens we just clear it
+ // and send the entire history. We always send the additional info like the current selected
+ // index (so for going back and forth between history entries we set the index to kLastIndex
+ // if nothing else changed send an empty array and the additonal info like the selected index)
+ collectFrom(idx) {
+ if (this._fromIdx <= idx) {
+ // If we already know that we need to update history fromn index N we can ignore any changes
+ // tha happened with an element with index larger than N.
+ // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which means we don't ignore anything
+ // here, and in case of navigation in the history back and forth we use kLastIndex which ignores
+ // only the subsequent navigations, but not any new elements added.
+ return;
+ }
+
+ this._fromIdx = idx;
+ this.store.messageQueue.push("historychange", () => {
+ if (this._fromIdx === kNoIndex) {
+ return null;
+ }
+
+ let history = lazy.SessionHistory.collect(
+ this.mm.docShell,
+ this._fromIdx
+ );
+ this._fromIdx = kNoIndex;
+ return history;
+ });
+ }
+
+ handleEvent(event) {
+ this.collect();
+ }
+
+ OnHistoryNewEntry(newURI, oldIndex) {
+ // Collect the current entry as well, to make sure to collect any changes
+ // that were made to the entry while the document was active.
+ this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1);
+ }
+
+ OnHistoryGotoIndex() {
+ // We ought to collect the previously current entry as well, see bug 1350567.
+ this.collectFrom(kLastIndex);
+ }
+
+ OnHistoryPurge() {
+ this.collect();
+ }
+
+ OnHistoryReload() {
+ this.collect();
+ return true;
+ }
+
+ OnHistoryReplaceEntry() {
+ this.collect();
+ }
+
+ /**
+ * @see nsIWebProgressListener.onStateChange
+ */
+ onStateChange(webProgress, request, stateFlags, status) {
+ // Ignore state changes for subframes because we're only interested in the
+ // top-document starting or stopping its load.
+ if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) {
+ return;
+ }
+
+ // onStateChange will be fired when loading the initial about:blank URI for
+ // a browser, which we don't actually care about. This is particularly for
+ // the case of unrestored background tabs, where the content has not yet
+ // been restored: we don't want to accidentally send any updates to the
+ // parent when the about:blank placeholder page has loaded.
+ if (!this.mm.docShell.hasLoadedNonBlankURI) {
+ return;
+ }
+
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ this.collect();
+ } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ this.collect();
+ }
+ }
+}
+SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISHistoryListener",
+ "nsISupportsWeakReference",
+]);
+
+/**
+ * A message queue that takes collected data and will take care of sending it
+ * to the chrome process. It allows flushing using synchronous messages and
+ * takes care of any race conditions that might occur because of that. Changes
+ * will be batched if they're pushed in quick succession to avoid a message
+ * flood.
+ */
+class MessageQueue extends Handler {
+ constructor(store) {
+ super(store);
+
+ /**
+ * A map (string -> lazy fn) holding lazy closures of all queued data
+ * collection routines. These functions will return data collected from the
+ * docShell.
+ */
+ this._data = new Map();
+
+ /**
+ * The delay (in ms) used to delay sending changes after data has been
+ * invalidated.
+ */
+ this.BATCH_DELAY_MS = 1000;
+
+ /**
+ * The minimum idle period (in ms) we need for sending data to chrome process.
+ */
+ this.NEEDED_IDLE_PERIOD_MS = 5;
+
+ /**
+ * Timeout for waiting an idle period to send data. We will set this from
+ * the pref "browser.sessionstore.interval".
+ */
+ this._timeoutWaitIdlePeriodMs = null;
+
+ /**
+ * The current timeout ID, null if there is no queue data. We use timeouts
+ * to damp a flood of data changes and send lots of changes as one batch.
+ */
+ this._timeout = null;
+
+ /**
+ * Whether or not sending batched messages on a timer is disabled. This should
+ * only be used for debugging or testing. If you need to access this value,
+ * you should probably use the timeoutDisabled getter.
+ */
+ this._timeoutDisabled = false;
+
+ /**
+ * True if there is already a send pending idle dispatch, set to prevent
+ * scheduling more than one. If false there may or may not be one scheduled.
+ */
+ this._idleScheduled = false;
+
+ this.timeoutDisabled = Services.prefs.getBoolPref(TIMEOUT_DISABLED_PREF);
+ this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(PREF_INTERVAL);
+
+ Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this);
+ Services.prefs.addObserver(PREF_INTERVAL, this);
+ }
+
+ /**
+ * True if batched messages are not being fired on a timer. This should only
+ * ever be true when debugging or during tests.
+ */
+ get timeoutDisabled() {
+ return this._timeoutDisabled;
+ }
+
+ /**
+ * Disables sending batched messages on a timer. Also cancels any pending
+ * timers.
+ */
+ set timeoutDisabled(val) {
+ this._timeoutDisabled = val;
+
+ if (val && this._timeout) {
+ clearTimeout(this._timeout);
+ this._timeout = null;
+ }
+ }
+
+ uninit() {
+ Services.prefs.removeObserver(TIMEOUT_DISABLED_PREF, this);
+ Services.prefs.removeObserver(PREF_INTERVAL, this);
+ this.cleanupTimers();
+ }
+
+ /**
+ * Cleanup pending idle callback and timer.
+ */
+ cleanupTimers() {
+ this._idleScheduled = false;
+ if (this._timeout) {
+ clearTimeout(this._timeout);
+ this._timeout = null;
+ }
+ }
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ switch (data) {
+ case TIMEOUT_DISABLED_PREF:
+ this.timeoutDisabled = Services.prefs.getBoolPref(
+ TIMEOUT_DISABLED_PREF
+ );
+ break;
+ case PREF_INTERVAL:
+ this._timeoutWaitIdlePeriodMs =
+ Services.prefs.getIntPref(PREF_INTERVAL);
+ break;
+ default:
+ console.error("received unknown message '" + data + "'");
+ break;
+ }
+ }
+ }
+
+ /**
+ * Pushes a given |value| onto the queue. The given |key| represents the type
+ * of data that is stored and can override data that has been queued before
+ * but has not been sent to the parent process, yet.
+ *
+ * @param key (string)
+ * A unique identifier specific to the type of data this is passed.
+ * @param fn (function)
+ * A function that returns the value that will be sent to the parent
+ * process.
+ */
+ push(key, fn) {
+ this._data.set(key, fn);
+
+ if (!this._timeout && !this._timeoutDisabled) {
+ // Wait a little before sending the message to batch multiple changes.
+ this._timeout = setTimeoutWithTarget(
+ () => this.sendWhenIdle(),
+ this.BATCH_DELAY_MS,
+ this.mm.tabEventTarget
+ );
+ }
+ }
+
+ /**
+ * Sends queued data when the remaining idle time is enough or waiting too
+ * long; otherwise, request an idle time again. If the |deadline| is not
+ * given, this function is going to schedule the first request.
+ *
+ * @param deadline (object)
+ * An IdleDeadline object passed by idleDispatch().
+ */
+ sendWhenIdle(deadline) {
+ if (!this.mm.content) {
+ // The frameloader is being torn down. Nothing more to do.
+ return;
+ }
+
+ if (deadline) {
+ if (
+ deadline.didTimeout ||
+ deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS
+ ) {
+ this.send();
+ return;
+ }
+ } else if (this._idleScheduled) {
+ // Bail out if there's a pending run.
+ return;
+ }
+ ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), {
+ timeout: this._timeoutWaitIdlePeriodMs,
+ });
+ this._idleScheduled = true;
+ }
+
+ /**
+ * Sends queued data to the chrome process.
+ *
+ * @param options (object)
+ * {flushID: 123} to specify that this is a flush
+ * {isFinal: true} to signal this is the final message sent on unload
+ */
+ send(options = {}) {
+ // Looks like we have been called off a timeout after the tab has been
+ // closed. The docShell is gone now and we can just return here as there
+ // is nothing to do.
+ if (!this.mm.docShell) {
+ return;
+ }
+
+ this.cleanupTimers();
+
+ let flushID = (options && options.flushID) || 0;
+ let histID = "FX_SESSION_RESTORE_CONTENT_COLLECT_DATA_MS";
+
+ let data = {};
+ for (let [key, func] of this._data) {
+ if (key != "isPrivate") {
+ TelemetryStopwatch.startKeyed(histID, key);
+ }
+
+ let value = func();
+
+ if (key != "isPrivate") {
+ TelemetryStopwatch.finishKeyed(histID, key);
+ }
+
+ if (value || (key != "storagechange" && key != "historychange")) {
+ data[key] = value;
+ }
+ }
+
+ this._data.clear();
+
+ try {
+ // Send all data to the parent process.
+ this.mm.sendAsyncMessage("SessionStore:update", {
+ data,
+ flushID,
+ isFinal: options.isFinal || false,
+ epoch: this.store.epoch,
+ });
+ } catch (ex) {
+ if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) {
+ Services.telemetry
+ .getHistogramById("FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM")
+ .add(1);
+ this.mm.sendAsyncMessage("SessionStore:error");
+ }
+ }
+ }
+}
+
+/**
+ * Listens for and handles messages sent by the session store service.
+ */
+const MESSAGES = [
+ "SessionStore:restoreHistory",
+ "SessionStore:restoreTabContent",
+ "SessionStore:resetRestore",
+ "SessionStore:flush",
+ "SessionStore:prepareForProcessChange",
+];
+
+export class ContentSessionStore {
+ constructor(mm) {
+ if (Services.appinfo.sessionHistoryInParent) {
+ throw new Error("This frame script should not be loaded for SHIP");
+ }
+
+ this.mm = mm;
+ this.messageQueue = new MessageQueue(this);
+
+ this.epoch = 0;
+
+ this.contentRestoreInitialized = false;
+
+ this.handlers = [
+ this.messageQueue,
+ new EventListener(this),
+ new SessionHistoryListener(this),
+ ];
+
+ XPCOMUtils.defineLazyGetter(this, "contentRestore", () => {
+ this.contentRestoreInitialized = true;
+ return new lazy.ContentRestore(mm);
+ });
+
+ MESSAGES.forEach(m => mm.addMessageListener(m, this));
+
+ mm.addEventListener("unload", this);
+ }
+
+ receiveMessage({ name, data }) {
+ // The docShell might be gone. Don't process messages,
+ // that will just lead to errors anyway.
+ if (!this.mm.docShell) {
+ return;
+ }
+
+ // A fresh tab always starts with epoch=0. The parent has the ability to
+ // override that to signal a new era in this tab's life. This enables it
+ // to ignore async messages that were already sent but not yet received
+ // and would otherwise confuse the internal tab state.
+ if (data && data.epoch && data.epoch != this.epoch) {
+ this.epoch = data.epoch;
+ }
+
+ switch (name) {
+ case "SessionStore:restoreHistory":
+ this.restoreHistory(data);
+ break;
+ case "SessionStore:restoreTabContent":
+ this.restoreTabContent(data);
+ break;
+ case "SessionStore:resetRestore":
+ this.contentRestore.resetRestore();
+ break;
+ case "SessionStore:flush":
+ this.flush(data);
+ break;
+ case "SessionStore:prepareForProcessChange":
+ // During normal in-process navigations, the DocShell would take
+ // care of automatically persisting layout history state to record
+ // scroll positions on the nsSHEntry. Unfortunately, process switching
+ // is not a normal navigation, so for now we do this ourselves. This
+ // is a workaround until session history state finally lives in the
+ // parent process.
+ this.mm.docShell.persistLayoutHistoryState();
+ break;
+ default:
+ console.error("received unknown message '" + name + "'");
+ break;
+ }
+ }
+
+ // non-SHIP only
+ restoreHistory(data) {
+ let { epoch, tabData, loadArguments, isRemotenessUpdate } = data;
+
+ this.contentRestore.restoreHistory(tabData, loadArguments, {
+ // Note: The callbacks passed here will only be used when a load starts
+ // that was not initiated by sessionstore itself. This can happen when
+ // some code calls browser.loadURI() or browser.reload() on a pending
+ // browser/tab.
+
+ onLoadStarted: () => {
+ // Notify the parent that the tab is no longer pending.
+ this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", {
+ epoch,
+ });
+ },
+
+ onLoadFinished: () => {
+ // Tell SessionStore.sys.mjs that it may want to restore some more tabs,
+ // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time.
+ this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", {
+ epoch,
+ });
+ },
+ });
+
+ if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT) {
+ // For non-remote tabs, when restoreHistory finishes, we send a synchronous
+ // message to SessionStore.sys.mjs so that it can run SSTabRestoring. Users of
+ // SSTabRestoring seem to get confused if chrome and content are out of
+ // sync about the state of the restore (particularly regarding
+ // docShell.currentURI). Using a synchronous message is the easiest way
+ // to temporarily synchronize them.
+ //
+ // For remote tabs, because all nsIWebProgress notifications are sent
+ // asynchronously using messages, we get the same-order guarantees of the
+ // message manager, and can use an async message.
+ this.mm.sendSyncMessage("SessionStore:restoreHistoryComplete", {
+ epoch,
+ isRemotenessUpdate,
+ });
+ } else {
+ this.mm.sendAsyncMessage("SessionStore:restoreHistoryComplete", {
+ epoch,
+ isRemotenessUpdate,
+ });
+ }
+ }
+
+ restoreTabContent({ loadArguments, isRemotenessUpdate, reason }) {
+ let epoch = this.epoch;
+
+ // We need to pass the value of didStartLoad back to SessionStore.sys.mjs.
+ let didStartLoad = this.contentRestore.restoreTabContent(
+ loadArguments,
+ isRemotenessUpdate,
+ () => {
+ // Tell SessionStore.sys.mjs that it may want to restore some more tabs,
+ // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time.
+ this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", {
+ epoch,
+ isRemotenessUpdate,
+ });
+ }
+ );
+
+ this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", {
+ epoch,
+ isRemotenessUpdate,
+ reason,
+ });
+
+ if (!didStartLoad) {
+ // Pretend that the load succeeded so that event handlers fire correctly.
+ this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", {
+ epoch,
+ isRemotenessUpdate,
+ });
+ }
+ }
+
+ flush({ id }) {
+ // Flush the message queue, send the latest updates.
+ this.messageQueue.send({ flushID: id });
+ }
+
+ handleEvent(event) {
+ if (event.type == "unload") {
+ this.onUnload();
+ }
+ }
+
+ onUnload() {
+ // Upon frameLoader destruction, send a final update message to
+ // the parent and flush all data currently held in the child.
+ this.messageQueue.send({ isFinal: true });
+
+ for (let handler of this.handlers) {
+ if (handler.uninit) {
+ handler.uninit();
+ }
+ }
+
+ if (this.contentRestoreInitialized) {
+ // Remove progress listeners.
+ this.contentRestore.resetRestore();
+ }
+
+ // We don't need to take care of any StateChangeNotifier observers as they
+ // will die with the content script. The same goes for the privacy transition
+ // observer that will die with the docShell when the tab is closed.
+ }
+}