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
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
class SessionHistoryWatcher {
#entriesByKey = {};
#sessionHistory;
#sessionHistoryListener;
#currentEntry = null;
/**
* Start watching (from the parent process) for session history updates related to a
* given Watcher Actor.
*
* @param {WatcherActor} watcherActor: The watcher actor from which we should observe
* document event
* @param {object} options
* @param {Function} options.onAvailable
* @param {Function} options.onUpdated
* @param {Function} options.onDestroyed
*/
async watch(watcherActor, { onAvailable, onUpdated, onDestroyed }) {
this.watcherActor = watcherActor;
this.onAvailable = onAvailable;
this.onUpdated = onUpdated;
this.onDestroyed = onDestroyed;
this.#sessionHistory = BrowsingContext.get(
this.watcherActor.browserElement.browsingContext.id
).sessionHistory;
this.#processSessionHistory();
this.#currentEntry = this.#sessionHistory.getEntryAtIndex(
this.#sessionHistory.index
);
this.#sessionHistoryListener = {
QueryInterface: ChromeUtils.generateQI([
"nsISHistoryListener",
"nsISupportsWeakReference",
]),
OnHistoryCommit: () => {
const newEntry = this.#sessionHistory.getEntryAtIndex(
this.#sessionHistory.index
);
const isUpdate = this.#currentEntry?.sharesDocumentWith(newEntry);
this.#currentEntry = newEntry;
this.#processSessionHistory(isUpdate);
},
OnEntryUpdated: entry => {
const title = entry.title;
const url = entry.URI.spec;
const name = entry.name;
this.onUpdated([
{
resourceId: this.resourceId,
resourceUpdates: {
sessionHistoryEntry: { url, title, name, key: key(entry.ID) },
},
browsingContextID:
this.watcherActor.browserElement.browsingContext.id,
},
]);
},
};
this.#sessionHistory.addSHistoryListener(this.#sessionHistoryListener);
}
get resourceId() {
return "session-history-" + this.watcherActor.browserElement.browserId;
}
/**
* Will go over the session history and call onAvailable or onUpdated.
*
* @param {boolean} isUpdate When true, delivers changes via onUpdated instead of onAvailable.
*/
#processSessionHistory(isUpdate = false) {
const sessionHistorydiagrams = [];
for (const { start, end } of sameDocuments(this.#sessionHistory)) {
const rows = createRows(
this.#sessionHistory,
this.#entriesByKey,
start,
end
);
sessionHistorydiagrams.push({ rows, start, end });
}
const resource = {
resourceId: this.resourceId,
browsingContextID: this.watcherActor.browserElement.browsingContext.id,
count: this.#sessionHistory.count,
current: this.#sessionHistory.index,
diagrams: sessionHistorydiagrams,
entriesByKey: this.#entriesByKey,
};
if (isUpdate) {
this.onUpdated([
{
resourceId: this.resourceId,
resourceUpdates: { sessionHistory: resource },
browsingContextID:
this.watcherActor.browserElement.browsingContext.id,
},
]);
} else {
this.onAvailable([resource]);
}
}
destroy() {
this.#sessionHistory.removeSHistoryListener(this.#sessionHistoryListener);
this.#sessionHistory = null;
this.#sessionHistoryListener = null;
this.#currentEntry = null;
}
}
/**
* Given a tree-like data structure, performs a pre-order (also known as tree
* order) traversal of that tree. Since the elements can be anything, the
* caller needs to supply a way to find how many children a specific node has
* as well as a way to iterate children.
*
* @param {*} root Starting node of the traversal
* @param {function(*): int} getChildCount Helper to find child count
* @param {function(*, int): *} getChildAtIndex Helper to find child at index
*/
function* preOrderTraversal(root, getChildCount, getChildAtIndex) {
const queue = [root];
while (queue.length) {
const entry = queue.shift();
if (!entry) {
continue;
}
for (let index = getChildCount(entry) - 1; index >= 0; --index) {
queue.unshift(getChildAtIndex(entry, index));
}
yield entry;
}
}
function key(k) {
return `${k}`;
}
class Diagram {
#parent;
#entry;
#key;
#rows = [];
#lookup = new Map();
static EMPTY = Symbol("Diagram.EMPTY");
/**
* Create a session history diagram from a particular entry.
*
* @param {object|null} parent Parent node
* @param {object} entry Entry to create the diagram from
*/
constructor(parent, entry) {
this.#parent = parent;
this.#entry = entry;
this.#key = key(entry.docshellID);
}
get entry() {
return this.#entry;
}
get key() {
return this.#key;
}
get parent() {
return this.#parent;
}
get rows() {
return this.#rows;
}
lookup(id) {
return this.#lookup.get(id);
}
/**
* Append an entry to a row of the diagram.
*
* @param {object} entry New entry.
* @param {int} index Index to append at.
*/
addChild(entry, index) {
const id = key(entry.docshellID);
const row = this.#lookup.getOrInsertComputed(id, () => {
const newRow = new Array(index).fill(Diagram.EMPTY);
this.#rows.push({ id, newRow });
return newRow;
});
row.push(entry);
}
}
function* sameDocuments(sessionHistory) {
let previous = {
sharesDocumentWith() {
return true;
},
};
let start = 0;
const size = sessionHistory.count;
for (let index = 0; index < size; index++) {
const entry = sessionHistory.getEntryAtIndex(index);
const sameDocument = previous.sharesDocumentWith(entry);
previous = entry;
if (sameDocument) {
continue;
}
yield { start, end: index };
start = index;
}
yield { start, end: size };
}
function createRows(sessionHistory, entriesByKey, start, end) {
const lookup = new Map();
const size = end - start;
// We add a rootDiagram for convenience to be able to add top-level entries
// to it. This diagram will never be rendered to the full diagram, and the
// id will never be used in lookup.
const rootDiagram = new Diagram(null, { docshellID: "fakeDocShellID" });
const getChildCount = entry => entry.childCount;
const getChildAt = (entry, index) => entry.GetChildAt(index);
for (let index = start; index < end; index++) {
const root = sessionHistory.getEntryAtIndex(index);
rootDiagram.addChild(root, index - start);
lookup.getOrInsertComputed(key(root.docshellID), () => {
return new Diagram(rootDiagram, root);
});
for (const entry of preOrderTraversal(root, getChildCount, getChildAt)) {
const parent = entry.parent;
if (parent) {
const diagram = lookup.get(key(parent.docshellID));
diagram.addChild(entry, index - start);
lookup.getOrInsertComputed(key(entry.docshellID), () => {
return new Diagram(diagram, entry);
});
}
}
}
return Array.from(
preOrderTraversal(
rootDiagram,
entry => entry.rows.length,
({ rows }, index) => lookup.get(rows[index].id)
),
diagram => {
if (!diagram.parent) {
return [];
}
const row = [];
const entries = diagram.parent.lookup(diagram.key);
for (let count = entries.length; count > size; count--) {
entries.push(Diagram.EMPTY);
}
while (entries.length < size) {
entries.push(Diagram.EMPTY);
}
let previous = Diagram.EMPTY;
let newEntry = null;
while (entries.length) {
const entry = entries.shift();
if (entry !== Diagram.EMPTY && previous.ID != entry.ID) {
const url = entry.URI.spec;
newEntry = {
age: 1,
key: key(entry.ID),
sameDocNav:
previous !== Diagram.EMPTY && entry.sharesDocumentWith(previous),
};
const parent = entry.parent?.navigationId;
const extra = {};
if (parent) {
extra.parent = key(parent);
}
const numChildren = getChildCount(entry);
if (numChildren) {
const children = new Array(numChildren);
for (let index = 0; index < numChildren; ++index) {
children[index] = getChildAt(entry, index)?.navigationId;
}
extra.children = children.map(key);
}
entriesByKey[key(entry.ID)] = {
url,
title: entry.title,
name: entry.name,
id: key(entry.navigationId),
key: key(entry.navigationKey),
bfcache: entry.isInBFCache,
...extra,
};
row.push(newEntry);
} else if (!newEntry) {
newEntry = { age: 1 };
row.push(newEntry);
} else if (entry !== Diagram.EMPTY || previous === Diagram.EMPTY) {
newEntry.age++;
} else {
newEntry = { age: 1 };
row.push(newEntry);
}
previous = entry;
}
return row;
}
).filter(row => row.length);
}
module.exports = SessionHistoryWatcher;