commit 96ac3f1fc5c641765b8feb79634bd0cb1d48fd8d
parent af94611fd2fcf7daeab877542fd0bcd5b1afb507
Author: Stephen Thompson <sthompson@mozilla.com>
Date: Sat, 15 Nov 2025 01:30:48 +0000
Bug 1994530 - canonical URL actor for tab notes r=tabbrowser-reviewers,jswinarton
Standalone new JSWindowActor called CanonicalURL that notifies the parent process when the canonical URL in page content changes. This is intended for use by the in-development Tab Notes feature. That feature requires that we assign users' notes to specific web pages, and we are experimenting with a proof of concept where we use a canonical URL from the page content to determine whether a note pertains to that page.
This implementation has many shortfalls, but it's just an extension point right now. For example, this does not detect back/forward button navigation nor single-page application navigation.
Creates a new `tabnotes` folder under browser/components scoped to the Tabbed Browser component in Bugzilla. Again, this is liable to change in the future. It is likely that this code will move to join some other existing JSWindowActor if we decide that it's a good fit.
Differential Revision: https://phabricator.services.mozilla.com/D272561
Diffstat:
9 files changed, 267 insertions(+), 0 deletions(-)
diff --git a/browser/base/content/test/performance/browser_startup_content.js b/browser/base/content/test/performance/browser_startup_content.js
@@ -75,6 +75,10 @@ const intermittently_loaded_scripts = {
"resource://gre/actors/CookieBannerChild.sys.mjs",
"resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ // Canonical URL detection behind pref `browser.tabs.notes.enabled`
+ "resource:///actors/CanonicalURLChild.sys.mjs",
+ "moz-src:///browser/components/tabnotes/CanonicalURL.sys.mjs",
+
// Test related
"chrome://remote/content/marionette/actors/MarionetteEventsChild.sys.mjs",
"chrome://remote/content/shared/Log.sys.mjs",
diff --git a/browser/components/DesktopActorRegistry.sys.mjs b/browser/components/DesktopActorRegistry.sys.mjs
@@ -273,6 +273,21 @@ let JSWINDOWACTORS = {
messageManagerGroups: ["browsers"],
},
+ CanonicalURL: {
+ parent: {
+ esModuleURI: "resource:///actors/CanonicalURLParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource:///actors/CanonicalURLChild.sys.mjs",
+ events: {
+ DOMContentLoaded: {},
+ },
+ },
+ enablePreference: "browser.tabs.notes.enabled",
+ matches: ["http://*/*", "https://*/*"],
+ messageManagerGroups: ["browsers"],
+ },
+
ClickHandler: {
parent: {
esModuleURI: "resource:///actors/ClickHandlerParent.sys.mjs",
diff --git a/browser/components/moz.build b/browser/components/moz.build
@@ -66,6 +66,7 @@ DIRS += [
"sidebar",
"syncedtabs",
"tabbrowser",
+ "tabnotes",
"tabunloader",
"taskbartabs",
"textrecognition",
diff --git a/browser/components/tabnotes/CanonicalURL.sys.mjs b/browser/components/tabnotes/CanonicalURL.sys.mjs
@@ -0,0 +1,94 @@
+/* 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/. */
+
+/**
+ * Given a web page content document, finds candidates for an explicitly
+ * declared canonical URL. Includes a fallback URL to use in case the content
+ * did not declare a canonical URL.
+ *
+ * @param {Document} document
+ * @returns {CanonicalURLSourceResults}
+ */
+export function findCandidates(document) {
+ return {
+ link: getLinkRelCanonical(document),
+ opengraph: getOpenGraphUrl(document),
+ jsonLd: getJSONLDUrl(document),
+ fallback: getFallbackCanonicalUrl(document),
+ };
+}
+
+/**
+ * Given a set of canonical URL candidates from `CanonicalURL.findCandidates`,
+ * returns the best value to use as the canonical URL.
+ *
+ * @param {CanonicalURLSourceResults} sources
+ * @returns {string}
+ */
+export function pickCanonicalUrl(sources) {
+ return (
+ sources.link ?? sources.opengraph ?? sources.jsonLd ?? sources.fallback
+ );
+}
+
+/**
+ * TODO: resolve relative URLs
+ * TODO: can be a different hostname or domain; does that need special handling?
+ *
+ * @see https://www.rfc-editor.org/rfc/rfc6596
+ *
+ * @param {Document} document
+ * @returns {string|null}
+ */
+function getLinkRelCanonical(document) {
+ return document.querySelector('link[rel="canonical"]')?.getAttribute("href");
+}
+
+/**
+ * @see https://ogp.me/#url
+ *
+ * @param {Document} document
+ * @returns {string|null}
+ */
+function getOpenGraphUrl(document) {
+ return document
+ .querySelector('meta[property="og:url"]')
+ ?.getAttribute("content");
+}
+
+/**
+ * Naïvely returns the first JSON-LD entity's URL, if found.
+ * TODO: make sure it's a web page-like/content schema?
+ *
+ * @see https://schema.org/url
+ *
+ * @param {Document} document
+ * @returns {string|null}
+ */
+function getJSONLDUrl(document) {
+ return Array.from(
+ document.querySelectorAll('script[type="application/ld+json"]')
+ )
+ .map(script => {
+ try {
+ return JSON.parse(script.textContent);
+ } catch {
+ return null;
+ }
+ })
+ .find(obj => obj?.url)?.url;
+}
+
+/**
+ * @param {Document} document
+ * @returns {string|null}
+ */
+function getFallbackCanonicalUrl(document) {
+ const fallbackUrl = URL.parse(document.documentURI);
+ if (fallbackUrl) {
+ fallbackUrl.hash = "";
+ return fallbackUrl.toString();
+ }
+ return null;
+}
diff --git a/browser/components/tabnotes/CanonicalURLChild.sys.mjs b/browser/components/tabnotes/CanonicalURLChild.sys.mjs
@@ -0,0 +1,42 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ findCandidates: "moz-src:///browser/components/tabnotes/CanonicalURL.sys.mjs",
+ pickCanonicalUrl:
+ "moz-src:///browser/components/tabnotes/CanonicalURL.sys.mjs",
+});
+
+/**
+ * Identifies the canonical URL in a top-level content frame, if possible,
+ * and notifies the parent process about it.
+ */
+export class CanonicalURLChild extends JSWindowActorChild {
+ /**
+ * @param {Event} event
+ */
+ handleEvent(event) {
+ switch (event.type) {
+ case "DOMContentLoaded":
+ this.#discoverCanonicalUrl();
+ }
+ }
+
+ /**
+ * Find a canonical URL in the document and tell the parent about it.
+ */
+ #discoverCanonicalUrl() {
+ const candidates = lazy.findCandidates(this.document);
+ const canonicalUrl = lazy.pickCanonicalUrl(candidates);
+ const canonicalUrlSources = Object.keys(candidates).filter(
+ candidate => candidates[candidate]
+ );
+ this.sendAsyncMessage("CanonicalURL:Identified", {
+ canonicalUrl,
+ canonicalUrlSources,
+ });
+ }
+}
diff --git a/browser/components/tabnotes/CanonicalURLParent.sys.mjs b/browser/components/tabnotes/CanonicalURLParent.sys.mjs
@@ -0,0 +1,70 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
+ return console.createInstance({
+ prefix: "CanonicalURL",
+ maxLogLevel: Services.prefs.getBoolPref("browser.tabs.notes.debug", false)
+ ? "Debug"
+ : "Warn",
+ });
+});
+
+/**
+ * Receives canonical URL identifications from CanonicalURLChild and dispatches
+ * event notifications on the <browser>.
+ */
+export class CanonicalURLParent extends JSWindowActorParent {
+ /**
+ * Called when a message is received from the content process.
+ *
+ * @param {ReceiveMessageArgument} msg
+ */
+ receiveMessage(msg) {
+ switch (msg.name) {
+ case "CanonicalURL:Identified":
+ {
+ const browser = this.browsingContext?.embedderElement;
+
+ // If we don't have a browser then it went away before we could record,
+ // so we don't know where the data came from.
+ if (!browser) {
+ lazy.logConsole.debug(
+ "CanonicalURL:Identified: reject due to missing browser"
+ );
+ return;
+ }
+
+ if (!browser.ownerGlobal.gBrowser?.getTabForBrowser(browser)) {
+ lazy.logConsole.debug(
+ "CanonicalURL:Identified: reject due to the browser not being a tab browser"
+ );
+ return;
+ }
+
+ const { canonicalUrl, canonicalUrlSources } = msg.data;
+
+ let event = new browser.ownerGlobal.CustomEvent(
+ "CanonicalURL:Identified",
+ {
+ bubbles: true,
+ cancelable: false,
+ detail: {
+ canonicalUrl,
+ canonicalUrlSources,
+ },
+ }
+ );
+ browser.dispatchEvent(event);
+ lazy.logConsole.info("CanonicalURL:Identified", {
+ canonicalUrl,
+ canonicalUrlSources,
+ });
+ }
+ break;
+ }
+ }
+}
diff --git a/browser/components/tabnotes/moz.build b/browser/components/tabnotes/moz.build
@@ -0,0 +1,17 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+MOZ_SRC_FILES += [
+ "CanonicalURL.sys.mjs",
+]
+
+FINAL_TARGET_FILES.actors += [
+ "CanonicalURLChild.sys.mjs",
+ "CanonicalURLParent.sys.mjs",
+]
diff --git a/browser/components/tabnotes/tsconfig.json b/browser/components/tabnotes/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "include": ["**/*.mjs", "types/*.ts"],
+ "exclude": [],
+ "extends": "../../../tools/@types/tsconfig.json",
+
+ "compilerOptions": {
+ "checkJs": true,
+
+ "plugins": [
+ {
+ "transform": "../../../tools/ts/plugins/checkRootOnly.js",
+ "transformProgram": true
+ }
+ ]
+ }
+}
diff --git a/browser/components/tabnotes/types/tabnotes.ts b/browser/components/tabnotes/types/tabnotes.ts
@@ -0,0 +1,8 @@
+/* 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/. */
+
+type CanonicalURLSource = "link" | "opengraph" | "jsonLd" | "fallback";
+type CanonicalURLSourceResults = {
+ [source in CanonicalURLSource]: string | null;
+};