Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/validation/iframe-cross-document-history-267.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Nested cross-document history traversal contract

## Scope and baseline

This #267/#264 continuation started from exact WebScene `main`
`497b4dbbd6e5b576bbc07428e9b6e586a656f36e`, immediately after merged
#589/#590 restricted cross-origin WindowProxy behavior. Before the final local
commit it was rebased onto current `main`
`47dd9afc39748fa0adc804c7aaa567d40d71fc43` after #591/#592 merged.

The existing nested History model retained bounded same-document entries and
state across reload, but every ordinary child document navigation discarded
that list. Consequently `back()`, `forward()` and `go()` could only move among
hash/state entries belonging to the current Document. This slice extends the
same per-frame list across active-document replacement without introducing a
page cache.

## Bounded active-document traversal

Each retained entry now carries a logical document sequence. `pushState()` and
`replaceState()` retain the current sequence; an admitted child navigation
creates a new sequence and a null-state entry. Forward entries are discarded
before a new navigation. The existing limits remain authoritative: at most
1,024 entries, 8 KiB per URL, 1 MiB per cloned state and 16 MiB of cloned state
per child. When the entry cap is reached, the oldest entry and its state bytes
are retired.

Traversal within one sequence preserves the active realm and continues to
dispatch `popstate` and fragment-sensitive `hashchange`. Traversal to another
sequence runs the existing guarded `beforeunload`, `pagehide`, hidden
`visibilitychange` and `unload` path, cancels outgoing realm work, loads a new
Document, restores the selected entry/state into its History object, dispatches
`load`, then `popstate`. The owner WindowProxy stays stable, the iframe `src`
content attribute stays authored, and stale History objects cannot schedule a
second traversal. `Location.replace()` and `document.open()` replace the
current entry rather than appending one.

This is active-document reconstruction. No outgoing Document, JavaScript heap,
scroll position, form state or resource response is cached.

## Authored contracts

- `tests/WebPlatformSubset/contracts/iframe-cross-document-history-traversal.html`
derives its queued traversal/state boundary from pinned WPT
`html/browsers/history/the-history-interface/traverse_the_history_1.html`
and the active-document cases under
`html/browsers/browsing-the-web/history-traversal/`, at
`2c705104a295c48053eeddf7fe0170d790a4e853`.
- The `iframe-navigation-lifecycle` native source gate covers A-to-B entry
creation, back/forward resource admission, fresh Document identity, cloned
state/null-state restoration, lifecycle order, post-load `popstate`, stable
WindowProxy, reflected `src` preservation and exact request count.

Joint top-level/child session-history ordering, bfcache and `pageshow.persisted`,
POST resubmission, scroll/form persisted user state, failed traversal recovery,
redirect replacement and history traversal across opaque origins remain
deferred.

## Validation status

Per the task constraint, no build, browser/WPT run, native test, benchmark,
computer-use flow, package gate or CI job was run. Only `git diff --check` is
used for this commit. The authored profile and native assertions remain
unexecuted and are deferred to the cumulative #264/#267 validation pass.
Original file line number Diff line number Diff line change
Expand Up @@ -2500,6 +2500,8 @@
return restore_outer(frame_hydration_result::failed);
}
perform_microtask_checkpoint();
complete_nested_cross_document_history_traversal(
frame, local_frame_context);
frame_last_error_value.clear();
static_cast<void>(frame);
return restore_outer(frame_hydration_result::complete);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2685,6 +2685,62 @@
frame_unload_dispatches.erase(frame.id);
}

