Backed out changeset 496886cb30a5 (bug 1867152) for bc failures on browser_user_input...
[gecko.git] / browser / components / sessionstore / ContentSessionStore.sys.mjs
blob44f59cd39d540003b5fe3b08ca93a19a33f434f4
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/. */
5 import {
6   clearTimeout,
7   setTimeoutWithTarget,
8 } from "resource://gre/modules/Timer.sys.mjs";
10 const lazy = {};
12 ChromeUtils.defineESModuleGetters(lazy, {
13   ContentRestore: "resource:///modules/sessionstore/ContentRestore.sys.mjs",
14   SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs",
15 });
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;
26 class Handler {
27   constructor(store) {
28     this.store = store;
29   }
31   get contentRestore() {
32     return this.store.contentRestore;
33   }
35   get contentRestoreInitialized() {
36     return this.store.contentRestoreInitialized;
37   }
39   get mm() {
40     return this.store.mm;
41   }
43   get messageQueue() {
44     return this.store.messageQueue;
45   }
48 /**
49  * Listens for and handles content events that we need for the
50  * session store service to be notified of state changes in content.
51  */
52 class EventListener extends Handler {
53   constructor(store) {
54     super(store);
56     SessionStoreUtils.addDynamicFrameFilteredListener(
57       this.mm,
58       "load",
59       this,
60       true
61     );
62   }
64   handleEvent(event) {
65     let { content } = this.mm;
67     // Ignore load events from subframes.
68     if (event.target != content.document) {
69       return;
70     }
72     if (content.document.documentURI.startsWith("about:reader")) {
73       if (
74         event.type == "load" &&
75         !content.document.body.classList.contains("loaded")
76       ) {
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);
80         return;
81       }
83       content.removeEventListener("AboutReaderContentReady", this);
84     }
86     if (this.contentRestoreInitialized) {
87       // Restore the form data and scroll position.
88       this.contentRestore.restoreDocument();
89     }
90   }
93 /**
94  * Listens for changes to the session history. Whenever the user navigates
95  * we will collect URLs and everything belonging to session history.
96  *
97  * Causes a SessionStore:update message to be sent that contains the current
98  * session history.
99  *
100  * Example:
101  *   {entries: [{url: "about:mozilla", ...}, ...], index: 1}
102  */
103 class SessionHistoryListener extends Handler {
104   constructor(store) {
105     super(store);
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
113     // a delay.
114     this.mm.docShell
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(
123       this,
124       Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
125     );
127     // Collect data if we start with a non-empty shistory.
128     if (!lazy.SessionHistory.isEmpty(this.mm.docShell)) {
129       this.collect();
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();
137     }
139     // Listen for page title changes.
140     this.mm.addEventListener("DOMTitleChanged", this);
141   }
143   get mm() {
144     return this.store.mm;
145   }
147   uninit() {
148     let sessionHistory = this.mm.docShell.QueryInterface(
149       Ci.nsIWebNavigation
150     ).sessionHistory;
151     if (sessionHistory) {
152       sessionHistory.legacySHistory.removeSHistoryListener(this); // OK in non-geckoview
153     }
154   }
156   collect() {
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);
163     }
164   }
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)
173   collectFrom(idx) {
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.
180       return;
181     }
183     this._fromIdx = idx;
184     this.store.messageQueue.push("historychange", () => {
185       if (this._fromIdx === kNoIndex) {
186         return null;
187       }
189       let history = lazy.SessionHistory.collect(
190         this.mm.docShell,
191         this._fromIdx
192       );
193       this._fromIdx = kNoIndex;
194       return history;
195     });
196   }
198   handleEvent(event) {
199     this.collect();
200   }
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);
206   }
208   OnHistoryGotoIndex() {
209     // We ought to collect the previously current entry as well, see bug 1350567.
210     this.collectFrom(kLastIndex);
211   }
213   OnHistoryPurge() {
214     this.collect();
215   }
217   OnHistoryReload() {
218     this.collect();
219     return true;
220   }
222   OnHistoryReplaceEntry() {
223     this.collect();
224   }
226   /**
227    * @see nsIWebProgressListener.onStateChange
228    */
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) {
233       return;
234     }
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) {
242       return;
243     }
245     if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
246       this.collect();
247     } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
248       this.collect();
249     }
250   }
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
263  * flood.
264  */
265 class MessageQueue extends Handler {
266   constructor(store) {
267     super(store);
269     /**
270      * A map (string -> lazy fn) holding lazy closures of all queued data
271      * collection routines. These functions will return data collected from the
272      * docShell.
273      */
274     this._data = new Map();
276     /**
277      * The delay (in ms) used to delay sending changes after data has been
278      * invalidated.
279      */
280     this.BATCH_DELAY_MS = 1000;
282     /**
283      * The minimum idle period (in ms) we need for sending data to chrome process.
284      */
285     this.NEEDED_IDLE_PERIOD_MS = 5;
287     /**
288      * Timeout for waiting an idle period to send data. We will set this from
289      * the pref "browser.sessionstore.interval".
290      */
291     this._timeoutWaitIdlePeriodMs = null;
293     /**
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.
296      */
297     this._timeout = null;
299     /**
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.
303      */
304     this._timeoutDisabled = false;
306     /**
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.
309      */
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);
317   }
319   /**
320    * True if batched messages are not being fired on a timer. This should only
321    * ever be true when debugging or during tests.
322    */
323   get timeoutDisabled() {
324     return this._timeoutDisabled;
325   }
327   /**
328    * Disables sending batched messages on a timer. Also cancels any pending
329    * timers.
330    */
331   set timeoutDisabled(val) {
332     this._timeoutDisabled = val;
334     if (val && this._timeout) {
335       clearTimeout(this._timeout);
336       this._timeout = null;
337     }
338   }
340   uninit() {
341     Services.prefs.removeObserver(TIMEOUT_DISABLED_PREF, this);
342     Services.prefs.removeObserver(PREF_INTERVAL, this);
343     this.cleanupTimers();
344   }
346   /**
347    * Cleanup pending idle callback and timer.
348    */
349   cleanupTimers() {
350     this._idleScheduled = false;
351     if (this._timeout) {
352       clearTimeout(this._timeout);
353       this._timeout = null;
354     }
355   }
357   observe(subject, topic, data) {
358     if (topic == "nsPref:changed") {
359       switch (data) {
360         case TIMEOUT_DISABLED_PREF:
361           this.timeoutDisabled = Services.prefs.getBoolPref(
362             TIMEOUT_DISABLED_PREF
363           );
364           break;
365         case PREF_INTERVAL:
366           this._timeoutWaitIdlePeriodMs =
367             Services.prefs.getIntPref(PREF_INTERVAL);
368           break;
369         default:
370           console.error("received unknown message '" + data + "'");
371           break;
372       }
373     }
374   }
376   /**
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.
380    *
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
385    *        process.
386    */
387   push(key, fn) {
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(),
394         this.BATCH_DELAY_MS,
395         this.mm.tabEventTarget
396       );
397     }
398   }
400   /**
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.
404    *
405    * @param deadline (object)
406    *        An IdleDeadline object passed by idleDispatch().
407    */
408   sendWhenIdle(deadline) {
409     if (!this.mm.content) {
410       // The frameloader is being torn down. Nothing more to do.
411       return;
412     }
414     if (deadline) {
415       if (
416         deadline.didTimeout ||
417         deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS
418       ) {
419         this.send();
420         return;
421       }
422     } else if (this._idleScheduled) {
423       // Bail out if there's a pending run.
424       return;
425     }
426     ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), {
427       timeout: this._timeoutWaitIdlePeriodMs,
428     });
429     this._idleScheduled = true;
430   }
432   /**
433    * Sends queued data to the chrome process.
434    *
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
438    */
439   send(options = {}) {
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
442     // is nothing to do.
443     if (!this.mm.docShell) {
444       return;
445     }
447     this.cleanupTimers();
449     let flushID = (options && options.flushID) || 0;
450     let histID = "FX_SESSION_RESTORE_CONTENT_COLLECT_DATA_MS";
452     let data = {};
453     for (let [key, func] of this._data) {
454       if (key != "isPrivate") {
455         TelemetryStopwatch.startKeyed(histID, key);
456       }
458       let value = func();
460       if (key != "isPrivate") {
461         TelemetryStopwatch.finishKeyed(histID, key);
462       }
464       if (value || (key != "storagechange" && key != "historychange")) {
465         data[key] = value;
466       }
467     }
469     this._data.clear();
471     try {
472       // Send all data to the parent process.
473       this.mm.sendAsyncMessage("SessionStore:update", {
474         data,
475         flushID,
476         isFinal: options.isFinal || false,
477         epoch: this.store.epoch,
478       });
479     } catch (ex) {
480       if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) {
481         Services.telemetry
482           .getHistogramById("FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM")
483           .add(1);
484         this.mm.sendAsyncMessage("SessionStore:error");
485       }
486     }
487   }
491  * Listens for and handles messages sent by the session store service.
492  */
493 const MESSAGES = [
494   "SessionStore:restoreHistory",
495   "SessionStore:restoreTabContent",
496   "SessionStore:resetRestore",
497   "SessionStore:flush",
498   "SessionStore:prepareForProcessChange",
501 export class ContentSessionStore {
502   constructor(mm) {
503     if (Services.appinfo.sessionHistoryInParent) {
504       throw new Error("This frame script should not be loaded for SHIP");
505     }
507     this.mm = mm;
508     this.messageQueue = new MessageQueue(this);
510     this.epoch = 0;
512     this.contentRestoreInitialized = false;
514     this.handlers = [
515       this.messageQueue,
516       new EventListener(this),
517       new SessionHistoryListener(this),
518     ];
520     ChromeUtils.defineLazyGetter(this, "contentRestore", () => {
521       this.contentRestoreInitialized = true;
522       return new lazy.ContentRestore(mm);
523     });
525     MESSAGES.forEach(m => mm.addMessageListener(m, this));
527     mm.addEventListener("unload", this);
528   }
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) {
534       return;
535     }
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;
543     }
545     switch (name) {
546       case "SessionStore:restoreHistory":
547         this.restoreHistory(data);
548         break;
549       case "SessionStore:restoreTabContent":
550         this.restoreTabContent(data);
551         break;
552       case "SessionStore:resetRestore":
553         this.contentRestore.resetRestore();
554         break;
555       case "SessionStore:flush":
556         this.flush(data);
557         break;
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
564         // parent process.
565         this.mm.docShell.persistLayoutHistoryState();
566         break;
567       default:
568         console.error("received unknown message '" + name + "'");
569         break;
570     }
571   }
573   // non-SHIP only
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
581       // browser/tab.
583       onLoadStarted: () => {
584         // Notify the parent that the tab is no longer pending.
585         this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", {
586           epoch,
587         });
588       },
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", {
594           epoch,
595         });
596       },
597     });
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.
606       //
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", {
611         epoch,
612         isRemotenessUpdate,
613       });
614     } else {
615       this.mm.sendAsyncMessage("SessionStore:restoreHistoryComplete", {
616         epoch,
617         isRemotenessUpdate,
618       });
619     }
620   }
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(
627       loadArguments,
628       isRemotenessUpdate,
629       () => {
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", {
633           epoch,
634           isRemotenessUpdate,
635         });
636       }
637     );
639     this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", {
640       epoch,
641       isRemotenessUpdate,
642       reason,
643     });
645     if (!didStartLoad) {
646       // Pretend that the load succeeded so that event handlers fire correctly.
647       this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", {
648         epoch,
649         isRemotenessUpdate,
650       });
651     }
652   }
654   flush({ id }) {
655     // Flush the message queue, send the latest updates.
656     this.messageQueue.send({ flushID: id });
657   }
659   handleEvent(event) {
660     if (event.type == "unload") {
661       this.onUnload();
662     }
663   }
665   onUnload() {
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) {
672         handler.uninit();
673       }
674     }
676     if (this.contentRestoreInitialized) {
677       // Remove progress listeners.
678       this.contentRestore.resetRestore();
679     }
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.
684   }