tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit ab69e32e0298c7d364622be19d65a8b85a182264
parent 485624dbe95b53a760b77ac56ea95327cd9977c7
Author: Jan-Niklas Jaeschke <jjaschke@mozilla.com>
Date:   Sun, 19 Oct 2025 17:08:10 +0000

Bug 1970909: Call reveal algorithm for callers of find algorithm instead of the find algorithm itself. r=emilio

The previous behavior caused all sorts of edge-casey issues:
Find-in-page would open all <details> or hidden=until-found elements
which contain the search string. Therefore, accordions would open
one after another, and the last one would be opened instead of the one
with the match.

Finding a text fragment on a page would open all details/hidden=until-found  elements
which contain any of a context term of the text directive.

Creating a text fragment link (context menu -> Copy Link to Highlight) would open
all hidden=until-found/details elements which contain the target range.

The reason for this behavior was `nsFind::Find()` calling the reveal algorithm unconditionally.

This patch fixes this by only calling the reveal algorithm at the appropriate places,
which is inside the find-in-page backend (`nsTypeAheadFind.cpp`),
in the `window.find()` backend (`nsWebBrowserFind.cpp`),
and in the text fragment finder backend (`TextDirectiveFinder.cpp`).

In addition, this patch refactors the tests for finding hidden=until-found / <details> elements:
- test_nsFind.html only tests that text inside hidden=until-found or <details> can be found
- New tests in `browser_findbar_hidden_beforematch.js` and `test_find.html` ensure that
  the elements are visible after they are found, and that `beforematch` is fired for hidden=until-found elements
- New tests have been added for finding and creating text fragments to ensure that only the correct elements are revealed.

Differential Revision: https://phabricator.services.mozilla.com/D269160

Diffstat:
Mdom/base/TextDirectiveFinder.cpp | 3+++
Mdom/base/nsINode.cpp | 8++++++++
Mdom/base/nsINode.h | 2++
Mdom/base/test/mochitest.toml | 2++
Mdom/base/test/test_find.html | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adom/base/test/test_text-fragments-create-text-directive-no-reveal.html | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atesting/web-platform/tests/scroll-to-text-fragment/find-range-from-text-directive-no-reveal.html | 36++++++++++++++++++++++++++++++++++++
Mtoolkit/components/find/nsFind.cpp | 7-------
Mtoolkit/components/find/nsWebBrowserFind.cpp | 5+++++
Mtoolkit/components/find/test/mochitest/test_nsFind.html | 35++++-------------------------------
Mtoolkit/components/typeaheadfind/nsTypeAheadFind.cpp | 6++++++
Mtoolkit/content/tests/browser/browser.toml | 2++
Atoolkit/content/tests/browser/browser_findbar_hidden_beforematch.js | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 414 insertions(+), 38 deletions(-)

