fix(input): revalidate targets before activation and editing

This commit is contained in:
ldm0
2026-10-15 10:25:36 +08:00
parent e02b70d900
commit d2f6fd8fd3
4 changed files with 151 additions and 8 deletions
@@ -549,6 +549,12 @@ pub(crate) fn replace_contenteditable_selection(
let Some(target) = node_wrapper_from_handle(scope, handle) else {
return false;
};
let Some(document) = unsafe { &*runtime_ptr }
.dom_host()
.owner_document_handle(handle)
else {
return false;
};
if !dispatch_beforeinput(
scope,
runtime_ptr,
@@ -558,6 +564,19 @@ pub(crate) fn replace_contenteditable_selection(
) {
return false;
}
// beforeinput may retire the frame, adopt the target into another
// Document, or make it non-editable. Validate native state again before
// changing either the DOM or its selection; exceptions alone do not
// cancel a still-valid edit.
{
let runtime = unsafe { &*runtime_ptr };
if runtime.dom_host().owner_document_handle(handle) != Some(document)
|| !runtime.dom_host().is_connected(handle)
|| contenteditable_editing_host(runtime, handle).is_none()
{
return false;
}
}
let inserted = if let Some(selection) = window_selection(scope) {
if let Some((node, start, end)) = selected_text_node_range(scope, target, selection) {
if !replace_text_node_range(scope, node, start, end, replacement_text) {
@@ -785,7 +804,11 @@ fn click_handle_internal(
focus_behavior: ClickFocusBehavior,
) -> RendererInputDispatchOutcome {
let runtime = unsafe { &*runtime_ptr };
if is_disabled_form_control(runtime, handle) {
if is_disabled_form_control(runtime, handle)
|| runtime
.document_execution_context_for_node(handle)
.is_none()
{
return RendererInputDispatchOutcome {
handled: false,
triggered_top_level_navigation: false,
@@ -793,18 +816,30 @@ fn click_handle_internal(
pending_file_chooser: None,
};
}
if focus_behavior == ClickFocusBehavior::Apply
let focus_handle = if focus_behavior == ClickFocusBehavior::Apply
&& (user_initiated || synthetic_click_focuses_element(runtime, handle))
{
let focus_handle = if is_focusable(runtime, handle) {
if is_focusable(runtime, handle) {
Some(handle)
} else {
contenteditable_editing_host(runtime, handle)
.filter(|host| is_focusable(runtime, *host))
};
if let Some(focus_handle) = focus_handle {
update_focus(scope, runtime_ptr, Some(focus_handle));
}
} else {
None
};
if let Some(focus_handle) = focus_handle {
update_focus(scope, runtime_ptr, Some(focus_handle));
}
// Focus can run author code. Reborrow and revalidate before legacy
// preactivation changes checkedness, dirty flags or indeterminate state.
let runtime = unsafe { &*runtime_ptr };
if is_disabled_form_control(runtime, handle)
|| runtime
.document_execution_context_for_node(handle)
.is_none()
{
return RendererInputDispatchOutcome::default();
}
// A click listener may change an input to or from `type=file`, and may then
// synchronously call `document.open()`. Chromium decides whether to open a
@@ -2189,6 +2189,7 @@ mod midi_owner_interfaces;
mod mouse_snapshot;
mod native_bridge_identity;
mod native_exception_stack_capture;
mod native_input_validation;
mod native_node_event_outcomes;
mod no_cors_header_fill;
mod observer_callbacks;
@@ -0,0 +1,103 @@
use super::native_node_event_outcomes::{native_outcome_vm, node_handle};
async fn beforeinput_target_change(mutation: &str, should_edit: bool) {
for policy in [
moli_page_types::LayoutPolicy::Mock,
moli_page_types::LayoutPolicy::OnDemand,
moli_page_types::LayoutPolicy::Realtime,
] {
let mut vm = native_outcome_vm(policy).await;
let script=r#"
globalThis.editor=frame.contentDocument.getElementById('childTarget');editor.contentEditable='true';
globalThis.editCalls=[];
editor.addEventListener('beforeinput',e=>{
editCalls.push([e.type,e.data,e.defaultPrevented]);
MUTATION
});
editor.addEventListener('input',e=>editCalls.push([e.type,e.data]));'ready'
"#.replace("MUTATION",mutation);
vm.eval(&script).unwrap();
let handle = node_handle(&mut vm, "editor");
let edited = vm
.with_default_context_scope_and_checkpoint_for_test(|scope, host_ptr| {
Ok(
crate::native_bridge::element::replace_contenteditable_selection(
scope,
host_ptr,
handle,
"X",
crate::native_bridge::element::TextEditInputType::InsertText,
),
)
})
.unwrap();
assert_eq!(edited, should_edit, "{policy:?}: {mutation}");
let facts = vm
.eval("JSON.stringify([editor.textContent,editCalls])")
.unwrap();
let expected = if should_edit {
serde_json::json!(["childX", [["beforeinput", "X", false], ["input", "X"]]])
} else {
serde_json::json!(["child", [["beforeinput", "X", false]]])
};
assert_eq!(
serde_json::from_str::<serde_json::Value>(&facts).unwrap(),
expected,
"{policy:?}: {mutation}"
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn native_edit_stops_when_beforeinput_removes_its_frame() {
beforeinput_target_change("frame.remove();", false).await;
}
#[tokio::test(flavor = "current_thread")]
async fn native_edit_stops_when_beforeinput_adopts_the_editor_into_another_document() {
beforeinput_target_change("document.adoptNode(editor);document.body.append(editor);editor.focus();const s=getSelection();s.selectAllChildren(editor);s.collapseToEnd();",false).await;
}
#[tokio::test(flavor = "current_thread")]
async fn native_edit_stops_when_beforeinput_disables_editing() {
beforeinput_target_change("editor.contentEditable='false';", false).await;
}
#[tokio::test(flavor = "current_thread")]
async fn native_edit_continues_with_a_valid_target_and_after_a_callback_exception() {
beforeinput_target_change("void 0;", true).await;
beforeinput_target_change("throw new Error('beforeinput-control');", true).await;
}
#[tokio::test(flavor = "current_thread")]
async fn native_click_revalidates_the_target_after_focus_before_preactivation() {
for policy in [
moli_page_types::LayoutPolicy::Mock,
moli_page_types::LayoutPolicy::OnDemand,
moli_page_types::LayoutPolicy::Realtime,
] {
let mut vm = native_outcome_vm(policy).await;
vm.eval(
r#"
globalThis.check=frame.contentDocument.createElement('input');check.type='checkbox';
check.indeterminate=true;frame.contentDocument.body.append(check);
globalThis.focusCalls=0;globalThis.clickCalls=0;
check.addEventListener('focus',()=>{focusCalls++;frame.remove();});
check.addEventListener('click',()=>clickCalls++);'ready'
"#,
)
.unwrap();
let handle = node_handle(&mut vm, "check");
let handled = vm
.with_default_context_scope_and_checkpoint_for_test(|scope, host_ptr| {
Ok(crate::native_bridge::element::activate_handle_via_click(
scope, host_ptr, handle, 0.0, 0.0, 0, 0,
)
.handled)
})
.unwrap();
assert!(!handled, "retired focus target: {policy:?}");
assert_eq!(vm.eval("check.defaultChecked=true;JSON.stringify([focusCalls,clickCalls,check.checked,check.indeterminate,document.getElementById('frame')===null])").unwrap(),
"[1,0,true,true,true]","focus callback must not allow hidden preactivation mutations: {policy:?}");
}
}
@@ -1,7 +1,9 @@
use super::window_node_worlds::child_node_world;
use super::*;
async fn native_outcome_vm(policy: moli_page_types::LayoutPolicy) -> StandaloneScriptVmHarness {
pub(super) async fn native_outcome_vm(
policy: moli_page_types::LayoutPolicy,
) -> StandaloneScriptVmHarness {
let mut vm = new_storage_test_vm("https://native-node-outcomes.test/");
vm.set_layout_policy(policy);
vm.eval("if(!document.body){const root=document.documentElement||document.appendChild(document.createElement('html'));root.appendChild(document.createElement('body'));}'ready'").unwrap();
@@ -52,10 +54,12 @@ async fn retired_form_reset_and_native_click_preserve_control_state() {
r#"["changed-again",1,0,false,true,false,false,true,"replacement"]"#,
"retired controls must not execute native defaults: {policy:?}"
);
assert_eq!(vm.eval("oldCheck.defaultChecked=true;JSON.stringify([oldCheck.checked,oldCheck.defaultChecked])").unwrap(),
"[true,true]","rejected activation must also preserve checked dirty state: {policy:?}");
}
}
fn node_handle(vm: &mut ScriptVm, name: &'static str) -> DomHandle {
pub(super) fn node_handle(vm: &mut ScriptVm, name: &'static str) -> DomHandle {
vm.with_default_context_scope_and_checkpoint_for_test(|scope, _| {
let global = scope.get_current_context().global(scope);
let node = global