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
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
8 } from "resource://gre/modules/Timer.sys.mjs";
12 ChromeUtils.defineESModuleGetters(lazy, {
13 ContentRestore: "resource:///modules/sessionstore/ContentRestore.sys.mjs",
14 SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs",
17 // This pref controls whether or not we send updates to the parent on a timeout
18 // or not, and should only be used for tests or debugging.
19 const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates";
21 const PREF_INTERVAL = "browser.sessionstore.interval";
23 const kNoIndex = Number.MAX_SAFE_INTEGER;
24 const kLastIndex = Number.MAX_SAFE_INTEGER - 1;
31 get contentRestore() {
32 return this.store.contentRestore;
35 get contentRestoreInitialized() {
36 return this.store.contentRestoreInitialized;
44 return this.store.messageQueue;
49 * Listens for and handles content events that we need for the
50 * session store service to be notified of state changes in content.
52 class EventListener extends Handler {
56 SessionStoreUtils.addDynamicFrameFilteredListener(
65 let { content } = this.mm;
67 // Ignore load events from subframes.
68 if (event.target != content.document) {
72 if (content.document.documentURI.startsWith("about:reader")) {
74 event.type == "load" &&
75 !content.document.body.classList.contains("loaded")
77 // Don't restore the scroll position of an about:reader page at this
78 // point; listen for the custom event dispatched from AboutReader.sys.mjs.
79 content.addEventListener("AboutReaderContentReady", this);
83 content.removeEventListener("AboutReaderContentReady", this);
86 if (this.contentRestoreInitialized) {
87 // Restore the form data and scroll position.
88 this.contentRestore.restoreDocument();
94 * Listens for changes to the session history. Whenever the user navigates
95 * we will collect URLs and everything belonging to session history.
97 * Causes a SessionStore:update message to be sent that contains the current
101 * {entries: [{url: "about:mozilla", ...}, ...], index: 1}
103 class SessionHistoryListener extends Handler {
107 this._fromIdx = kNoIndex;
109 // By adding the SHistoryListener immediately, we will unfortunately be
110 // notified of every history entry as the tab is restored. We don't bother
111 // waiting to add the listener later because these notifications are cheap.
112 // We will likely only collect once since we are batching collection on
115 .QueryInterface(Ci.nsIWebNavigation)
116 .sessionHistory.legacySHistory.addSHistoryListener(this); // OK in non-geckoview
118 let webProgress = this.mm.docShell
119 .QueryInterface(Ci.nsIInterfaceRequestor)
120 .getInterface(Ci.nsIWebProgress);
122 webProgress.addProgressListener(
124 Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
127 // Collect data if we start with a non-empty shistory.
128 if (!lazy.SessionHistory.isEmpty(this.mm.docShell)) {
130 // When a tab is detached from the window, for the new window there is a
131 // new SessionHistoryListener created. Normally it is empty at this point
132 // but in a test env. the initial about:blank might have a children in which
133 // case we fire off a history message here with about:blank in it. If we
134 // don't do it ASAP then there is going to be a browser swap and the parent
135 // will be all confused by that message.
136 this.store.messageQueue.send();
139 // Listen for page title changes.
140 this.mm.addEventListener("DOMTitleChanged", this);
144 return this.store.mm;
148 let sessionHistory = this.mm.docShell.QueryInterface(
151 if (sessionHistory) {
152 sessionHistory.legacySHistory.removeSHistoryListener(this); // OK in non-geckoview
157 // We want to send down a historychange even for full collects in case our
158 // session history is a partial session history, in which case we don't have
159 // enough information for a full update. collectFrom(-1) tells the collect
160 // function to collect all data avaliable in this process.
161 if (this.mm.docShell) {
162 this.collectFrom(-1);
166 // History can grow relatively big with the nested elements, so if we don't have to, we
167 // don't want to send the entire history all the time. For a simple optimization
168 // we keep track of the smallest index from after any change has occured and we just send
169 // the elements from that index. If something more complicated happens we just clear it
170 // and send the entire history. We always send the additional info like the current selected
171 // index (so for going back and forth between history entries we set the index to kLastIndex
172 // if nothing else changed send an empty array and the additonal info like the selected index)
174 if (this._fromIdx <= idx) {
175 // If we already know that we need to update history fromn index N we can ignore any changes
176 // tha happened with an element with index larger than N.
177 // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which means we don't ignore anything
178 // here, and in case of navigation in the history back and forth we use kLastIndex which ignores
179 // only the subsequent navigations, but not any new elements added.
184 this.store.messageQueue.push("historychange", () => {
185 if (this._fromIdx === kNoIndex) {
189 let history = lazy.SessionHistory.collect(
193 this._fromIdx = kNoIndex;
202 OnHistoryNewEntry(newURI, oldIndex) {
203 // Collect the current entry as well, to make sure to collect any changes
204 // that were made to the entry while the document was active.
205 this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1);
208 OnHistoryGotoIndex() {
209 // We ought to collect the previously current entry as well, see bug 1350567.
210 this.collectFrom(kLastIndex);
222 OnHistoryReplaceEntry() {
227 * @see nsIWebProgressListener.onStateChange
229 onStateChange(webProgress, request, stateFlags, status) {
230 // Ignore state changes for subframes because we're only interested in the
231 // top-document starting or stopping its load.
232 if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) {
236 // onStateChange will be fired when loading the initial about:blank URI for
237 // a browser, which we don't actually care about. This is particularly for
238 // the case of unrestored background tabs, where the content has not yet
239 // been restored: we don't want to accidentally send any updates to the
240 // parent when the about:blank placeholder page has loaded.
241 if (!this.mm.docShell.hasLoadedNonBlankURI) {
245 if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
247 } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
252 SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([
253 "nsIWebProgressListener",
254 "nsISHistoryListener",
255 "nsISupportsWeakReference",
259 * A message queue that takes collected data and will take care of sending it
260 * to the chrome process. It allows flushing using synchronous messages and
261 * takes care of any race conditions that might occur because of that. Changes
262 * will be batched if they're pushed in quick succession to avoid a message
265 class MessageQueue extends Handler {
270 * A map (string -> lazy fn) holding lazy closures of all queued data
271 * collection routines. These functions will return data collected from the
274 this._data = new Map();
277 * The delay (in ms) used to delay sending changes after data has been
280 this.BATCH_DELAY_MS = 1000;
283 * The minimum idle period (in ms) we need for sending data to chrome process.
285 this.NEEDED_IDLE_PERIOD_MS = 5;
288 * Timeout for waiting an idle period to send data. We will set this from
289 * the pref "browser.sessionstore.interval".
291 this._timeoutWaitIdlePeriodMs = null;
294 * The current timeout ID, null if there is no queue data. We use timeouts
295 * to damp a flood of data changes and send lots of changes as one batch.
297 this._timeout = null;
300 * Whether or not sending batched messages on a timer is disabled. This should
301 * only be used for debugging or testing. If you need to access this value,
302 * you should probably use the timeoutDisabled getter.
304 this._timeoutDisabled = false;
307 * True if there is already a send pending idle dispatch, set to prevent
308 * scheduling more than one. If false there may or may not be one scheduled.
310 this._idleScheduled = false;
312 this.timeoutDisabled = Services.prefs.getBoolPref(TIMEOUT_DISABLED_PREF);
313 this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(PREF_INTERVAL);
315 Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this);
316 Services.prefs.addObserver(PREF_INTERVAL, this);
320 * True if batched messages are not being fired on a timer. This should only
321 * ever be true when debugging or during tests.
323 get timeoutDisabled() {
324 return this._timeoutDisabled;
328 * Disables sending batched messages on a timer. Also cancels any pending
331 set timeoutDisabled(val) {
332 this._timeoutDisabled = val;
334 if (val && this._timeout) {
335 clearTimeout(this._timeout);
336 this._timeout = null;
341 Services.prefs.removeObserver(TIMEOUT_DISABLED_PREF, this);
342 Services.prefs.removeObserver(PREF_INTERVAL, this);
343 this.cleanupTimers();
347 * Cleanup pending idle callback and timer.
350 this._idleScheduled = false;
352 clearTimeout(this._timeout);
353 this._timeout = null;
357 observe(subject, topic, data) {
358 if (topic == "nsPref:changed") {
360 case TIMEOUT_DISABLED_PREF:
361 this.timeoutDisabled = Services.prefs.getBoolPref(
362 TIMEOUT_DISABLED_PREF
366 this._timeoutWaitIdlePeriodMs =
367 Services.prefs.getIntPref(PREF_INTERVAL);
370 console.error("received unknown message '" + data + "'");
377 * Pushes a given |value| onto the queue. The given |key| represents the type
378 * of data that is stored and can override data that has been queued before
379 * but has not been sent to the parent process, yet.
381 * @param key (string)
382 * A unique identifier specific to the type of data this is passed.
383 * @param fn (function)
384 * A function that returns the value that will be sent to the parent
388 this._data.set(key, fn);
390 if (!this._timeout && !this._timeoutDisabled) {
391 // Wait a little before sending the message to batch multiple changes.
392 this._timeout = setTimeoutWithTarget(
393 () => this.sendWhenIdle(),
395 this.mm.tabEventTarget
401 * Sends queued data when the remaining idle time is enough or waiting too
402 * long; otherwise, request an idle time again. If the |deadline| is not
403 * given, this function is going to schedule the first request.
405 * @param deadline (object)
406 * An IdleDeadline object passed by idleDispatch().
408 sendWhenIdle(deadline) {
409 if (!this.mm.content) {
410 // The frameloader is being torn down. Nothing more to do.
416 deadline.didTimeout ||
417 deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS
422 } else if (this._idleScheduled) {
423 // Bail out if there's a pending run.
426 ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), {
427 timeout: this._timeoutWaitIdlePeriodMs,
429 this._idleScheduled = true;
433 * Sends queued data to the chrome process.
435 * @param options (object)
436 * {flushID: 123} to specify that this is a flush
437 * {isFinal: true} to signal this is the final message sent on unload
440 // Looks like we have been called off a timeout after the tab has been
441 // closed. The docShell is gone now and we can just return here as there
443 if (!this.mm.docShell) {
447 this.cleanupTimers();
449 let flushID = (options && options.flushID) || 0;
450 let histID = "FX_SESSION_RESTORE_CONTENT_COLLECT_DATA_MS";
453 for (let [key, func] of this._data) {
454 if (key != "isPrivate") {
455 TelemetryStopwatch.startKeyed(histID, key);
460 if (key != "isPrivate") {
461 TelemetryStopwatch.finishKeyed(histID, key);
464 if (value || (key != "storagechange" && key != "historychange")) {
472 // Send all data to the parent process.
473 this.mm.sendAsyncMessage("SessionStore:update", {
476 isFinal: options.isFinal || false,
477 epoch: this.store.epoch,
480 if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) {
482 .getHistogramById("FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM")
484 this.mm.sendAsyncMessage("SessionStore:error");
491 * Listens for and handles messages sent by the session store service.
494 "SessionStore:restoreHistory",
495 "SessionStore:restoreTabContent",
496 "SessionStore:resetRestore",
497 "SessionStore:flush",
498 "SessionStore:prepareForProcessChange",
501 export class ContentSessionStore {
503 if (Services.appinfo.sessionHistoryInParent) {
504 throw new Error("This frame script should not be loaded for SHIP");
508 this.messageQueue = new MessageQueue(this);
512 this.contentRestoreInitialized = false;
516 new EventListener(this),
517 new SessionHistoryListener(this),
520 ChromeUtils.defineLazyGetter(this, "contentRestore", () => {
521 this.contentRestoreInitialized = true;
522 return new lazy.ContentRestore(mm);
525 MESSAGES.forEach(m => mm.addMessageListener(m, this));
527 mm.addEventListener("unload", this);
530 receiveMessage({ name, data }) {
531 // The docShell might be gone. Don't process messages,
532 // that will just lead to errors anyway.
533 if (!this.mm.docShell) {
537 // A fresh tab always starts with epoch=0. The parent has the ability to
538 // override that to signal a new era in this tab's life. This enables it
539 // to ignore async messages that were already sent but not yet received
540 // and would otherwise confuse the internal tab state.
541 if (data && data.epoch && data.epoch != this.epoch) {
542 this.epoch = data.epoch;
546 case "SessionStore:restoreHistory":
547 this.restoreHistory(data);
549 case "SessionStore:restoreTabContent":
550 this.restoreTabContent(data);
552 case "SessionStore:resetRestore":
553 this.contentRestore.resetRestore();
555 case "SessionStore:flush":
558 case "SessionStore:prepareForProcessChange":
559 // During normal in-process navigations, the DocShell would take
560 // care of automatically persisting layout history state to record
561 // scroll positions on the nsSHEntry. Unfortunately, process switching
562 // is not a normal navigation, so for now we do this ourselves. This
563 // is a workaround until session history state finally lives in the
565 this.mm.docShell.persistLayoutHistoryState();
568 console.error("received unknown message '" + name + "'");
574 restoreHistory(data) {
575 let { epoch, tabData, loadArguments, isRemotenessUpdate } = data;
577 this.contentRestore.restoreHistory(tabData, loadArguments, {
578 // Note: The callbacks passed here will only be used when a load starts
579 // that was not initiated by sessionstore itself. This can happen when
580 // some code calls browser.loadURI() or browser.reload() on a pending
583 onLoadStarted: () => {
584 // Notify the parent that the tab is no longer pending.
585 this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", {
590 onLoadFinished: () => {
591 // Tell SessionStore.sys.mjs that it may want to restore some more tabs,
592 // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time.
593 this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", {
599 if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT) {
600 // For non-remote tabs, when restoreHistory finishes, we send a synchronous
601 // message to SessionStore.sys.mjs so that it can run SSTabRestoring. Users of
602 // SSTabRestoring seem to get confused if chrome and content are out of
603 // sync about the state of the restore (particularly regarding
604 // docShell.currentURI). Using a synchronous message is the easiest way
605 // to temporarily synchronize them.
607 // For remote tabs, because all nsIWebProgress notifications are sent
608 // asynchronously using messages, we get the same-order guarantees of the
609 // message manager, and can use an async message.
610 this.mm.sendSyncMessage("SessionStore:restoreHistoryComplete", {
615 this.mm.sendAsyncMessage("SessionStore:restoreHistoryComplete", {
622 restoreTabContent({ loadArguments, isRemotenessUpdate, reason }) {
623 let epoch = this.epoch;
625 // We need to pass the value of didStartLoad back to SessionStore.sys.mjs.
626 let didStartLoad = this.contentRestore.restoreTabContent(
630 // Tell SessionStore.sys.mjs that it may want to restore some more tabs,
631 // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time.
632 this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", {
639 this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", {
646 // Pretend that the load succeeded so that event handlers fire correctly.
647 this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", {
655 // Flush the message queue, send the latest updates.
656 this.messageQueue.send({ flushID: id });
660 if (event.type == "unload") {
666 // Upon frameLoader destruction, send a final update message to
667 // the parent and flush all data currently held in the child.
668 this.messageQueue.send({ isFinal: true });
670 for (let handler of this.handlers) {
671 if (handler.uninit) {
676 if (this.contentRestoreInitialized) {
677 // Remove progress listeners.
678 this.contentRestore.resetRestore();
681 // We don't need to take care of any StateChangeNotifier observers as they
682 // will die with the content script. The same goes for the privacy transition
683 // observer that will die with the docShell when the tab is closed.