commit 182bb259bfc7444872b0c83d7ca09d14a6dc6512
parent 8bf1985cc5d177d80cf0ec3da21bc19c957f341d
Author: Vincent Hilla <vhilla@mozilla.com>
Date: Tue, 16 Dec 2025 18:12:06 +0000
Bug 1858562 - Part 4: Browser Chrome Document Picture-in-Picture adjustments. r=mconley,smaug,sthompson,dom-core,webidl,sessionstore-reviewers,dao,urlbar-reviewers
Implement a basic UI/UX for Document Picture-in-Picture.
- The opener URI is shown in the PiP window's location bar.
- Fullscreen via F11 and session restore are blocked for PiP windows.
Differential Revision: https://phabricator.services.mozilla.com/D267248
Diffstat:
13 files changed, 230 insertions(+), 15 deletions(-)
diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js
@@ -2215,16 +2215,19 @@ var XULBrowserWindow = {
aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SESSION_STORE
);
- // We want to update the popup visibility if we received this notification
- // via simulated locationchange events such as switching between tabs, however
- // if this is a document navigation then PopupNotifications will be updated
- // via TabsProgressListener.onLocationChange and we do not want it called twice
- gURLBar.setURI({
- uri: aLocationURI,
- dueToTabSwitch: aIsSimulated,
- dueToSessionRestore: isSessionRestore,
- isSameDocument,
- });
+ // Don't update URL for document PiP window as it shows its opener url
+ if (!window.browsingContext.isDocumentPiP) {
+ // We want to update the popup visibility if we received this notification
+ // via simulated locationchange events such as switching between tabs, however
+ // if this is a document navigation then PopupNotifications will be updated
+ // via TabsProgressListener.onLocationChange and we do not want it called twice
+ gURLBar.setURI({
+ uri: aLocationURI,
+ dueToTabSwitch: aIsSimulated,
+ dueToSessionRestore: isSessionRestore,
+ isSameDocument,
+ });
+ }
BookmarkingUI.onLocationChange();
// If we've actually changed document, update the toolbar visibility.
@@ -2542,6 +2545,13 @@ var XULBrowserWindow = {
aState |= Ci.nsIWebProgressListener.STATE_IDENTITY_ASSOCIATED;
}
+ if (window.browsingContext.isDocumentPiP) {
+ gURLBar.setURI({
+ uri,
+ isSameDocument: true,
+ });
+ }
+
try {
uri = Services.io.createExposableURI(uri);
} catch (e) {}
diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs
@@ -2260,6 +2260,12 @@ var SessionStoreInternal = {
* Window reference
*/
onBeforeBrowserWindowShown(aWindow) {
+ // Do not track Document Picture-in-Picture windows since these are
+ // ephemeral and tied to a specific tab's browser document.
+ if (aWindow.browsingContext.isDocumentPiP) {
+ return;
+ }
+
// Register the window.
this.onLoad(aWindow);
diff --git a/browser/components/urlbar/content/UrlbarInput.mjs b/browser/components/urlbar/content/UrlbarInput.mjs
@@ -717,6 +717,14 @@ export class UrlbarInput extends HTMLElement {
"Cannot set URI for UrlbarInput that is not an address bar"
);
}
+ if (
+ this.window.browsingContext.isDocumentPiP &&
+ uri.spec.startsWith("about:blank")
+ ) {
+ // If this is a Document PiP, its url will be about:blank while
+ // the opener will be a secure context, i.e. no about:blank
+ throw new Error("Document PiP should show its opener URL");
+ }
// We only need to update the searchModeUI on tab switch conditionally
// as we only persist searchMode with ScotchBonnet enabled.
if (
diff --git a/dom/base/nsGlobalWindowOuter.cpp b/dom/base/nsGlobalWindowOuter.cpp
@@ -4223,6 +4223,12 @@ nsresult nsGlobalWindowOuter::SetFullscreenInternal(FullscreenReason aReason,
return NS_OK;
}
+ // Element.requestFullscreen() is already blocked, but also block
+ // fullscreening for other callers, especially the chrome window.
+ if (GetBrowsingContext()->Top()->GetIsDocumentPiP()) {
+ return NS_OK;
+ }
+
// SetFullscreen needs to be called on the root window, so get that
// via the DocShell tree, and if we are not already the root,
// call SetFullscreen on that window instead.
diff --git a/dom/chrome-webidl/BrowsingContext.webidl b/dom/chrome-webidl/BrowsingContext.webidl
@@ -301,6 +301,12 @@ interface BrowsingContext {
undefined resetNavigationRateLimit();
readonly attribute long childOffset;
+
+ // https://wicg.github.io/document-picture-in-picture/
+ // This is true both for the top-level BC of the content and chrome window
+ // of a Document Picture-in-Picture window.
+ [BinaryName="GetIsDocumentPiP"]
+ readonly attribute boolean isDocumentPiP;
};
BrowsingContext includes LoadContextMixin;
diff --git a/dom/documentpip/moz.build b/dom/documentpip/moz.build
@@ -13,6 +13,8 @@ UNIFIED_SOURCES += [
"DocumentPictureInPicture.cpp",
]
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"]
+
include("/ipc/chromium/chromium-config.mozbuild")
FINAL_LIBRARY = "xul"
diff --git a/dom/documentpip/tests/browser/browser.toml b/dom/documentpip/tests/browser/browser.toml
@@ -2,4 +2,12 @@
support-files = ["head.js"]
prefs = ["dom.documentpip.enabled=true"]
+["browser_parent_has_pip_flag.js"]
+
["browser_pip_closes_once.js"]
+
+["browser_pip_fullscreen_disallowed.js"]
+
+["browser_pip_ui.js"]
+
+["browser_sessionstore_ignore_dpip.js"]
diff --git a/dom/documentpip/tests/browser/browser_parent_has_pip_flag.js b/dom/documentpip/tests/browser/browser_parent_has_pip_flag.js
@@ -0,0 +1,19 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function check_chrome_window_has_pip_flag() {
+ const [tab, chromePiP] = await newTabWithPiP();
+
+ is(
+ chromePiP.location.href,
+ "chrome://browser/content/browser.xhtml",
+ "Got the chrome window of the PiP"
+ );
+ ok(chromePiP.browsingContext.isDocumentPiP, "Chrome window has pip flag set");
+
+ // Cleanup.
+ await BrowserTestUtils.closeWindow(chromePiP);
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/dom/documentpip/tests/browser/browser_pip_closes_once.js b/dom/documentpip/tests/browser/browser_pip_closes_once.js
@@ -13,14 +13,31 @@
add_task(async function closing_pip_sends_exactly_one_DOMWindowClosed() {
const [tab, chromePiP] = await newTabWithPiP();
- let closeCount = 0;
- chromePiP.addEventListener("DOMWindowClose", () => closeCount++);
+ // Note: Counting DOMWindowClose in the parent process isn't the same.
+ // - The parent might have multiple, i.e. for closing tab and native window
+ // - Sending the second event up to the parent might fail if closing has progressed far enough
+ await SpecialPowers.spawn(chromePiP.gBrowser.selectedBrowser, [], () => {
+ content.opener.closeCount = 0;
+ SpecialPowers.addChromeEventListener(
+ "DOMWindowClose",
+ () => content.opener.closeCount++,
+ true,
+ false
+ );
+ });
+ // close PiP
await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
content.documentPictureInPicture.window.close();
});
await BrowserTestUtils.windowClosed(chromePiP);
+ const closeCount = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ () => content.closeCount
+ );
is(closeCount, 1, "Received a single DOMWindowClosed");
+
BrowserTestUtils.removeTab(tab);
});
diff --git a/dom/documentpip/tests/browser/browser_pip_fullscreen_disallowed.js b/dom/documentpip/tests/browser/browser_pip_fullscreen_disallowed.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function check_chrome_window_of_pip_cannot_fullscreen() {
+ const [tab, chromePiP] = await newTabWithPiP();
+
+ // Sanity check: VK_F11 will set window.fullScreen via View:Fullscreen command
+ const fullScreenEntered = BrowserTestUtils.waitForEvent(window, "fullscreen");
+ window.fullScreen = true;
+ ok(window.fullScreen, "FS works as expected on window");
+ await fullScreenEntered;
+ ok(true, "Got fullscreen event");
+
+ const fullScreenExited = BrowserTestUtils.waitForEvent(window, "fullscreen");
+ window.fullScreen = false;
+ ok(!window.fullScreen, "FS works as expected on window");
+ await fullScreenExited;
+ ok(true, "Got fullscreen event");
+
+ // Check PiP cannot be fullscreened by chrome
+ is(
+ chromePiP.location.href,
+ "chrome://browser/content/browser.xhtml",
+ "Got the chrome window of the PiP"
+ );
+ chromePiP.fullScreen = true;
+ ok(!chromePiP.fullScreen, "Didn't enter fullscreen on PiP");
+
+ // Cleanup.
+ await BrowserTestUtils.closeWindow(chromePiP);
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/dom/documentpip/tests/browser/browser_pip_ui.js b/dom/documentpip/tests/browser/browser_pip_ui.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { UrlbarTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/UrlbarTestUtils.sys.mjs"
+);
+
+add_task(async function pip_urlbar_shows_readonly_opener_url() {
+ const [tab, chromePiP] = await newTabWithPiP();
+
+ // correct URL at the beginning
+ const expectedURL = UrlbarTestUtils.trimURL(
+ tab.linkedBrowser.currentURI.spec
+ );
+ is(chromePiP.gURLBar.value, expectedURL, "PiP urlbar shows opener url");
+ ok(chromePiP.gURLBar.readOnly, "Location bar is read-only in PiP");
+
+ // correct URL after PiP location change
+ const onLocationChange = BrowserTestUtils.waitForLocationChange(
+ chromePiP.gBrowser,
+ "about:blank#0"
+ );
+ await SpecialPowers.spawn(chromePiP.gBrowser.selectedBrowser, [], () => {
+ content.location.href = "about:blank#0";
+ });
+ await onLocationChange;
+ is(chromePiP.gURLBar.value, expectedURL, "PiP urlbar shows opener url");
+
+ // Cleanup.
+ await BrowserTestUtils.closeWindow(chromePiP);
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function pip_alwaysontop_chromeFlag() {
+ const [tab, chromePiP] = await newTabWithPiP();
+
+ // Currently, we cannot check the widget is actually alwaysontop. But we can check
+ // that the respective chromeFlag is set.
+ const chromeFlags = chromePiP.docShell.treeOwner
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).chromeFlags;
+ ok(
+ chromeFlags & Ci.nsIWebBrowserChrome.CHROME_ALWAYS_ON_TOP,
+ "PiP has alwaysontop chrome flag"
+ );
+
+ // Cleanup.
+ await BrowserTestUtils.closeWindow(chromePiP);
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/dom/documentpip/tests/browser/browser_sessionstore_ignore_dpip.js b/dom/documentpip/tests/browser/browser_sessionstore_ignore_dpip.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TabStateFlusher } = ChromeUtils.importESModule(
+ "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
+);
+
+add_task(async function check_document_pip_not_saved() {
+ const [tab, chromePiP] = await newTabWithPiP();
+
+ await TabStateFlusher.flushWindow(window);
+ await TabStateFlusher.flushWindow(chromePiP);
+ let state = SessionStore.getCurrentState(true);
+ // Don't keepOnlyWorthSavingTabs as Ctrl+Shift+T can restore regardless of that
+ // and the about:blank in chromePiP might not be worth saving.
+
+ is(state.windows.length, 1, "Only one window in session store");
+
+ // Just to be extra sure, check the only tracked window is for |tab|
+ const urls = state.windows[0].tabs
+ .flatMap(ssTab => ssTab.entries.map(entry => entry.url))
+ .filter(url => !!url);
+ // |urls| contains an unexpected about:blank (bug 2006027).
+ // But if example.com is in there, the only tacked window is |tab|
+ ok(
+ urls.includes("https://example.com/"),
+ "Window tracked in sessionstore is tab not PiP"
+ );
+
+ // Cleanup.
+ await BrowserTestUtils.closeWindow(chromePiP);
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/xpfe/appshell/AppWindow.cpp b/xpfe/appshell/AppWindow.cpp
@@ -2320,8 +2320,8 @@ void AppWindow::SetContentScrollbarVisibility(bool aVisible) {
}
void AppWindow::ApplyChromeFlags() {
- nsCOMPtr<dom::Element> window = GetWindowDOMElement();
- if (!window) {
+ nsCOMPtr<dom::Element> root = GetWindowDOMElement();
+ if (!root) {
return;
}
@@ -2362,7 +2362,19 @@ void AppWindow::ApplyChromeFlags() {
// Note that if we're not actually changing the value this will be a no-op,
// so no need to compare to the old value.
IgnoredErrorResult rv;
- window->SetAttribute(u"chromehidden"_ns, newvalue, rv);
+ root->SetAttribute(u"chromehidden"_ns, newvalue, rv);
+
+ // Also set the IsDocumentPiP on the chrome browsing context
+ if ((mChromeFlags &
+ nsIWebBrowserChrome::CHROME_DOCUMENT_PICTURE_IN_PICTURE) ==
+ nsIWebBrowserChrome::CHROME_DOCUMENT_PICTURE_IN_PICTURE) {
+ nsCOMPtr<mozIDOMWindowProxy> windowProxy;
+ GetWindowDOMWindow(getter_AddRefs(windowProxy));
+ if (nsCOMPtr<nsPIDOMWindowOuter> window = do_QueryInterface(windowProxy)) {
+ nsresult rv = window->GetBrowsingContext()->SetIsDocumentPiP(true);
+ NS_ENSURE_SUCCESS_VOID(rv);
+ }
+ }
}
NS_IMETHODIMP