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() }); } )HTML"; + constexpr std::string_view cross_document_history_a = R"HTML( +
a
)HTML"; + constexpr std::string_view cross_document_history_b = R"HTML( +
b
)HTML"; resource_server server{.content = { {std::string{address}, "frame navigation"}, {"https://iframe-navigation.test/fake.html?id=markdown-preview", @@ -181,6 +205,12 @@ void test_same_origin_iframe_navigation_document_replacement() std::string{history_reload_page}}, {"https://iframe-navigation.test/fake.html?id=history-reload#two", std::string{history_reload_page}}, + {"https://iframe-navigation.test/fake.html?id=history-a", + std::string{cross_document_history_a}}, + {"https://iframe-navigation.test/fake.html?id=history-a#saved", + std::string{cross_document_history_a}}, + {"https://iframe-navigation.test/fake.html?id=history-b", + std::string{cross_document_history_b}}, {"https://cross-origin.test/fake.html", "Cross origin" ""} @@ -634,6 +664,90 @@ void test_same_origin_iframe_navigation_document_replacement() require(server.requests.load(std::memory_order_relaxed) == requests_before_nested_history + 2, "nested traversal or unload reentrancy admitted an extra resource request"); + const auto requests_before_cross_document_history = server.requests.load( + std::memory_order_relaxed); + execute(engine, R"JS( + globalThis.__crossDocumentHistory = { + complete:false,error:null,loads:[],popstates:[],lifecycle:[], + stable:true,restoredA:false,restoredB:false,reflectedSource:false + }; + (() => { + const state=__crossDocumentHistory; + const frame=document.createElement('iframe'); + frame.setAttribute('sandbox','allow-same-origin allow-scripts'); + frame.src='./fake.html?id=history-a'; + document.body.appendChild(frame); + const proxy=frame.contentWindow; + let firstA=null,firstB=null; + globalThis.__crossDocumentHistoryLifecycle=(page,type,event,source)=>{ + state.lifecycle.push(`${page}:${type}`); + state.stable=state.stable && frame.contentWindow===proxy + && source===frame.contentDocument; + }; + globalThis.__crossDocumentHistoryPopstate=(page,value,source)=>{ + state.popstates.push(`${page}:${value?.page||'null'}`); + state.stable=state.stable && frame.contentWindow===proxy + && source===frame.contentDocument; + if(page==='a'){ + state.restoredA=value?.page==='a' && source!==firstA + && proxy.location.hash==='#saved' && proxy.history.length===2; + setTimeout(()=>proxy.history.forward(),0); + }else{ + state.restoredB=value===null && source!==firstB + && proxy.history.length===2; + setTimeout(()=>{ + delete globalThis.__crossDocumentHistoryLifecycle; + delete globalThis.__crossDocumentHistoryPopstate; + frame.remove();state.complete=true; + },0); + } + }; + proxy.addEventListener('DOMContentLoaded',event=>{ + try{ + const page=event.target.querySelector('#page')?.textContent; + state.loads.push(page); + state.stable=state.stable && frame.contentWindow===proxy + && frame.contentDocument===event.target; + if(state.loads.length===1){ + firstA=event.target; + proxy.history.replaceState({page:'a'},'','#saved'); + frame.src='./fake.html?id=history-b'; + }else if(state.loads.length===2){ + firstB=event.target; + if(proxy.history.length!==2 || proxy.history.state!==null) + throw new Error('page b history did not retain two entries'); + proxy.history.back(); + }else if(state.loads.length===3){ + state.reflectedSource= + frame.getAttribute('src')==='./fake.html?id=history-b'; + } + }catch(error){ + state.error=String(error&&error.stack||error); + frame.remove();state.complete=true; + } + }); + })(); + )JS", "iframe-cross-document-history.js"); + require(evaluate_until_equals(engine, "__crossDocumentHistory.complete", + "iframe-cross-document-history-complete.js", "true", 4'000) == "true", + "nested cross-document history contract timed out: " + + evaluate(engine, "JSON.stringify(__crossDocumentHistory)", + "iframe-cross-document-history-timeout-detail.js")); + require(evaluate(engine, R"JS((()=>{const s=__crossDocumentHistory; + return s.error===null && s.stable && s.restoredA && s.restoredB + && s.reflectedSource && s.loads.join(',')==='a,b,a,b' + && s.popstates.join(',')==='a:a,b:null' + && s.lifecycle.join(',')=== + 'a:beforeunload,a:pagehide,a:visibilitychange,a:unload,'+ + 'b:beforeunload,b:pagehide,b:visibilitychange,b:unload,'+ + 'a:beforeunload,a:pagehide,a:visibilitychange,a:unload'; + })())JS", "iframe-cross-document-history-result.js") == "true", + "nested cross-document history diverged: " + + evaluate(engine, "JSON.stringify(__crossDocumentHistory)", + "iframe-cross-document-history-result-detail.js")); + require(server.requests.load(std::memory_order_relaxed) + == requests_before_cross_document_history + 4, + "nested cross-document traversal admitted an unexpected resource request"); execute(engine, R"JS( globalThis.__iframeLifecycleGate = { complete:false, error:null, cycles:0, maximumLiveFrames:0, diff --git a/tests/WebPlatformSubset/contracts/iframe-cross-document-history-traversal.html b/tests/WebPlatformSubset/contracts/iframe-cross-document-history-traversal.html new file mode 100644 index 00000000..15c957a8 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/iframe-cross-document-history-traversal.html @@ -0,0 +1,87 @@ + + +Nested cross-document history traversal contract + + + diff --git a/tests/WebPlatformSubset/contracts/resources/iframe-cross-document-history-a.html b/tests/WebPlatformSubset/contracts/resources/iframe-cross-document-history-a.html new file mode 100644 index 00000000..54d85dd0 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/resources/iframe-cross-document-history-a.html @@ -0,0 +1,8 @@ + + +Nested history A +
a
+ diff --git a/tests/WebPlatformSubset/contracts/resources/iframe-cross-document-history-b.html b/tests/WebPlatformSubset/contracts/resources/iframe-cross-document-history-b.html new file mode 100644 index 00000000..ac95a0c1 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/resources/iframe-cross-document-history-b.html @@ -0,0 +1,8 @@ + + +Nested history B +
b
+ diff --git a/tests/WebPlatformSubset/webscene-iframe-cross-document-history-profile.json b/tests/WebPlatformSubset/webscene-iframe-cross-document-history-profile.json new file mode 100644 index 00000000..ed34028e --- /dev/null +++ b/tests/WebPlatformSubset/webscene-iframe-cross-document-history-profile.json @@ -0,0 +1,40 @@ +{ + "profile": "webscene-iframe-cross-document-history-1", + "wptRevision": "2c705104a295c48053eeddf7fe0170d790a4e853", + "runtime": "v8", + "viewport": { + "width": 800, + "height": 600, + "deviceScaleFactor": 1 + }, + "required": [ + { + "path": "contracts/iframe-cross-document-history-traversal.html", + "type": "contract", + "capabilities": [ + "nested-cross-document-history-back-forward", + "nested-history-state-restoration", + "nested-history-popstate-after-replacement", + "nested-history-windowproxy-identity", + "nested-history-reflected-src-preservation" + ], + "evidence": [ + "wpt-traverse-the-history-1-2c705104", + "wpt-history-cross-document-2c705104", + "native-iframe-cross-document-history" + ], + "reason": "Pinned-WPT-derived contract for bounded child back/forward traversal across active documents, restored state and stable browsing-context identity." + } + ], + "harnessBlocked": [], + "excluded": [ + { + "path": "html/browsers/history/the-history-interface/traverse_the_history_1.html", + "reason": "The upstream test uses hosted top-level and child joint session history. This contract isolates two active child documents while preserving its queued traversal and state restoration boundary." + }, + { + "path": "html/browsers/browsing-the-web/history-traversal/", + "reason": "The broader upstream directory includes bfcache, persisted user state, pageshow and joint top-level traversal. Those require infrastructure outside this active-document replacement slice." + } + ] +}