mirror of
https://github.com/lexmount/moli.git
synced 2026-11-06 16:07:48 +00:00
fix(input): revalidate targets before activation and editing
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user