Bug 1959610 - Align CloseOuter with #script-closable session history check. r=smaug

Since https://github.com/whatwg/html/pull/6315, script-closable checks for a
history length of one, instead of the session history containing only one
document.

This commit also adds a wpt to test that windows opened by links and forms
count as `created by a script`.

Differential Revision: https://phabricator.services.mozilla.com/D245035
This commit is contained in:
Vincent Hilla
2025-04-22 09:34:17 +00:00
parent 7771aa6e86
commit e7e22edc70
12 changed files with 137 additions and 102 deletions

View File

@@ -224,9 +224,6 @@ struct EmbedderColorSchemes {
* This is only ever set to true on the top BC, so consumers need to get \
* the value from the top BC! */ \
FIELD(HasSessionHistory, bool) \
/* Tracks if this context is the only top-level document in the session \
* history of the context. */ \
FIELD(IsSingleToplevelInHistory, bool) \
FIELD(UseErrorPages, bool) \
FIELD(PlatformOverride, nsString) \
/* Specifies if this BC has loaded documents besides the initial \

View File

@@ -1301,7 +1301,6 @@ void CanonicalBrowsingContext::ReplaceActiveSessionHistoryEntry(
nsSHistory* shistory = static_cast<nsSHistory*>(GetSessionHistory());
if (shistory) {
shistory->NotifyOnHistoryReplaceEntry();
shistory->UpdateRootBrowsingContextState();
}
ResetSHEntryHasUserInteractionCache();

View File

@@ -287,8 +287,6 @@ interface nsISHistory: nsISupports
[noscript] void AddChildSHEntryHelper(in nsISHEntry aCloneRef, in nsISHEntry aNewEntry,
in BrowsingContext aRootBC, in boolean aCloneChildren);
[noscript, notxpcom] boolean isEmptyOrHasEntriesForSingleTopLevelPage();
/**
* Determine if we can navigate back in history from the entry at aIndex
* to an entry that has user interaction.

View File

@@ -784,17 +784,6 @@ void nsSHistory::HandleEntriesToSwapInDocShell(
}
}
void nsSHistory::UpdateRootBrowsingContextState(BrowsingContext* aRootBC) {
if (aRootBC && aRootBC->EverAttached()) {
bool sameDocument = IsEmptyOrHasEntriesForSingleTopLevelPage();
if (sameDocument != aRootBC->GetIsSingleToplevelInHistory()) {
// If the browsing context is discarded then its session history is
// invalid and will go away.
Unused << aRootBC->SetIsSingleToplevelInHistory(sameDocument);
}
}
}
NS_IMETHODIMP
nsSHistory::AddToRootSessionHistory(bool aCloneChildren, nsISHEntry* aOSHE,
BrowsingContext* aRootBC,
@@ -883,7 +872,6 @@ nsSHistory::AddEntry(nsISHEntry* aSHEntry, bool aPersist) {
NotifyListeners(mListeners, [](auto l) { l->OnHistoryReplaceEntry(); });
aSHEntry->SetPersist(aPersist);
mEntries[mIndex] = aSHEntry;
UpdateRootBrowsingContextState();
return NS_OK;
}
}
@@ -915,8 +903,6 @@ nsSHistory::AddEntry(nsISHEntry* aSHEntry, bool aPersist) {
PurgeHistory(Length() - gHistoryMaxSize);
}
UpdateRootBrowsingContextState();
return NS_OK;
}
@@ -1150,8 +1136,6 @@ nsSHistory::PurgeHistory(int32_t aNumEntries) {
rootBC->GetDocShell()->HistoryPurged(aNumEntries);
}
UpdateRootBrowsingContextState(rootBC);
return NS_OK;
}
@@ -1213,8 +1197,6 @@ nsSHistory::ReplaceEntry(int32_t aIndex, nsISHEntry* aReplaceEntry) {
aReplaceEntry->SetPersist(true);
mEntries[aIndex] = aReplaceEntry;
UpdateRootBrowsingContextState();
return NS_OK;
}
@@ -2026,8 +2008,6 @@ void nsSHistory::RemoveEntries(nsTArray<nsID>& aIDs, int32_t aStartIndex,
}
--index;
}
UpdateRootBrowsingContextState();
}
void nsSHistory::RemoveFrameEntries(nsISHEntry* aEntry) {
@@ -2417,25 +2397,6 @@ nsSHistory::CreateEntry(nsISHEntry** aEntry) {
return NS_OK;
}
NS_IMETHODIMP_(bool)
nsSHistory::IsEmptyOrHasEntriesForSingleTopLevelPage() {
if (mEntries.IsEmpty()) {
return true;
}
nsISHEntry* entry = mEntries[0];
size_t length = mEntries.Length();
for (size_t i = 1; i < length; ++i) {
bool sharesDocument = false;
mEntries[i]->SharesDocumentWith(entry, &sharesDocument);
if (!sharesDocument) {
return false;
}
}
return true;
}
static void CollectEntries(
nsTHashMap<nsIDHashKey, SessionHistoryEntry*>& aHashtable,
SessionHistoryEntry* aEntry) {

View File

@@ -189,7 +189,6 @@ class nsSHistory : public mozilla::LinkedListElement<nsSHistory>,
uint64_t newID = aRootBC ? aRootBC->Id() : 0;
if (mRootBC != newID) {
mRootBC = newID;
UpdateRootBrowsingContextState(aRootBC);
}
}
@@ -200,13 +199,6 @@ class nsSHistory : public mozilla::LinkedListElement<nsSHistory>,
return mRequestedIndex == -1 ? mIndex : mRequestedIndex;
}
// Update the root browsing context state when adding, removing or
// replacing entries.
void UpdateRootBrowsingContextState() {
RefPtr<mozilla::dom::BrowsingContext> rootBC(GetBrowsingContext());
UpdateRootBrowsingContextState(rootBC);
}
void GetEpoch(uint64_t& aEpoch,
mozilla::Maybe<mozilla::dom::ContentParentId>& aId) const {
aEpoch = mEpoch;
@@ -228,9 +220,6 @@ class nsSHistory : public mozilla::LinkedListElement<nsSHistory>,
private:
friend class nsSHistoryObserver;
void UpdateRootBrowsingContextState(
mozilla::dom::BrowsingContext* aBrowsingContext);
bool LoadDifferingEntries(nsISHEntry* aPrevEntry, nsISHEntry* aNextEntry,
mozilla::dom::BrowsingContext* aParent,
long aLoadType,

View File

@@ -5920,9 +5920,12 @@ void nsGlobalWindowOuter::CloseOuter(bool aTrustedCaller) {
nsresult rv = mDoc->GetURL(url);
NS_ENSURE_SUCCESS_VOID(rv);
RefPtr<ChildSHistory> csh =
nsDocShell::Cast(mDocShell)->GetSessionHistory();
if (!StringBeginsWith(url, u"about:neterror"_ns) &&
!mBrowsingContext->GetTopLevelCreatedByWebContent() &&
!aTrustedCaller && !IsOnlyTopLevelDocumentInSHistory()) {
!aTrustedCaller && csh && csh->Count() > 1) {
bool allowClose =
mAllowScriptsToClose ||
Preferences::GetBool("dom.allow_scripts_to_close_windows", true);
@@ -5965,23 +5968,6 @@ void nsGlobalWindowOuter::CloseOuter(bool aTrustedCaller) {
FinalClose();
}
bool nsGlobalWindowOuter::IsOnlyTopLevelDocumentInSHistory() {
NS_ENSURE_TRUE(mDocShell && mBrowsingContext, false);
// Disabled since IsFrame() is buggy in Fission
// MOZ_ASSERT(mBrowsingContext->IsTop());
if (mozilla::SessionHistoryInParent()) {
return mBrowsingContext->GetIsSingleToplevelInHistory();
}
RefPtr<ChildSHistory> csh = nsDocShell::Cast(mDocShell)->GetSessionHistory();
if (csh && csh->LegacySHistory()) {
return csh->LegacySHistory()->IsEmptyOrHasEntriesForSingleTopLevelPage();
}
return false;
}
nsresult nsGlobalWindowOuter::Close() {
CloseOuter(/* aTrustedCaller = */ true);
return NS_OK;