diff --git a/dom/base/TextDirectiveFinder.cpp b/dom/base/TextDirectiveFinder.cpp @@ -77,6 +77,9 @@ nsTArray<RefPtr<nsRange>> TextDirectiveFinder::FindTextDirectivesInDocument() { textDirectiveRanges.AppendElement(range); TEXT_FRAGMENT_LOG("Found text directive '{}'", ToString(textDirective).c_str()); + if (RefPtr startNode = range->GetStartContainer()) { + startNode->QueueAncestorRevealingAlgorithm(); + } } else { uninvokedTextDirectives.AppendElement(std::move(textDirective)); } diff --git a/dom/base/nsINode.cpp b/dom/base/nsINode.cpp @@ -4181,6 +4181,14 @@ ShadowRoot* nsINode::GetShadowRootForSelection() const { return shadowRoot; } +void nsINode::QueueAncestorRevealingAlgorithm() { + NS_DispatchToMainThread(NS_NewRunnableFunction( + "RevealAncestors", + [self = RefPtr{this}]() MOZ_CAN_RUN_SCRIPT_BOUNDARY_LAMBDA { + self->AncestorRevealingAlgorithm(IgnoreErrors()); + })); +} + enum class RevealType : uint8_t { UntilFound, Details, diff --git a/dom/base/nsINode.h b/dom/base/nsINode.h @@ -2546,6 +2546,8 @@ class nsINode : public mozilla::dom::EventTarget { return HasSlots() ? GetExistingSlots()->mWeakReference : nullptr; } + void QueueAncestorRevealingAlgorithm(); + MOZ_CAN_RUN_SCRIPT void AncestorRevealingAlgorithm(ErrorResult& aRv); protected: diff --git a/dom/base/test/mochitest.toml b/dom/base/test/mochitest.toml @@ -1696,6 +1696,8 @@ skip-if = [ "os == 'android'", # The test needs to run reasonably fast. ] +["test_text-fragments-create-text-directive-no-reveal.html"] + ["test_text-fragments-create-text-directive.html"] ["test_text-fragments-get-text-directive-ranges.html"] diff --git a/dom/base/test/test_find.html b/dom/base/test/test_find.html @@ -198,6 +198,69 @@ let runTests = t.step_func_done(function() { testFindable(false, "find me", ` Do you find <span inert>not findable</span> me? `, "boundary-crossing inert"); + + testFindable(true, "hidden content", ` + <div hidden="until-found">This is hidden content</div> + `, "hidden=until-found should be findable"); + + testFindable(true, "details content", ` + <details> + <summary>Click to expand</summary> + <div>This is details content</div> + </details> + `, "closed details should be findable"); + + promise_test(async function() { + const iframe = document.querySelector("iframe"); + iframe.contentDocument.documentElement.innerHTML = ` + <div id="hidden" hidden="until-found">findable text</div> + `; + + const beforematchPromise = new Promise(resolve => { + iframe.contentDocument.getElementById("hidden").addEventListener("beforematch", () => { + resolve(); + }, { once: true }); + }); + + iframe.contentWindow.getSelection().removeAllRanges(); + const found = iframe.contentWindow.find("findable"); + + assert_true(found, "Should find text in hidden=until-found"); + + await beforematchPromise; + + await new Promise(resolve => iframe.contentWindow.requestAnimationFrame(resolve)); + + const hiddenElement = iframe.contentDocument.getElementById("hidden"); + assert_equals(hiddenElement.hidden, false, "hidden attribute should be removed"); + }, "beforematch event fires and element is revealed for hidden=until-found"); + + testFindable(false, "should not be found", ` + <style>#outer { content-visibility: hidden; }</style> + <div id="outer"> + <div hidden="until-found">This should not be found</div> + </div> + `, "hidden=until-found inside content-visibility:hidden should not be findable"); + + promise_test(async function() { + const iframe = document.querySelector("iframe"); + iframe.contentDocument.documentElement.innerHTML = ` + <details id="details"> + <summary>Summary</summary> + <div>Details text</div> + </details> + `; + + iframe.contentWindow.getSelection().removeAllRanges(); + const found = iframe.contentWindow.find("Details text"); + + assert_true(found, "Should find text in closed details"); + + await new Promise(resolve => iframe.contentWindow.requestAnimationFrame(resolve)); + + const detailsElement = iframe.contentDocument.getElementById("details"); + assert_true(detailsElement.open, "details element should be opened after find"); + }, "details element is opened after find"); }); window.onload = function() { diff --git a/dom/base/test/test_text-fragments-create-text-directive-no-reveal.html b/dom/base/test/test_text-fragments-create-text-directive-no-reveal.html @@ -0,0 +1,70 @@ +<!DOCTYPE html> +<html> +<head> + <title>Text Fragment Creation Should Not Reveal Other Elements (Bug 1970909)</title> + <meta charset="UTF-8"> + <link rel="help" href="https://bugzil.la/1970909"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> +</head> +<body> + <div id="hidden1" hidden="until-found">abc</div> + + <details id="details1"> + <summary>First Section</summary> + <div id="content1">abc</div> + </details> + + <details id="details2"> + <summary>Second Section</summary> + <div id="content2">abc def</div> + </details> + + <script> + SimpleTest.waitForExplicitFinish(); + + async function runTests() { + try { + await SpecialPowers.pushPrefEnv({"set": [ + ["dom.text_fragments.enabled", true], + ["dom.text_fragments.create_text_fragment.enabled", true], + ]}); + + // Creating a text fragment from a selection in details2 should not reveal + // other elements (closed <details> or hidden=until-found elements). + + ok(hidden1.hidden === "until-found", + "Precondition: hidden1 should have hidden=until-found"); + details1.open = false; + details2.open = true; + + ok(!details1.open, "Precondition: details1 should be closed"); + ok(details2.open, "Precondition: details2 should be open"); + + const range = document.createRange(); + range.setStart(content2.firstChild, 0); + range.setEnd(content2.firstChild, 3); + is(range.toString(), "abc", "Precondition: Range should contain 'abc'"); + + // Create text directive from the range + // This will internally search for "abc" to determine if context is needed + // and must not reveal other matches of the search string. + const textDirective = await SpecialPowers.wrap(document).fragmentDirective.createTextDirectiveForRanges([range]); + + await new Promise(resolve => requestAnimationFrame(resolve)); + + isnot(textDirective, null, "A text directive should be created for 'abc'"); + + is(hidden1.hidden, "until-found", "hidden=until-found element should not be revealed"); + ok(!details1.open, "closed details element should not be opened"); + ok(details2.open, "already open element which contains the selection should remain open"); + is(textDirective, "text=abc,-def", `Text directive "${textDirective}" should be correct`); + + } finally { + SimpleTest.finish(); + } + } + + document.body.onload = runTests; + </script> +</body> +</html> diff --git a/testing/web-platform/tests/scroll-to-text-fragment/find-range-from-text-directive-no-reveal.html b/testing/web-platform/tests/scroll-to-text-fragment/find-range-from-text-directive-no-reveal.html @@ -0,0 +1,36 @@ +<!doctype html> +<title>Finding the text directive range must not reveal other hidden elements</title> +<meta charset=utf-8> +<link rel="help" href="https://bugzil.la/1970909"> +<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/"> +<link rel="author" title="Jan-Niklas Jaeschke" href="mailto:jjaschke@mozilla.com"> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<body> + +<div id="hidden1" hidden="until-found">abc</div> +<details id="details1"> + <summary>First Section</summary> + <div>abc</div> +</details> +<details id="details2"> + <summary>Second Section</summary> + <div>abc def</div> +</details> + +<script> +// Test that navigating to a text fragment does not open hidden=until-found or +// closed <details> elements that contain an invalid match for the text fragment. +promise_test(async t => { + + window.location.hash = ':~:text=abc,-def'; + + await new Promise(resolve => requestAnimationFrame(resolve)); + + assert_equals(hidden1.hidden, 'until-found', 'hidden=until-found element should remain hidden'); + assert_false(details1.open, 'closed <details> element should remain closed'); + assert_true(details2.open, '<details> element containing the match should be opened'); +}, 'Text fragment with suffix should only reveal the matching details element'); +</script> +</body> +</html> diff --git a/toolkit/components/find/nsFind.cpp b/toolkit/components/find/nsFind.cpp @@ -1010,13 +1010,6 @@ already_AddRefed<nsRange> nsFind::FindFromRangeBoundaries( if (!rv.Failed()) { range->SetEnd(*endParent, matchEndOffset, rv); } - // https://html.spec.whatwg.org/#interaction-with-details-and-hidden=until-found - NS_DispatchToMainThread(NS_NewRunnableFunction( - "RevealHiddenUntilFound", - [node = RefPtr(startParent)]() - MOZ_CAN_RUN_SCRIPT_BOUNDARY_LAMBDA { - node->AncestorRevealingAlgorithm(IgnoreErrors()); - })); if (!rv.Failed()) { return range.forget(); } diff --git a/toolkit/components/find/nsWebBrowserFind.cpp b/toolkit/components/find/nsWebBrowserFind.cpp @@ -634,6 +634,11 @@ nsresult nsWebBrowserFind::SearchInFrame(nsPIDOMWindowOuter* aWindow, if (NS_SUCCEEDED(rv) && foundRange) { *aDidFind = true; + // Reveal hidden-until-found and closed details elements for the match. + // https://html.spec.whatwg.org/#interaction-with-details-and-hidden=until-found + if (RefPtr startNode = foundRange->GetStartContainer()) { + startNode->QueueAncestorRevealingAlgorithm(); + } sel->RemoveAllRanges(IgnoreErrors()); // Beware! This may flush notifications via synchronous // ScrollSelectionIntoView. diff --git a/toolkit/components/find/test/mochitest/test_nsFind.html b/toolkit/components/find/test/mochitest/test_nsFind.html @@ -278,42 +278,15 @@ async function runTests() { assertNotFound(quotes, "Tardigrade"); assertNotFound(quotes, "Amoeba"); - // hidden=until-found content is found + // nsFind should be able to find text inside hidden=until-found + // or closed <details> elements. + // Note: nsFind itself does not trigger the reveal algorithm; that is the + // responsibility of the caller (e.g., find-in-page, nsTypeAheadFind). // https://html.spec.whatwg.org/#attr-hidden-until-found - const hiddenUntilFound = document.querySelector("#hidden-until-found"); - // Create a promise that resolves when the beforematch event fires. - const beforematchPromise = new Promise(resolve => { - hiddenUntilFound.onbeforematch = () => resolve(); - }); - is( - getComputedStyle(hiddenUntilFound).contentVisibility, - "hidden", - "hidden=until-found content has content-visibility: hidden before being found" - ); assertFound(document.body, "hidden until found"); - await beforematchPromise; - await new Promise(resolve => requestAnimationFrame(resolve)); - - is( - getComputedStyle(hiddenUntilFound).contentVisibility, - "visible", - "hidden=until-found content has content-visibility: visible after being found" - ); - - // https://html.spec.whatwg.org/#interaction-with-details-and-hidden=until-found - const hiddenHiddenUntilFound = document.querySelector("div#hidden-until-found-inside-hidden"); - hiddenHiddenUntilFound.onbeforematch = () => ok( - false, - "Beforematch must not be fired for a hidden=until-found element which is inside a content-visibility:hidden element." - ); assertNotFound(document.body, "hidden because of content-visibility: hidden outside of hidden=until-found"); - const nestedHiddenInHiddenUntilFound = document.querySelector("div#hidden-until-found-with-inner-hidden"); - nestedHiddenInHiddenUntilFound.onbeforematch = () => ok( - false, - "Beforematch must not be fired for a hidden=until-found element if the matched content is content-visibility:hidden" - ); assertNotFound(document.body, "nested hidden block should not be found"); assertNotFound(document.body, "nested visibility:hidden should not be found"); diff --git a/toolkit/components/typeaheadfind/nsTypeAheadFind.cpp b/toolkit/components/typeaheadfind/nsTypeAheadFind.cpp @@ -503,6 +503,12 @@ nsresult nsTypeAheadFind::FindItNow(uint32_t aMode, bool aIsLinksOnly, } mSelectionController = do_GetWeakReference(selectionController); + // Reveal hidden-until-found and closed details elements for the match. + // https://html.spec.whatwg.org/#interaction-with-details-and-hidden=until-found + if (RefPtr startNode = returnRange->GetStartContainer()) { + startNode->QueueAncestorRevealingAlgorithm(); + } + // Select the found text if (selection) { selection->RemoveAllRanges(IgnoreErrors()); diff --git a/toolkit/content/tests/browser/browser.toml b/toolkit/content/tests/browser/browser.toml @@ -140,6 +140,8 @@ tags = "audiochannel" ["browser_findbar_disabled_manual.js"] +["browser_findbar_hidden_beforematch.js"] + ["browser_findbar_hiddenframes.js"] ["browser_findbar_marks.js"] diff --git a/toolkit/content/tests/browser/browser_findbar_hidden_beforematch.js b/toolkit/content/tests/browser/browser_findbar_hidden_beforematch.js @@ -0,0 +1,213 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// These tests verify that find-in-page properly reveals hidden +// content (hidden=until-found and closed <details> elements) by triggering +// the ancestor revealing algorithm and firing beforematch events. + +const TEST_PAGE_URI = + "data:text/html;charset=utf-8," + + encodeURIComponent(` +<!DOCTYPE html> +<body> +<div id="hidden1" hidden="until-found">First hidden text</div> +<div id="hidden2" hidden="until-found">Second hidden text</div> +<details id="details1"> + <summary>First Details</summary> + <div>First details text</div> +</details> +<details id="details2"> + <summary>Second Details</summary> + <div>Second details text</div> +</details> +</body> +`); + +// Helper function to wait for Find Again to complete +async function promiseFindAgain(findbar) { + return new Promise(resolve => { + let resultListener = { + onFindResult() { + findbar.browser.finder.removeResultListener(resultListener); + resolve(); + }, + onCurrentSelection() {}, + onMatchesCountResult() {}, + onHighlightFinished() {}, + }; + findbar.browser.finder.addResultListener(resultListener); + findbar.onFindAgainCommand(); + }); +} + +add_task(async function test_hidden_until_found_reveal_behavior() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + + let findbar = await gBrowser.getFindBar(); + + let matchCountPromise = new Promise(resolve => { + let resultListener = { + onFindResult() {}, + onCurrentSelection() {}, + onHighlightFinished() {}, + onMatchesCountResult(response) { + if (response.total > 0) { + findbar.browser.finder.removeResultListener(resultListener); + resolve(response); + } + }, + }; + findbar.browser.finder.addResultListener(resultListener); + }); + + // Perform the first find with highlighting enabled to trigger match counting + await promiseFindFinished(gBrowser, "hidden text", true); + + let matchCount = await matchCountPromise; + is(matchCount.total, 2, "Should find 2 matches in hidden elements"); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + return new Promise(resolve => content.requestAnimationFrame(resolve)); + }); + + let { hidden1, hidden2 } = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => { + return { + hidden1: content.document.getElementById("hidden1").hidden, + hidden2: content.document.getElementById("hidden2").hidden, + }; + } + ); + + is(hidden1, false, "first hidden=until-found element should be revealed"); + is( + hidden2, + "until-found", + "second hidden=until-found element should NOT be revealed yet" + ); + + // Set up beforematch listener for the second element + let secondBeforematchPromise = SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => { + return new Promise(resolve => { + let element = content.document.getElementById("hidden2"); + element.addEventListener("beforematch", () => resolve(), { + once: true, + }); + }); + } + ); + + await promiseFindAgain(findbar); + + await secondBeforematchPromise; + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + return new Promise(resolve => content.requestAnimationFrame(resolve)); + }); + + let { secondFindhidden1, secondFindhidden2 } = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => { + return { + secondFindhidden1: content.document.getElementById("hidden1").hidden, + secondFindhidden2: content.document.getElementById("hidden2").hidden, + }; + } + ); + is( + secondFindhidden1, + false, + "first element should remain revealed after Find Next" + ); + is( + secondFindhidden2, + false, + "Second element should be revealed after Find Next" + ); + + gBrowser.removeTab(tab); +}); + +add_task(async function test_details_reveal_behavior() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE_URI + ); + + let findbar = await gBrowser.getFindBar(); + + let matchCountPromise = new Promise(resolve => { + let resultListener = { + onFindResult() {}, + onCurrentSelection() {}, + onHighlightFinished() {}, + onMatchesCountResult(response) { + if (response.total > 0) { + findbar.browser.finder.removeResultListener(resultListener); + resolve(response); + } + }, + }; + findbar.browser.finder.addResultListener(resultListener); + }); + + // Perform the first find with highlighting enabled to trigger match counting + await promiseFindFinished(gBrowser, "details text", true); + + let matchCount = await matchCountPromise; + is(matchCount.total, 2, "Should find 2 matches in details elements"); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + return new Promise(resolve => content.requestAnimationFrame(resolve)); + }); + + let { open1, open2 } = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => { + return { + open1: content.document.getElementById("details1").open, + open2: content.document.getElementById("details2").open, + }; + } + ); + + ok(open1, "first <details> element should be open"); + ok(!open2, "second <details> element should NOT be open yet"); + + await promiseFindAgain(findbar); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + return new Promise(resolve => content.requestAnimationFrame(resolve)); + }); + + let { secondFindOpen1, secondFindOpen2 } = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => { + return { + secondFindOpen1: content.document.getElementById("details1").open, + secondFindOpen2: content.document.getElementById("details2").open, + }; + } + ); + ok( + secondFindOpen1, + "First details element should remain opened after Find Next" + ); + ok( + secondFindOpen2, + "Second details element should be opened after Find Next" + ); + + gBrowser.removeTab(tab); +});