diff --git a/docs/validation/iframe-cross-document-history-267.md b/docs/validation/iframe-cross-document-history-267.md
new file mode 100644
index 00000000..a95795a3
--- /dev/null
+++ b/docs/validation/iframe-cross-document-history-267.md
@@ -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.
diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc
index 15a692b3..e8fdfa81 100644
--- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc
+++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc
@@ -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(frame);
return restore_outer(frame_hydration_result::complete);
diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc
index f3ddf527..6a29be96 100644
--- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc
+++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc
@@ -2685,6 +2685,62 @@
frame_unload_dispatches.erase(frame.id);
}
+ void record_nested_cross_document_navigation(
+ dom_node& frame,
+ const std::optional& 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(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
@@ -2729,13 +2785,20 @@
retire_service_worker_clients(
std::vector>{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);
@@ -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);
}
@@ -5795,9 +5860,11 @@
auto address = navigation_address_for_root(static_cast(
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;
}
@@ -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(
@@ -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(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{authored_source->second});
+ history->index = static_cast(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 state = v8::Null(isolate);
if (entry.state.has_value()
&& !deserialize_clone(
@@ -6145,6 +6243,37 @@
"web-api-binding");
}
+ void complete_nested_cross_document_history_traversal(
+ dom_node& frame,
+ v8::Local 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 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& info)
{
diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc
index 153e9276..7e58f319 100644
--- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc
+++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc
@@ -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",
@@ -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") {
diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc
index 898ff49f..6b1c6217 100644
--- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc
+++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc
@@ -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();
diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc
index e8fdfc35..fa004e04 100644
--- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc
+++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc
@@ -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();
diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc
index 327b305a..624cd5bd 100644
--- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc
+++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc
@@ -36,6 +36,8 @@
std::unordered_set hidden_frame_document_roots;
std::unordered_map nested_history_states;
std::unordered_set frame_reload_navigations;
+ std::unordered_set frame_history_traversals;
+ std::unordered_set frame_history_replace_navigations;
std::unordered_map>
frame_reload_reflected_sources;
std::vector>>
diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc
index 5bd760fa..04a28549 100644
--- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc
+++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc
@@ -90,12 +90,14 @@
struct history_entry_state final {
std::string url;
std::optional state;
+ uint64_t document_sequence{1U};
};
struct nested_history_state final {
std::vector entries;
size_t index{};
size_t state_bytes{};
+ uint64_t next_document_sequence{1U};
bool traversing{false};
};
diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_resource_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_resource_tests.inc
index 1ac122aa..68b1e005 100644
--- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_resource_tests.inc
+++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_resource_tests.inc
@@ -144,6 +144,30 @@ void test_same_origin_iframe_navigation_document_replacement()
});
}
+ a
+ b"
"
+
+