refactor(history): centralize fully-active admission

This commit is contained in:
ldm0
2026-09-22 20:09:38 +08:00
committed by Donough Liu
parent d919076a13
commit f3080df33d
6 changed files with 232 additions and 26 deletions
@@ -1,3 +1,4 @@
use super::history_runtime::require_fully_active_history_owner;
use super::location_runtime::{location_href_slot, sync_location_object};
use super::navigation_activation::bind_navigation_entry_runtime_owner;
use super::navigation_callbacks::cancel_active_intercepted_same_document_navigation;
@@ -23,8 +24,7 @@ use super::navigation_result::{
};
use super::navigation_serialize::sync_child_navigation_entry_seed_from_owner;
use super::navigation_window::{
runtime_window_is_global, runtime_window_owner, window_location_for_holder,
window_navigation_for_holder,
runtime_window_is_global, window_location_for_holder, window_navigation_for_holder,
};
use super::*;
use crate::webidl;
@@ -104,11 +104,13 @@ fn mutate_history_object<'s>(
return;
};
let _ = &parsed.unused;
let Some(owner) = require_fully_active_history_owner(scope, history) else {
return;
};
let Some(state) = structured_clone_value_for_storage(scope, parsed.state) else {
return;
};
let state_json = stringify_history_state(scope, state);
let owner = runtime_window_owner(scope, history);
let Some(location) = window_location_for_holder(scope, owner) else {
return;
};
@@ -1,8 +1,10 @@
mod admission;
mod apply;
mod length;
mod results;
mod traversal;
pub(super) use self::admission::require_fully_active_history_owner;
pub(super) use self::apply::apply_history_entry;
pub(crate) use self::length::{
increment_top_level_history_length_for_runtime_owner,
@@ -0,0 +1,27 @@
use super::super::navigation_window::{navigation_document_is_active, runtime_window_owner};
use super::super::{context_host_ptr_from_global_bridge, throw_dom_exception_value};
/// History API algorithms call this after WebIDL argument conversion.
pub(in crate::context_bootstrap) fn require_fully_active_history_owner<'s>(
scope: &mut v8::PinScope<'s, '_>,
history: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Object>> {
let owner = runtime_window_owner(scope, history);
let is_fully_active = if let Some(popup_id) =
crate::native_bridge::lightweight_popup_id_from_window(scope, owner)
{
context_host_ptr_from_global_bridge(scope)
.is_some_and(|host_ptr| unsafe { &*host_ptr }.lightweight_popup_is_open(popup_id))
} else {
navigation_document_is_active(scope, owner)
};
if is_fully_active {
return Some(owner);
}
throw_dom_exception_value(
scope,
"The associated Document is not fully active.",
"SecurityError",
);
None
}
@@ -1,3 +1,4 @@
use super::super::history_runtime::require_fully_active_history_owner;
use super::super::navigation_activation::{
navigation_activation_value, navigation_current_entry_value, navigation_transition_value,
};
@@ -151,6 +152,9 @@ fn history_length_getter_function<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
if require_fully_active_history_owner(scope, args.this()).is_none() {
return;
}
let value = history_length_value(scope, args.this())
.unwrap_or_else(|| v8::Number::new(scope, 0.0).into());
rv.set(value);
@@ -161,6 +165,9 @@ fn history_state_getter_function<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
if require_fully_active_history_owner(scope, args.this()).is_none() {
return;
}
let value = history_state_value(scope, args.this());
rv.set(value);
}
@@ -212,6 +219,9 @@ fn history_scroll_restoration_getter_function<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
if require_fully_active_history_owner(scope, args.this()).is_none() {
return;
}
let value = history_scroll_restoration_value(scope, args.this())
.unwrap_or_else(|| v8str(scope, "auto").into());
rv.set(value);
@@ -232,6 +242,9 @@ fn history_scroll_restoration_setter_function<'s>(
let Some(next_value) = ScrollRestoration::parse(&next_value) else {
return;
};
if require_fully_active_history_owner(scope, args.this()).is_none() {
return;
}
set_history_scroll_restoration(scope, args.this(), next_value.label());
}
@@ -1,5 +1,6 @@
use super::history_runtime::{
cancel_pending_precommit_history_traversal, pending_history_traversal_target_index,
require_fully_active_history_owner,
};
use super::location_navigation::{
LocationNavigationKind, navigate_location_object,
@@ -69,14 +70,43 @@ pub(super) fn history_go_callback<'s>(
);
return;
}
let delta = if args.length() == 0 {
let action = if args.length() == 0 {
Some(HistoryGoAction::Reload)
} else {
coerce_history_go_delta(scope, args.get(0))
};
match delta {
Some(HistoryGoAction::Reload) => {
let owner = runtime_window_owner(scope, args.this());
let Some(action) = action else {
return;
};
history_go_with_action(scope, args.this(), action);
}
pub(super) fn history_back_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
history_go_with_action(scope, args.this(), HistoryGoAction::Traverse(-1));
}
pub(super) fn history_forward_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
history_go_with_action(scope, args.this(), HistoryGoAction::Traverse(1));
}
fn history_go_with_action<'s>(
scope: &mut v8::PinScope<'s, '_>,
history: v8::Local<'s, v8::Object>,
action: HistoryGoAction,
) {
let Some(owner) = require_fully_active_history_owner(scope, history) else {
return;
};
match action {
HistoryGoAction::Reload => {
let Some(location) = window_location_for_holder(scope, owner) else {
return;
};
@@ -87,28 +117,11 @@ pub(super) fn history_go_callback<'s>(
None,
);
}
Some(HistoryGoAction::Traverse(delta)) => history_traverse(scope, args.this(), delta),
Some(HistoryGoAction::Noop) => {}
None => {}
HistoryGoAction::Traverse(delta) => history_traverse(scope, history, delta),
HistoryGoAction::Noop => {}
}
}
pub(super) fn history_back_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
history_traverse(scope, args.this(), -1);
}
pub(super) fn history_forward_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
history_traverse(scope, args.this(), 1);
}
pub(super) fn navigation_back_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
@@ -1449,6 +1449,155 @@ fn history_navigation_arguments_use_webidl_conversion() {
);
}
#[test]
fn history_operations_reject_a_removed_child_document() {
let mut vm = new_storage_test_vm("https://example.com/page.html");
let result = vm
.eval(
r#"
(() => {
const root = document.documentElement ||
document.appendChild(document.createElement("html"));
const body = document.body || root.appendChild(document.createElement("body"));
const frame = document.createElement("iframe");
body.appendChild(frame);
const childHistory = frame.contentWindow.history;
const ChildDOMException = frame.contentWindow.DOMException;
frame.remove();
const probe = callback => {
try {
callback();
return "no throw";
} catch (error) {
return `${error.name}:${error instanceof ChildDOMException}`;
}
};
return [
probe(() => childHistory.length),
probe(() => childHistory.scrollRestoration),
probe(() => childHistory.state),
probe(() => { childHistory.scrollRestoration = "manual"; }),
probe(() => childHistory.go()),
probe(() => childHistory.go(0)),
probe(() => childHistory.go(-1)),
probe(() => childHistory.go(1)),
probe(() => childHistory.go(Infinity)),
probe(() => childHistory.go(-Infinity)),
probe(() => childHistory.back()),
probe(() => childHistory.forward()),
probe(() => childHistory.pushState(1, "", "?x=1")),
probe(() => childHistory.replaceState(2, "", "?x=2"))
].join("|");
})()
"#,
)
.expect("removed child History operations should be rejected");
assert_eq!(result, ["SecurityError:true"; 14].join("|"));
}
#[test]
fn history_argument_errors_precede_inactive_document_errors() {
let mut vm = new_storage_test_vm("https://example.com/page.html");
let result = vm
.eval(
r#"
(() => {
const root = document.documentElement ||
document.appendChild(document.createElement("html"));
const body = document.body || root.appendChild(document.createElement("body"));
const frame = document.createElement("iframe");
body.appendChild(frame);
const childHistory = frame.contentWindow.history;
frame.remove();
const conversionError = new RangeError("conversion");
const rejected = {
[Symbol.toPrimitive]() { throw conversionError; }
};
const probe = callback => {
try {
callback();
return "no throw";
} catch (error) {
return error === conversionError ? "conversion" : error.name;
}
};
return [
probe(() => childHistory.go(Symbol())),
probe(() => childHistory.go(1n)),
probe(() => childHistory.go(rejected)),
probe(() => { childHistory.scrollRestoration = Symbol(); }),
probe(() => { childHistory.scrollRestoration = rejected; }),
probe(() => { childHistory.scrollRestoration = "invalid"; }),
probe(() => childHistory.pushState()),
probe(() => childHistory.pushState(null, Symbol())),
probe(() => childHistory.replaceState(null, "", Symbol())),
probe(() => childHistory.pushState(null, rejected)),
probe(() => childHistory.replaceState(null, "", rejected)),
probe(() => childHistory.pushState(() => {}, "")),
probe(() => childHistory.replaceState(() => {}, ""))
].join("|");
})()
"#,
)
.expect("inactive History should preserve conversion and serialization error ordering");
assert_eq!(
result,
"TypeError|TypeError|conversion|TypeError|conversion|no throw|TypeError|TypeError|TypeError|conversion|conversion|SecurityError|SecurityError"
);
}
#[test]
fn history_activity_is_checked_after_argument_conversion() {
let mut vm = new_storage_test_vm("https://example.com/page.html");
let result = vm
.eval(
r#"
(() => {
const root = document.documentElement ||
document.appendChild(document.createElement("html"));
const body = document.body || root.appendChild(document.createElement("body"));
const operations = [
(history, convert) => history.go(convert(0)),
(history, convert) => history.go(convert(-1)),
(history, convert) => history.go(convert(Infinity)),
(history, convert) => { history.scrollRestoration = convert("manual"); },
(history, convert) => history.pushState(() => {}, convert("")),
(history, convert) => history.replaceState(() => {}, "", convert("?x=2"))
];
return operations.map(operation => {
const frame = document.createElement("iframe");
body.appendChild(frame);
const childHistory = frame.contentWindow.history;
let conversions = 0;
const convert = value => ({
[Symbol.toPrimitive]() {
conversions++;
frame.remove();
return value;
}
});
try {
operation(childHistory, convert);
return "no throw";
} catch (error) {
return `${error.name}:${conversions}`;
}
}).join("|");
})()
"#,
)
.expect("History should reject a document removed during argument conversion");
assert_eq!(result, ["SecurityError:1"; 6].join("|"));
}
#[test]
fn history_state_preserves_structured_clone_values_not_representable_as_json() {
let mut vm = new_storage_test_vm("https://example.com/base");