← All issues

[NavigationScheduler] history.back/forward/go(n) calls don't coalesce per spec when queued synchronously

04c8ceb

NavigationScheduler is WebCore's per-frame pending-navigation slot: it holds one ScheduledNavigation in m_redirect and fires it at the next task boundary. The HTML spec, however, models history traversals as tasks on a single session-history-traversal queue keyed by the top-level traversable, so all history.back/forward/go calls queued synchronously across any frame must coalesce into one net delta before anything navigates. WebKit had no such queue: each scheduleHistoryNavigation() called schedule(), which called cancel() and replaced m_redirect, so a second call silently evicted the first.

Source/WebCore/loader/NavigationScheduler.cpp

ScheduledNavigation::AccumulateResult ScheduledHistoryNavigation::accumulateHistorySteps(int additionalSteps)
{
if (!additionalSteps) // go(0) is reload, not a delta
return AccumulateResult::NotHandled;
m_steps += additionalSteps;
m_historyItem = nullptr; // recompute target at fire() time
return AccumulateResult::Handled;
}
 
void NavigationScheduler::scheduleHistoryNavigation(Frame& originatingFrame, int steps)
{
if (auto* topLocalFrame = topLevelLocalFrame(); topLocalFrame != &m_frame) {
originatingFrame.loader().completed();
cancel();
topLocalFrame->navigationScheduler().scheduleHistoryNavigation(originatingFrame, steps);
return;
}
if (m_redirect) {
auto result = m_redirect->accumulateHistorySteps(steps);
if (result == ScheduledNavigation::AccumulateResult::Handled) {
if (!m_redirect->steps()) // net delta == 0: going nowhere
cancel();
return;
}
}
schedule(makeUnique<ScheduledHistoryNavigation>(steps));
}

The fix adds an accumulateHistorySteps virtual hook so steps accumulate numerically onto the already-pending ScheduledHistoryNavigation rather than replacing it, and forwards all subframe history traversals to the top-level LocalFrame's scheduler so iframe and main-frame calls coalesce at the correct scope. back();forward() now nets to zero and cancel()s instead of firing a spurious navigation; back();back() accumulates to -2 and fires once, skipping the intermediate entry. go(0) is explicitly excluded to preserve reload semantics.

This rewires how WebKit dispatches and merges cross-frame history navigations — a privileged path that determines which documents load, when load events fire, and how the back-forward list advances. The new iframe-scheduler-to-top-frame forwarding path and the step-accumulation boundary conditions around same-document traversals are novel logic on a security-sensitive control plane.

🔒

New cross-frame forwarding and step-accumulation paths introduce several boundary conditions across the same-document/cross-document divide — audit directions included.

Subscribe to read more