Bug 1895555 - Text Fragments: Implement same-document navigation. r=farre,dom-core

Same-document navigation follows a different code path than normal navigation
and was therefore not covered in the initial implementation for text fragments.
Same-document navigation does not set a URI in the `Document`, which
is the way cross-document navigation would parse text directives from the URL.

Instead, `nsDocShell::ScrollToAnchor()` is called via
`nsDocShell::InternalLoad()`-> `nsDocShell::HandleSameDocumentNavigation()`.
This code path needs to parse and remove the fragment directive from the new
fragment to be able to find text fragments and to allow for element-id fallback.
`nsDocShell::ScrollToAnchor()` needs to start an attempt to scroll to the text fragment
if it exists. It must not, however, clear the uninvoked text directives,  because a
same-document navigation could happen before the document is fully loaded,
hence the target text might not be part of the DOM tree.

As per spec, a second attempt to scroll to the text fragment is done after the load
is completed. This is done by `Document::ScrollToRef()`, which is called by
`nsDocumentViewer::LoadComplete()` after the load has finished.
This call will clear the uninvoked directives.

Differential Revision: https://phabricator.services.mozilla.com/D209726
This commit is contained in:
Jan-Niklas Jaeschke 2024-05-17 12:16:00 +00:00
parent a774f79b6b
commit 8726853107
9 changed files with 91 additions and 44 deletions

View file