void record_nested_cross_document_navigation(
dom_node& frame,
const std::optional<std::string>& requested_source,
bool replace)
{
constexpr size_t maximum_history_entries = 1024U;
constexpr size_t maximum_history_url_bytes = 8192U;
auto history = nested_history_states.find(frame.id);
if (history == nested_history_states.end()) return;
auto& state = history->second;
if (state.entries.empty() || state.index >= state.entries.size()) return;

const auto owner_base =
effective_document_base(css_cascade_root_for_node(frame));
auto target = requested_source.has_value()
&& !requested_source->empty()
? resolve_resource_url(*requested_source, owner_base)
: std::string("about:blank");
if (target.size() > maximum_history_url_bytes) target.clear();

const auto reclaim = [&](size_t first) {
for (auto index = first; index < state.entries.size(); ++index) {
if (state.entries[index].state.has_value()) {
state.state_bytes -= clone_packet_size(
*state.entries[index].state);
}
}
state.entries.erase(
state.entries.begin() + static_cast<std::ptrdiff_t>(first),
state.entries.end());
};
const auto document_sequence = ++state.next_document_sequence;
if (replace) {
if (state.entries[state.index].state.has_value()) {
state.state_bytes -= clone_packet_size(
*state.entries[state.index].state);
}
state.entries[state.index] = history_entry_state{
std::move(target), std::nullopt, document_sequence};
} else {
reclaim(state.index + 1U);
if (state.entries.size() >= maximum_history_entries) {
if (state.entries.front().state.has_value()) {
state.state_bytes -= clone_packet_size(
*state.entries.front().state);
}
state.entries.erase(state.entries.begin());
if (state.index != 0U) --state.index;
}
state.entries.push_back(history_entry_state{
std::move(target), std::nullopt, document_sequence});
state.index = state.entries.size() - 1U;
}
state.traversing = false;
}

bool begin_iframe_navigation(dom_node& frame)
{
// The initiating navigation owns the whole unload sequence. A handler
Expand Down Expand Up @@ -2729,13 +2785,20 @@
retire_service_worker_clients(
std::vector<v8::Local<v8::Context>>{outgoing_context});
}
if (frame_reload_navigations.contains(frame.id)) {
if (frame_history_traversals.contains(frame.id)) {
// History traversal selected the retained entry before initiating
// this replacement. Keep the list and its traversal guard alive
// until the replacement realm dispatches popstate.
} else if (frame_reload_navigations.contains(frame.id)) {
if (auto history = nested_history_states.find(frame.id);
history != nested_history_states.end()) {
history->second.traversing = false;
}
} else {
nested_history_states.erase(frame.id);
const auto replace =
frame_history_replace_navigations.erase(frame.id) != 0U;
record_nested_cross_document_navigation(
frame, requested_source, replace);
}
{
std::lock_guard lock(host_request_mutex);
Expand Down Expand Up @@ -2830,6 +2893,8 @@
}
nested_history_states.erase(frame_id);
frame_reload_navigations.erase(frame_id);
frame_history_traversals.erase(frame_id);
frame_history_replace_navigations.erase(frame_id);
frame_reload_reflected_sources.erase(frame_id);
}

Expand Down Expand Up @@ -5795,9 +5860,11 @@
auto address = navigation_address_for_root(static_cast<dom_node*>(
local_context->GetAlignedPointerFromEmbedderData(
1, v8::kEmbedderDataTypeTagDefault)));
constexpr size_t maximum_history_url_bytes = 8192U;
if (address.size() > maximum_history_url_bytes) address.clear();
auto inserted = nested_history_states.try_emplace(frame->id).first;
inserted->second.entries.push_back(
history_entry_state{std::move(address), std::nullopt});
history_entry_state{std::move(address), std::nullopt, 1U});
return &inserted->second;
}

Expand Down Expand Up @@ -6043,14 +6110,19 @@
history->SetPrivate(
local_context, history_state_key(isolate), state).Check();
if (nested_history != nullptr) {
const auto document_sequence =
nested_history->entries[nested_history->index]
.document_sequence;
nested_history->state_bytes -= reclaimed_state_bytes;
if (replace) {
nested_history->entries[nested_history->index] =
history_entry_state{resolved, std::move(packet)};
history_entry_state{
resolved, std::move(packet), document_sequence};
} else {
nested_history->entries.resize(retained_nested_entries);
nested_history->entries.push_back(
history_entry_state{resolved, std::move(packet)});
history_entry_state{
resolved, std::move(packet), document_sequence});
nested_history->index = nested_history->entries.size() - 1U;
}
nested_history->state_bytes += clone_packet_size(
Expand Down Expand Up @@ -6106,8 +6178,34 @@
}