View File

@@ -965,8 +965,6 @@ class nsGlobalWindowOuter final : public mozilla::dom::EventTarget,
void MaybeAllowStorageForOpenedWindow(nsIURI* aURI);
bool IsOnlyTopLevelDocumentInSHistory();
void MaybeResetWindowName(Document* aNewDocument);
public:

View File

@@ -1,8 +0,0 @@
<!doctype html>
<script>
let chan = new BroadcastChannel("close_noopener_beforeunload" + location.search);
onload = function() { window.close(); };
onbeforeunload = function() {
chan.postMessage({ name: "beforeunload", history: history.length });
}
</script>

View File

@@ -1,6 +0,0 @@
<!doctype html>
<script>
onload = function() {
setTimeout(() => window.location = "close_noopener_beforeunload-1.html" + location.search, 0);
}
</script>

View File

@@ -4,21 +4,31 @@
<script src="/resources/testharnessreport.js"></script>
<div id="log"></div>
<script>
async_test(t => {
window.open("close_noopener_beforeunload-2.html?2", "", "noopener");
let chan = new BroadcastChannel("close_noopener_beforeunload?2");
chan.onmessage = t.step_func_done(function(event) {
assert_equals(event.data.name, "beforeunload", "correct message received");
assert_equals(event.data.history, 2, "session history has multiple entries");
function waitForMessage(channel, name) {
return new Promise(resolve => {
function listener({ data }) {
if (data.name === name) {
channel.removeEventListener("message", listener);
resolve(data);
}
};
channel.addEventListener("message", listener);
});
}
async_test(t => {
let chan = new BroadcastChannel("close_noopener_beforeunload2");
waitForMessage(chan, "beforeunload").then(t.step_func_done(data => {
assert_equals(data.history, 2, "session history has multiple entries");
}));
window.open("self-close.html?navs=1&channel=close_noopener_beforeunload2", "", "noopener");
}, "closing noopener window with 2 entries");
async_test(t => {
window.open("close_noopener_beforeunload-1.html?1", "", "noopener");
let chan = new BroadcastChannel("close_noopener_beforeunload?1");
chan.onmessage = t.step_func_done(function(event) {
assert_equals(event.data.name, "beforeunload", "correct message received");
assert_equals(event.data.history, 1, "session history has a single entry");
});
let chan = new BroadcastChannel("close_noopener_beforeunload1");
waitForMessage(chan, "beforeunload").then(t.step_func_done(data => {
assert_equals(data.history, 1, "session history has a single entry");
}));
window.open("self-close.html?navs=0&channel=close_noopener_beforeunload1", "", "noopener");
}, "closing noopener window with 1 entry");
</script>

View File

@@ -0,0 +1,85 @@
<!doctype html>
<title>Test whether a window is script-closable</title>
<link rel="help" href="https://html.spec.whatwg.org/#script-closable"/>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<div id="log"></div>
<script>
let uid = 0;
async function withChannel(createWindow) {
const channel_name = `channel_${++uid}`;
const channel = new BroadcastChannel(channel_name);
const closedPromise = new Promise(resolve => {
channel.addEventListener("message", ({data}) => {
if (data.name === 'load') {
assert_true(true, 'window was loaded');
} else {
assert_not_equals(data.history, 1, "If script-closable, then not due to history length");
resolve(data.closed);
}
});
});
await createWindow(channel_name);
return closedPromise;
}
const Actions = {
async form(noopener) {
return withChannel((channel_name) => {
const form = document.createElement("form");
form.action = "self-close.html";
form.target = '_blank';
if (noopener) {
form.rel = "noopener";
}
// ?navs=1
const inp1 = document.createElement("input");
inp1.name = "navs";
inp1.value = "1";
form.appendChild(inp1);
// ?channel=channel_name
const inp2 = document.createElement("input");
inp2.name = "channel";
inp2.value = channel_name;
form.appendChild(inp2);
document.body.appendChild(form);
form.submit();
form.remove();
});
},
async link(noopener) {
return withChannel((channel_name) => {
const anchor = document.createElement("a");
anchor.href = `self-close.html?navs=1&channel=${channel_name}`;
anchor.target = '_blank';
if (noopener) {
anchor.rel = "noopener";
}
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
});
},
async script(noopener) {
return withChannel((channel_name) => {
const url = `self-close.html?navs=1&channel=${channel_name}`;
window.open(url, "_blank", noopener ? 'noopener' : '');
});
}
}
for (const action of [Actions.form, Actions.link, Actions.script]) {
for (const noopener of [false, true])
promise_test(async () => {
const closed = await action(noopener);
assert_true(closed, "window closed");
}, `Window created by ${action.name} with ${noopener ? 'noopener' : 'opener'} is script-closable`);
}
</script>

View File

@@ -0,0 +1,26 @@
<!doctype html>
<p>self-close.html?navs=n&channel=name will navigate n times, then close and notify the channel.</p>
<script>
window.onload = setTimeout(() => {
const urlParams = new URLSearchParams(window.location.search);
let n = parseInt(urlParams.get('navs')) || 0;
const channel = new BroadcastChannel(urlParams.get('channel'));
channel.postMessage({ name: 'load', href: window.location.href });
if (n > 0) {
urlParams.set('navs', n-1);
window.location.href = `${window.location.pathname}?${urlParams.toString()}#${n}`;
} else {
window.onbeforeunload = () => {
channel.postMessage({ name: 'beforeunload', history: history.length, closed: true });
}
window.close();
if (!window.closed) {
channel.postMessage({ name: 'close failed', history: history.length, closed: false });
}
}
}, 0);
</script>