@ -64,6 +64,7 @@
#include "mozilla/dom/ContentFrameMessageManager.h"
#include "mozilla/dom/DocGroup.h"
#include "mozilla/dom/Element.h"
#include "mozilla/dom/FragmentDirective.h"
#include "mozilla/dom/HTMLAnchorElement.h"
#include "mozilla/dom/HTMLIFrameElement.h"
#include "mozilla/dom/PerformanceNavigation.h"
@ -8476,6 +8477,17 @@ bool nsDocShell::IsSameDocumentNavigation(nsDocShellLoadState* aLoadState,
rvURINew = aLoadState->URI()->GetHasRef(&aState.mNewURIHasRef);
}
// A Fragment Directive must be removed from the new hash in order to allow
// fallback element id scroll. Additionally, the extracted parsed text
// directives need to be stored for further use.
nsTArray<TextDirective> textDirectives;
if (FragmentDirective::ParseAndRemoveFragmentDirectiveFromFragmentString(
aState.mNewHash, &textDirectives)) {
if (Document* doc = GetDocument()) {
doc->FragmentDirective()->SetTextDirectives(std::move(textDirectives));
}
}
if (currentURI && NS_SUCCEEDED(rvURINew)) {
nsresult rvURIOld = currentURI->GetRef(aState.mCurrentHash);
if (NS_SUCCEEDED(rvURIOld)) {
@ -10701,6 +10713,24 @@ nsresult nsDocShell::ScrollToAnchor(bool aCurHasRef, bool aNewHasRef,
rootScroll->ClearDidHistoryRestore();
}
// If it's a load from history, we don't have any anchor jumping to do.
// Scrollbar position will be restored by the caller based on positions stored
// in session history.
bool scroll = aLoadType != LOAD_HISTORY && aLoadType != LOAD_RELOAD_NORMAL;
// If the load contains text directives, try to apply them. This may fail if
// the load is a same-document load that was initiated before the document was
// fully loaded and the target is not yet included in the DOM tree.
// For this case, the `uninvokedTextDirectives` are not cleared, so that
// `Document::ScrollToRef()` can re-apply the text directive.
// `Document::ScrollToRef()` is (presumably) the second "async" call mentioned
// in sec. 7.4.2.3.3 in the HTML spec, "Fragment navigations":
// https://html.spec.whatwg.org/#scroll-to-fragid:~:text=This%20algorithm%20will%20be%20called%20twice
const bool hasScrolledToTextFragment =
presShell->HighlightAndGoToTextFragment(scroll);
if (hasScrolledToTextFragment) {
return NS_OK;
}
// If we have no new anchor, we do not want to scroll, unless there is a
// current anchor and we are doing a history load. So return if we have no
// new anchor, and there is no current anchor or the load is not a history
@ -10712,11 +10742,6 @@ nsresult nsDocShell::ScrollToAnchor(bool aCurHasRef, bool aNewHasRef,
// Both the new and current URIs refer to the same page. We can now
// browse to the hash stored in the new URI.
// If it's a load from history, we don't have any anchor jumping to do.
// Scrollbar position will be restored by the caller based on positions stored
// in session history.
bool scroll = aLoadType != LOAD_HISTORY && aLoadType != LOAD_RELOAD_NORMAL;
if (aNewHash.IsEmpty()) {
// 2. If fragment is the empty string, then return the special value top of
// the document.

View file

@ -13121,6 +13121,8 @@ void Document::ScrollToRef() {
const bool didScrollToTextFragment =
presShell->HighlightAndGoToTextFragment(true);
FragmentDirective()->ClearUninvokedDirectives();
// 2. If fragment is the empty string and no text directives have been
// scrolled to, then return the special value top of the document.
if (didScrollToTextFragment || mScrollToRef.IsEmpty()) {

View file

@ -51,6 +51,21 @@ JSObject* FragmentDirective::WrapObject(JSContext* aCx,
return FragmentDirective_Binding::Wrap(aCx, this, aGivenProto);
}
bool FragmentDirective::ParseAndRemoveFragmentDirectiveFromFragmentString(
nsCString& aFragment, nsTArray<TextDirective>* aTextDirectives) {
ParsedFragmentDirectiveResult fragmentDirective;
const bool hasRemovedFragmentDirective =
StaticPrefs::dom_text_fragments_enabled() &&
parse_fragment_directive(&aFragment, &fragmentDirective);
if (hasRemovedFragmentDirective) {
aFragment = fragmentDirective.url_without_fragment_directive;
if (aTextDirectives) {
aTextDirectives->SwapElements(fragmentDirective.text_directives);
}
}
return hasRemovedFragmentDirective;
}
void FragmentDirective::ParseAndRemoveFragmentDirectiveFromFragment(
nsCOMPtr<nsIURI>& aURI, nsTArray<TextDirective>* aTextDirectives) {
if (!aURI || !StaticPrefs::dom_text_fragments_enabled()) {
@ -65,18 +80,12 @@ void FragmentDirective::ParseAndRemoveFragmentDirectiveFromFragment(
nsAutoCString hash;
aURI->GetRef(hash);
ParsedFragmentDirectiveResult fragmentDirective;
const bool hasRemovedFragmentDirective =
parse_fragment_directive(&hash, &fragmentDirective);
ParseAndRemoveFragmentDirectiveFromFragmentString(hash, aTextDirectives);
if (!hasRemovedFragmentDirective) {
return;
}
Unused << NS_MutateURI(aURI)
.SetRef(fragmentDirective.url_without_fragment_directive)
.Finalize(aURI);
if (aTextDirectives) {
aTextDirectives->SwapElements(fragmentDirective.text_directives);
}
Unused << NS_MutateURI(aURI).SetRef(hash).Finalize(aURI);
}
nsTArray<RefPtr<nsRange>> FragmentDirective::FindTextFragmentsInDocument() {
@ -88,7 +97,6 @@ nsTArray<RefPtr<nsRange>> FragmentDirective::FindTextFragmentsInDocument() {
textDirectiveRanges.AppendElement(range);
}
}
mUninvokedTextDirectives.Clear();
return textDirectiveRanges;
}

View file

@ -72,6 +72,9 @@ class FragmentDirective final : public nsISupports, public nsWrapperCache {
return !mUninvokedTextDirectives.IsEmpty();
};
/** Clears all uninvoked directives. */
void ClearUninvokedDirectives() { mUninvokedTextDirectives.Clear(); }
/** Searches for the current uninvoked text directives and creates a range for
* each one that is found.
*
@ -94,6 +97,17 @@ class FragmentDirective final : public nsISupports, public nsWrapperCache {
nsCOMPtr<nsIURI>& aURI,
nsTArray<TextDirective>* aTextDirectives = nullptr);
/** Parses the fragment directive and removes it from the hash, given as
* string. This operation happens in-place.
*
* This function is called internally by
* `ParseAndRemoveFragmentDirectiveFromFragment()`.
*
* This function returns true if it modified `aFragment`.
*/
static bool ParseAndRemoveFragmentDirectiveFromFragmentString(
nsCString& aFragment, nsTArray<TextDirective>* aTextDirectives = nullptr);
private:
RefPtr<nsRange> FindRangeForTextDirective(
const TextDirective& aTextDirective);

View file

@ -2,11 +2,8 @@
[Text fragment specified in iframe.src]
expected: FAIL
[Navigate same-origin iframe via window.location]
expected: FAIL
[Cross-origin with element-id fallback]
expected: FAIL
[Non-matching text with element-id fallback]
[Navigate cross-origin iframe via window.location]
expected: FAIL

View file

@ -1,18 +1,3 @@
[percent-encoding.html]
[Test navigation with fragment: Percent char without hex digits is invalid..]
expected: FAIL
[Test navigation with fragment: Percent char followed by percent char is invalid..]
expected: FAIL
[Test navigation with fragment: Single digit percent-encoding is invalid..]
expected: FAIL
[Test navigation with fragment: Percent-encoding limited to two digits..]
expected: FAIL
[Test navigation with fragment: Percent-encoded "%%F".]
expected: FAIL
[Test navigation with fragment: Percent-encoding multibyte codepoint (CHECKMARK)..]
expected: FAIL

View file

@ -1,8 +0,0 @@
[same-document-tests.html]
expected:
[OK, TIMEOUT]
[Basic element id fallback]
expected: FAIL
[Malformed text directive element id fallback]
expected: [FAIL, TIMEOUT]

View file

@ -1,3 +0,0 @@
[scroll-to-text-fragment-same-doc.html]
[Activated for same-document window.location.replace]
expected: FAIL

View file

@ -0,0 +1,27 @@
<!doctype html>
<title>Same document navigation to text fragment directives before loading the document has finished</title>
<meta charset=utf-8>
<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="resources/util.js"></script>
<script>
// Ensure that a same-document text directive navigation works correctly
// if the navigation is triggered before the page finishes loading.
promise_test(async t => {
assert_implements(document.fragmentDirective, 'Text directive not implemented');
location.hash = ':~:text=line%20of%20text';
await t.step_wait(() => window.scrollY > 0, "Wait for scroll");
assert_true(isInViewport(document.getElementById('text')), 'Scrolled to text');
}, 'Same-document text directive navigation before loading the document has finished');
</script>
<style>
div {
margin: 200vh 0 200vh 0;
}
</style>
<div id="text">
This is a line of text.
</div>