LibWeb: Resume intercepted history traversals

When a traverse navigate event is intercepted, queue the resume step
specified by the Navigation API. This keeps intercepted traversals from
stopping at URL update time.

Do not resume that queued step if the intercepted NavigateEvent was
aborted or replaced by a newer ongoing event before it runs. Add tests
for the basic intercepted traverse case and the superseded intercepted
traverse case.
This commit is contained in:
Andreas Kling 2026-06-05 21:57:11 +02:00 committed by Andreas Kling
parent e356fd8188
commit 3ec9733efe
8 changed files with 241 additions and 9 deletions

View file

@ -1142,14 +1142,48 @@ bool Navigation::inner_navigate_event_firing_algorithm(
// promise as handled at the equivalent place.
WebIDL::mark_promise_as_handled(*m_transition->committed());
// 6. If navigationType is "traverse", then set navigation's suppress normal scroll restoration during ongoing navigation to true.
// NOTE: If event's scroll behavior was set to "after-transition", then scroll restoration will happen as part of finishing
// the relevant NavigateEvent. Otherwise, there will be no scroll restoration. That is, no navigation which is intercepted
// by intercept() goes through the normal scroll restoration process; scroll restoration for such navigations
// is either done manually, by the web developer, or is done after the transition.
if (navigation_type == Bindings::NavigationType::Traverse)
// Switch on event's navigationType:
// - "traverse":
if (navigation_type == Bindings::NavigationType::Traverse) {
// 1. Set navigation's suppress normal scroll restoration during ongoing navigation to true.
// NOTE: If event's scroll behavior was set to "after-transition", then scroll restoration will happen as part of finishing
// the relevant NavigateEvent. Otherwise, there will be no scroll restoration. That is, no navigation which is intercepted
// by intercept() goes through the normal scroll restoration process; scroll restoration for such navigations
// is either done manually, by the web developer, or is done after the transition.
m_suppress_scroll_restoration_during_ongoing_navigation = true;
// 2. Let userInvolvement be "none".
auto user_involvement_for_resume = UserNavigationInvolvement::None;
// 3. If event's userInitiated is true, then set userInvolvement to "activation".
if (event->user_initiated())
user_involvement_for_resume = UserNavigationInvolvement::Activation;
// NOTE: At this point after interception, it is not consequential whether the
// activation was a result of browser UI.
// 4. Append the following session history traversal steps to navigable's traversable navigable:
auto destination_entry = event->destination()->navigation_history_entry();
VERIFY(destination_entry);
auto target_step = destination_entry->session_history_entry().step().get<int>();
auto traversable = navigable->traversable_navigable();
traversable->append_session_history_traversal_steps(GC::create_function(heap(), [this, event, traversable, target_step, user_involvement_for_resume](NonnullRefPtr<Core::Promise<Empty>> signal) {
// NB: This appended step can run after a later navigation has aborted the intercepted
// traverse. In that case, the aborted traverse must not be resumed.
if (event->abort_controller()->signal()->aborted() || event != m_ongoing_navigate_event) {
signal->resolve({});
return;
}
// 1. Resume applying the traverse history step given event's destination's entry's session history entry's step,
// navigable's traversable navigable, and userInvolvement.
traversable->resume_applying_the_traverse_history_step(target_step, user_involvement_for_resume,
GC::create_function(traversable->heap(), [signal](HistoryStepResult) {
signal->resolve({});
}));
}));
}
// 7. If navigationType is "push" or "replace", then run the URL and history update steps given document and
// event's destination's URL, with serializedData set to event's classic history API state and historyHandling
// set to navigationType.

View file

@ -1235,9 +1235,9 @@ public:
// 2. Let targetEntry be the result of getting the target history entry given traversable and targetStep.
m_target_entry = m_traversable->get_the_target_history_entry(target_step.value());
// 3. If targetEntry is not traversable's current session history entry, and targetEntry's document state's origin is not the same as
// traversable's current session history entry's document state's origin, then:
if (m_target_entry != m_traversable->current_session_history_entry() && m_target_entry->document_state()->origin() != m_traversable->current_session_history_entry()->document_state()->origin()) {
// 3. If targetEntry is not traversable's current session history entry, and targetEntry's document state's origin is the same as
// traversable's current session history entry's document state's origin:
if (m_target_entry != m_traversable->current_session_history_entry() && m_target_entry->document_state()->origin() == m_traversable->current_session_history_entry()->document_state()->origin()) {
// 1. Let eventsFired be false.
@ -1620,6 +1620,18 @@ void TraversableNavigable::apply_the_traverse_history_step(int step, GC::Ptr<Sou
apply_the_history_step(step, true, source_snapshot_params, initiator_to_check, user_involvement, Bindings::NavigationType::Traverse, SynchronousNavigation::No, nullptr, on_complete);
}
// https://html.spec.whatwg.org/multipage/browsing-the-web.html#resume-applying-the-traverse-history-step
void TraversableNavigable::resume_applying_the_traverse_history_step(int step, UserNavigationInvolvement user_involvement, GC::Ref<GC::Function<void(HistoryStepResult)>> on_complete)
{
// To resume applying the traverse history step given a non-negative integer step, a traversable
// navigable traversable, and user navigation involvement userInvolvement, apply step to
// traversable given false, null, null, userInvolvement, and "traverse".
// NOTE: When resuming a traverse, we are already past the cancelation, initiator, and
// source snapshot checks, and this traversal has already been determined to be a
// same-document traversal. Hence, we can pass false and null for those arguments.
apply_the_history_step(step, false, {}, {}, user_involvement, Bindings::NavigationType::Traverse, SynchronousNavigation::No, nullptr, on_complete);
}
// https://html.spec.whatwg.org/multipage/document-sequences.html#close-a-top-level-traversable
void TraversableNavigable::close_top_level_traversable()
{

View file

@ -60,6 +60,7 @@ public:
HistoryObjectLengthAndIndex get_the_history_object_length_and_index(int) const;
void apply_the_traverse_history_step(int, GC::Ptr<SourceSnapshotParams>, GC::Ptr<Navigable>, UserNavigationInvolvement, GC::Ref<GC::Function<void(HistoryStepResult)>> on_complete);
void resume_applying_the_traverse_history_step(int, UserNavigationInvolvement, GC::Ref<GC::Function<void(HistoryStepResult)>> on_complete);
void apply_the_reload_history_step(UserNavigationInvolvement, GC::Ref<GC::Function<void(HistoryStepResult)>> on_complete);
enum class SynchronousNavigation : bool {
Yes,

View file

@ -18,6 +18,8 @@ Text/input/css/font-face-load-dedups-same-url.html
; pushState with path URL requires HTTP(s) scheme.
Text/input/navigation/history-replace-push-then-back.html
Text/input/navigation/intercepted-traverse-updates-history-entry.html
Text/input/navigation/aborted-intercepted-traverse-does-not-resume.html
; CSS @font-face url() requires HTTP(s) scheme.
Text/input/selection-rect-consistency-with-kerning.html

View file

@ -0,0 +1,9 @@
navigate type=traverse destination=/r/programming current=/r/programming/comments/post location=/r/programming/comments/post
navigateerror current=/r/programming/comments/post location=/r/programming/comments/post
navigate type=push destination=/r/programming/comments/post#other current=/r/programming/comments/post location=/r/programming/comments/post
currententrychange type=push current=/r/programming/comments/post#other location=/r/programming/comments/post#other
navigatesuccess current=/r/programming/comments/post#other location=/r/programming/comments/post#other
navigate type=traverse destination=/r/programming/comments/post current=/r/programming/comments/post#other location=/r/programming/comments/post#other
currententrychange type=traverse current=/r/programming/comments/post location=/r/programming/comments/post
navigatesuccess current=/r/programming/comments/post location=/r/programming/comments/post
final current=/r/programming/comments/post location=/r/programming/comments/post

View file

@ -0,0 +1,4 @@
navigate destination=/r/programming current=/r/programming/comments/post location=/r/programming/comments/post
handler current=/r/programming/comments/post location=/r/programming/comments/post
navigatesuccess current=/r/programming/comments/post location=/r/programming/comments/post
currententrychange current=/r/programming location=/r/programming

View file

@ -0,0 +1,105 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<script>
asyncTest(done => {
let sawTraverseNavigate = false;
let sawVerificationCurrentEntryChange = false;
let sawVerificationNavigateSuccess = false;
let traverseNavigateCount = 0;
let startedVerificationBack = false;
let finished = false;
function pathAndHashFromURL(url) {
const parsed = new URL(url);
return `${parsed.pathname}${parsed.hash}`;
}
function locationPathAndHash() {
return `${location.pathname}${location.hash}`;
}
function maybeDone() {
if (finished)
return;
if (!sawTraverseNavigate || !sawVerificationCurrentEntryChange || !sawVerificationNavigateSuccess)
return;
if (locationPathAndHash() !== "/r/programming/comments/post" || pathAndHashFromURL(navigation.currentEntry.url) !== "/r/programming/comments/post")
return;
finished = true;
println(`final current=${pathAndHashFromURL(navigation.currentEntry.url)} location=${locationPathAndHash()}`);
done();
}
navigation.addEventListener("navigate", event => {
if (!sawTraverseNavigate && event.navigationType !== "traverse")
return;
println(`navigate type=${event.navigationType} destination=${pathAndHashFromURL(event.destination.url)} current=${pathAndHashFromURL(navigation.currentEntry.url)} location=${locationPathAndHash()}`);
if (event.navigationType !== "traverse")
return;
++traverseNavigateCount;
if (traverseNavigateCount !== 1)
return;
sawTraverseNavigate = true;
event.intercept({
handler() {
println(`unexpected traverse handler current=${pathAndHashFromURL(navigation.currentEntry.url)} location=${locationPathAndHash()}`);
},
});
Promise.resolve().then(() => {
navigation.navigate("#other", { history: "push" });
});
});
navigation.addEventListener("currententrychange", event => {
if (!sawTraverseNavigate)
return;
println(`currententrychange type=${event.navigationType} current=${pathAndHashFromURL(navigation.currentEntry.url)} location=${locationPathAndHash()}`);
if (event.navigationType === "traverse" && traverseNavigateCount < 2) {
done();
return;
}
if (event.navigationType === "traverse")
sawVerificationCurrentEntryChange = true;
maybeDone();
});
navigation.addEventListener("navigatesuccess", () => {
if (!sawTraverseNavigate)
return;
println(`navigatesuccess current=${pathAndHashFromURL(navigation.currentEntry.url)} location=${locationPathAndHash()}`);
if (!startedVerificationBack && locationPathAndHash() === "/r/programming/comments/post#other") {
startedVerificationBack = true;
history.back();
return;
}
if (startedVerificationBack && locationPathAndHash() === "/r/programming/comments/post")
sawVerificationNavigateSuccess = true;
maybeDone();
});
navigation.addEventListener("navigateerror", () => {
if (!sawTraverseNavigate)
return;
println(`navigateerror current=${pathAndHashFromURL(navigation.currentEntry.url)} location=${locationPathAndHash()}`);
});
history.replaceState({}, "", "/r/programming");
history.pushState({}, "", "/r/programming/comments/post");
history.back();
});
</script>

View file

@ -0,0 +1,65 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<script>
asyncTest(done => {
let sawTraverseNavigate = false;
let sawTraverseHandler = false;
let sawTraverseCurrentEntryChange = false;
let sawNavigateSuccess = false;
let finished = false;
function pathFromURL(url) {
return new URL(url).pathname;
}
function maybeDone() {
if (finished)
return;
if (!sawTraverseNavigate || !sawTraverseHandler || !sawTraverseCurrentEntryChange || !sawNavigateSuccess)
return;
if (location.pathname !== "/r/programming" || pathFromURL(navigation.currentEntry.url) !== "/r/programming")
return;
finished = true;
done();
}
navigation.addEventListener("navigate", event => {
if (event.navigationType !== "traverse")
return;
sawTraverseNavigate = true;
println(`navigate destination=${pathFromURL(event.destination.url)} current=${pathFromURL(navigation.currentEntry.url)} location=${location.pathname}`);
event.intercept({
handler() {
sawTraverseHandler = true;
println(`handler current=${pathFromURL(navigation.currentEntry.url)} location=${location.pathname}`);
maybeDone();
},
});
});
navigation.addEventListener("currententrychange", event => {
if (event.navigationType !== "traverse")
return;
sawTraverseCurrentEntryChange = true;
println(`currententrychange current=${pathFromURL(navigation.currentEntry.url)} location=${location.pathname}`);
maybeDone();
});
navigation.addEventListener("navigatesuccess", () => {
if (!sawTraverseNavigate || !sawTraverseHandler)
return;
sawNavigateSuccess = true;
println(`navigatesuccess current=${pathFromURL(navigation.currentEntry.url)} location=${location.pathname}`);
maybeDone();
});
history.replaceState({}, "", "/r/programming");
history.pushState({}, "", "/r/programming/comments/post");
history.back();
});
</script>