const auto frame_id = frame->id;
const auto previous_index = history->index;
const auto old_url = current_navigation_address();
const auto entry = history->entries[static_cast<size_t>(target)];
const auto cross_document = entry.document_sequence
!= history->entries[history->index].document_sequence;
if (cross_document) {
const auto authored_source = frame->attributes.find("src");
frame_reload_reflected_sources.insert_or_assign(
frame_id,
authored_source == frame->attributes.end()
? std::nullopt
: std::optional<std::string>{authored_source->second});
history->index = static_cast<size_t>(target);
frame->attributes["src"] = entry.url;
frame_history_traversals.insert(frame_id);
if (entry.url.empty() || !begin_iframe_navigation(*frame)) {
if (auto current = nested_history_states.find(frame_id);
current != nested_history_states.end()) {
current->second.index = previous_index;
current->second.traversing = false;
}
frame_history_traversals.erase(frame_id);
restore_nested_reload_reflected_source(*frame);
return;
}
enqueue_iframe_hydration_if_needed(*frame);
return;
}
v8::Local<v8::Value> state = v8::Null(isolate);
if (entry.state.has_value()
&& !deserialize_clone(
Expand Down Expand Up @@ -6145,6 +6243,37 @@
"web-api-binding");
}

void complete_nested_cross_document_history_traversal(
dom_node& frame,
v8::Local<v8::Context> local_context)
{
if (frame_history_traversals.erase(frame.id) == 0U) return;
auto history = nested_history_states.find(frame.id);
if (history == nested_history_states.end()
|| history->second.index >= history->second.entries.size()) {
return;
}
auto& state = history->second;
v8::Local<v8::Value> restored_state = v8::Null(isolate);
const auto& entry = state.entries[state.index];
if (entry.state.has_value()
&& !deserialize_clone(
isolate, local_context, *entry.state).ToLocal(&restored_state)) {
state.traversing = false;
return;
}
dispatch_same_document_history_event(
local_context, "popstate", restored_state);
if (auto current = nested_history_states.find(frame.id);
current != nested_history_states.end()) {
current->second.traversing = false;
}
record_feature(
"web-api", "History.go", "partially-supported",
"bounded nested cross-document traversal with active-realm replacement",
"web-api-binding");
}

static void history_traversal_task(
const v8::FunctionCallbackInfo<v8::Value>& info)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,11 @@
return false;
}
frame.attributes["src"] = resolved;
if (!begin_iframe_navigation(frame)) return false;
if (replace) frame_history_replace_navigations.insert(frame.id);
if (!begin_iframe_navigation(frame)) {
frame_history_replace_navigations.erase(frame.id);
return false;
}
enqueue_iframe_hydration_if_needed(frame);
record_feature(
"web-api",
Expand Down Expand Up @@ -1123,7 +1127,11 @@
auto* self = current(info.GetIsolate());
auto* node = provisional_frame_owner(info.This());
if (node == nullptr) return;
if (!self->begin_iframe_navigation(*node)) return;
self->frame_history_replace_navigations.insert(node->id);
if (!self->begin_iframe_navigation(*node)) {
self->frame_history_replace_navigations.erase(node->id);
return;
}
node->attributes["frame-html"].clear();
auto* target = unwrap_node(info.This());
if (target != nullptr && target->tag == "html") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,8 @@
hidden_frame_document_roots.clear();
nested_history_states.clear();
frame_reload_navigations.clear();
frame_history_traversals.clear();
frame_history_replace_navigations.clear();
frame_reload_reflected_sources.clear();
frame_event_listeners.clear();
window_selections.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@
hidden_frame_document_roots.clear();
nested_history_states.clear();
frame_reload_navigations.clear();
frame_history_traversals.clear();
frame_history_replace_navigations.clear();
frame_reload_reflected_sources.clear();
frame_event_listeners.clear();
window_selections.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
std::unordered_set<uint32_t> hidden_frame_document_roots;
std::unordered_map<uint32_t, nested_history_state> nested_history_states;
std::unordered_set<uint32_t> frame_reload_navigations;
std::unordered_set<uint32_t> frame_history_traversals;
std::unordered_set<uint32_t> frame_history_replace_navigations;
std::unordered_map<uint32_t, std::optional<std::string>>
frame_reload_reflected_sources;
std::vector<std::shared_future<std::shared_ptr<const prepared_frame>>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,14 @@
struct history_entry_state final {
std::string url;
std::optional<clone_packet> state;
uint64_t document_sequence{1U};
};

struct nested_history_state final {
std::vector<history_entry_state> entries;
size_t index{};
size_t state_bytes{};
uint64_t next_document_sequence{1U};
bool traversing{false};
};

Expand Down
Loading
Loading