diff --git a/moli-renderer-v8/src/context_bootstrap/selection.rs b/moli-renderer-v8/src/context_bootstrap/selection.rs index b6667e3b90..667b3fe22f 100644 --- a/moli-renderer-v8/src/context_bootstrap/selection.rs +++ b/moli-renderer-v8/src/context_bootstrap/selection.rs @@ -17,7 +17,7 @@ pub(super) use values::{ selection_composed_end_node, selection_composed_end_offset, selection_composed_start_node, selection_composed_start_offset, selection_direction, selection_focus_node, selection_focus_offset, selection_has_range, selection_is_collapsed_internal, - selection_owner_document, selection_range, selection_range_update_state, - selection_set_collapsed, selection_store, selection_store_with_composed_boundaries, - selection_sync_associated_range, + selection_owner_document, selection_range, selection_range_belongs_to_document, + selection_range_update_state, selection_set_collapsed, selection_spans_dom_roots, + selection_store, selection_store_with_composed_boundaries, selection_sync_associated_range, }; diff --git a/moli-renderer-v8/src/context_bootstrap/selection/values.rs b/moli-renderer-v8/src/context_bootstrap/selection/values.rs index d22ae2f6aa..2237995031 100644 --- a/moli-renderer-v8/src/context_bootstrap/selection/values.rs +++ b/moli-renderer-v8/src/context_bootstrap/selection/values.rs @@ -1,9 +1,11 @@ use super::super::range::{ - RangeBoundarySide, new_range_for_document, range_boundary_container_object, - range_boundary_offset, range_is_collapsed, range_native_record_handle, set_range_boundary, + RangeBoundarySide, current_document_object, new_range_for_document, + range_boundary_container_object, range_boundary_offset, range_native_record_handle, + set_range_boundary, }; use super::*; use crate::document_runtime::DomHandle; +use crate::native_bridge::document::document_associated_window_for_handle; use crate::native_bridge::wrapped_handle_value; use crate::native_bridge::{ SelectionBoundaryRole, SelectionBoundarySnapshot, SelectionRecordHandle, @@ -39,14 +41,6 @@ pub(in crate::context_bootstrap) struct SelectionRangeUpdateState<'s> { pub old_composed_end_offset: u32, } -pub(in crate::context_bootstrap) fn window_selection_value<'s>( - scope: &mut v8::PinScope<'s, '_>, -) -> Option> { - let global = scope.get_current_context().global(scope); - get_private_value(scope, global, WINDOW_SELECTION_SLOT) - .and_then(|value| v8::Local::::try_from(value).ok()) -} - pub(in crate::context_bootstrap) fn new_selection_runtime_object<'s>( scope: &mut v8::PinScope<'s, '_>, ) -> v8::Local<'s, v8::Object> { @@ -211,6 +205,18 @@ pub(in crate::context_bootstrap) fn selection_is_collapsed_internal<'s>( .unwrap_or(true) } +pub(in crate::context_bootstrap) fn selection_spans_dom_roots<'s>( + scope: &mut v8::PinScope<'s, '_>, + selection: v8::Local<'s, v8::Object>, +) -> bool { + selection_record_handle(scope, selection) + .and_then(|handle| { + context_host_ptr_from_global_bridge(scope) + .map(|host_ptr| unsafe { &*host_ptr }.selection_record_spans_dom_roots(handle)) + }) + .unwrap_or(false) +} + pub(in crate::context_bootstrap) fn selection_store<'s>( scope: &mut v8::PinScope<'s, '_>, selection: v8::Local<'s, v8::Object>, @@ -329,13 +335,14 @@ pub(in crate::context_bootstrap) fn selection_range_update_state<'s>( scope: &mut v8::PinScope<'s, '_>, range: v8::Local<'s, v8::Object>, ) -> Option> { + // A Range may be created in one realm and selected in another. Resolve + // the association from native records, independently of the callee realm. let range_handle = range_native_record_handle(scope, range)?; - let backing = moli_webapi_declare::web_api_object_target(scope, range)?; - let selection = get_private_value(scope, backing, RANGE_ASSOCIATED_SELECTION_SLOT) + let host_ptr = context_host_ptr_from_global_bridge(scope)?; + let document = unsafe { &*host_ptr }.selection_document_for_range(range_handle)?; + let window = document_associated_window_for_handle(scope, host_ptr, document)?; + let selection = get_private_value(scope, window, WINDOW_SELECTION_SLOT) .and_then(|value| v8::Local::::try_from(value).ok())?; - if !selection_has_range(scope, selection) { - return None; - } let selected_range = selection_range(scope, selection)?; if range_native_record_handle(scope, selected_range) != Some(range_handle) { return None; @@ -381,16 +388,23 @@ pub(in crate::context_bootstrap) fn selection_sync_associated_range<'s>( selection_clear(scope, state.selection); return; }; - if !selection_boundary_is_connected(scope, range_start_node) - || !selection_boundary_is_connected(scope, range_end_node) - { + if !selection_range_belongs_to_document( + scope, + state.selection, + range_start_node, + range_end_node, + ) { selection_clear(scope, state.selection); return; } let range_start_offset = range_boundary_offset(scope, range, RangeBoundarySide::Start) as u32; let range_end_offset = range_boundary_offset(scope, range, RangeBoundarySide::End) as u32; - let direction = if range_is_collapsed(scope, range) { + // A collapsed DOM Range can still project a composed span across roots. + let direction = if composed_start_offset == composed_end_offset + && callback_value_dom_handle(scope, composed_start_node.into()) + == callback_value_dom_handle(scope, composed_end_node.into()) + { "none" } else { "forward" @@ -451,17 +465,33 @@ fn selection_update_slots_with_composed_boundaries<'s>( ); } -fn selection_boundary_is_connected<'s>( +pub(in crate::context_bootstrap) fn selection_range_belongs_to_document<'s>( scope: &mut v8::PinScope<'s, '_>, - node: v8::Local<'s, v8::Object>, + selection: v8::Local<'s, v8::Object>, + start_node: v8::Local<'s, v8::Object>, + end_node: v8::Local<'s, v8::Object>, ) -> bool { + let Some(document) = + selection_owner_document(scope, selection).or_else(|| current_document_object(scope)) + else { + return true; + }; let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { - return true; + return false; }; - let Some(handle) = callback_value_dom_handle(scope, node.into()) else { - return true; + let Some(document) = callback_value_dom_handle(scope, document.into()) else { + return false; }; - unsafe { &*host_ptr }.dom_host().is_connected(handle) + // Connectivity and ownership are native relationships. Author properties + // named parentNode, nodeType or host must not change Selection membership + // (or run script while checking an associated Range). + let dom = unsafe { &*host_ptr }.dom_host(); + [start_node, end_node].into_iter().all(|node| { + callback_value_dom_handle(scope, node.into()).is_some_and(|handle| { + dom.is_connected_to_document(handle) + && (handle == document || dom.owner_document_handle(handle) == Some(document)) + }) + }) } fn selection_record_handle<'s>( diff --git a/moli-renderer-v8/src/context_bootstrap/selection_callbacks.rs b/moli-renderer-v8/src/context_bootstrap/selection_callbacks.rs index ee07e20e88..b2e58f1d9d 100644 --- a/moli-renderer-v8/src/context_bootstrap/selection_callbacks.rs +++ b/moli-renderer-v8/src/context_bootstrap/selection_callbacks.rs @@ -1,7 +1,6 @@ use super::range::{ - RangeBoundarySide, child_index, current_document_object, new_range_for_document, - range_boundary_container_object, range_boundary_offset, range_is_collapsed, range_node_length, - range_validate_boundary_point, + RangeBoundarySide, child_index, new_range_for_document, range_boundary_container_object, + range_boundary_offset, range_is_collapsed, range_node_length, range_validate_boundary_point, }; use super::range_algorithms::range_delete_contents; use super::selection::{ @@ -10,7 +9,8 @@ use super::selection::{ selection_composed_start_node, selection_composed_start_offset, selection_direction, selection_dispatch_change, selection_focus_node, selection_focus_offset, selection_has_range, selection_is_collapsed_internal, selection_owner_document, selection_range, - selection_set_collapsed, selection_store, selection_store_with_composed_boundaries, + selection_range_belongs_to_document, selection_set_collapsed, selection_spans_dom_roots, + selection_store, selection_store_with_composed_boundaries, }; use super::selection_modify::selection_modify_target; use super::*; diff --git a/moli-renderer-v8/src/context_bootstrap/selection_callbacks/accessors.rs b/moli-renderer-v8/src/context_bootstrap/selection_callbacks/accessors.rs index 4c71b9aab8..3fa1d955a9 100644 --- a/moli-renderer-v8/src/context_bootstrap/selection_callbacks/accessors.rs +++ b/moli-renderer-v8/src/context_bootstrap/selection_callbacks/accessors.rs @@ -71,7 +71,9 @@ fn selection_attribute_value<'s>( SelectionAttribute::Type => { let value = if !selection_has_range(scope, selection) { "None" - } else if selection_is_collapsed_internal(scope, selection) { + } else if selection_is_collapsed_internal(scope, selection) + && !selection_spans_dom_roots(scope, selection) + { "Caret" } else { "Range" diff --git a/moli-renderer-v8/src/context_bootstrap/selection_callbacks/ranges.rs b/moli-renderer-v8/src/context_bootstrap/selection_callbacks/ranges.rs index fc35b73d9f..454d632d9e 100644 --- a/moli-renderer-v8/src/context_bootstrap/selection_callbacks/ranges.rs +++ b/moli-renderer-v8/src/context_bootstrap/selection_callbacks/ranges.rs @@ -96,35 +96,6 @@ fn selection_contains_node_boundary_points<'s>( Some((parent, index, parent, index + 1, false)) } -fn range_belongs_to_current_document<'s>( - scope: &mut v8::PinScope<'s, '_>, - selection: v8::Local<'s, v8::Object>, - start_node: v8::Local<'s, v8::Object>, - end_node: v8::Local<'s, v8::Object>, -) -> bool { - let Some(document) = - selection_owner_document(scope, selection).or_else(|| current_document_object(scope)) - else { - return true; - }; - let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { - return false; - }; - let Some(document) = callback_value_dom_handle(scope, document.into()) else { - return false; - }; - // Connectivity and ownership are native relationships. Author properties - // named parentNode, nodeType or host must not change Selection membership - // (or run script while checking an associated Range). - let dom = unsafe { &*host_ptr }.dom_host(); - [start_node, end_node].into_iter().all(|node| { - callback_value_dom_handle(scope, node.into()).is_some_and(|handle| { - dom.is_connected_to_document(handle) - && (handle == document || dom.owner_document_handle(handle) == Some(document)) - }) - }) -} - pub(in crate::context_bootstrap) fn selection_get_range_at_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -149,7 +120,7 @@ pub(in crate::context_bootstrap) fn selection_get_range_at_callback<'s>( throw_index_size_error(scope, "Index or offset is out of range."); return; }; - if !range_belongs_to_current_document(scope, args.this(), start_node, end_node) { + if !selection_range_belongs_to_document(scope, args.this(), start_node, end_node) { throw_index_size_error(scope, "Index or offset is out of range."); return; } @@ -423,7 +394,7 @@ pub(in crate::context_bootstrap) fn selection_add_range_callback<'s>( else { return; }; - if !range_belongs_to_current_document(scope, args.this(), start_node, end_node) { + if !selection_range_belongs_to_document(scope, args.this(), start_node, end_node) { return; } let start_offset = range_boundary_offset(scope, range, RangeBoundarySide::Start) as u32; diff --git a/moli-renderer-v8/src/native_bridge/context_host/selection_records.rs b/moli-renderer-v8/src/native_bridge/context_host/selection_records.rs index ab6091119c..3c1af9e4e5 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/selection_records.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/selection_records.rs @@ -599,10 +599,37 @@ impl JsContextHost { .is_collapsed(dom_host, handle) } + pub(crate) fn selection_document_for_range( + &self, + range: RangeRecordHandle, + ) -> Option { + self.selection_record_registry + .records + .values() + .find_map(|record| { + (record.associated_range == Some(range)) + .then_some(record.owner_document) + .flatten() + }) + } + pub(crate) fn document_selection_snapshot( &self, document: DomHandle, ) -> Option { self.selection_record_registry.document_snapshot(document) } + + pub(crate) fn selection_record_spans_dom_roots(&self, handle: SelectionRecordHandle) -> bool { + let Some(record) = self.selection_record_registry.records.get(&handle) else { + return false; + }; + let (Some(start), Some(end)) = (record.composed_start, record.composed_end) else { + return false; + }; + let dom = self.dom_host(); + dom.root_node_handle(start.container) + .zip(dom.root_node_handle(end.container)) + .is_some_and(|(start, end)| start != end) + } } diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage/event_dispatch_and_targets.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage/event_dispatch_and_targets.rs index b1917b6a04..c5fc14d40e 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage/event_dispatch_and_targets.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/events_selection_storage/event_dispatch_and_targets.rs @@ -1771,3 +1771,15 @@ fn selection_shadow_direction_and_composed_boundaries_follow_native_mutations() "" ); } + +#[test] +fn selection_tracks_associated_range_document_across_realms() { + let mut vm = new_storage_test_vm("https://selection-range-owner.test/"); + assert_eq!( + vm.eval(include_str!( + "../../../../../tests/fixtures/selection-range-ownership.js" + )) + .expect("associated Range mutations should update their owning Selection"), + "" + ); +} diff --git a/moli-renderer-v8/tests/fixtures/selection-range-ownership.js b/moli-renderer-v8/tests/fixtures/selection-range-ownership.js new file mode 100644 index 0000000000..58ff0617e9 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/selection-range-ownership.js @@ -0,0 +1,94 @@ +(() => { + const failures = []; + const check = (value, label) => { if (!value) failures.push(label); }; + const html = document.documentElement || document.appendChild(document.createElement('html')); + const body = document.body || html.appendChild(document.createElement('body')); + const frame = body.appendChild(document.createElement('iframe')); + const other = frame.contentWindow; + const make = doc => { + const box = doc.createElement('section'); + box.innerHTML = 'before

middletail

after
'; + const root = box.lastChild.attachShadow({mode:'closed'}); + root.innerHTML = '

shadow

'; + return {box, node:box.children[1], root, shadow:root.firstChild}; + }; + const a=make(document), b=make(other.document), detached=make(document); + body.appendChild(a.box); other.document.body.appendChild(b.box); + document.createDocumentFragment().appendChild(detached.box); + const windowless=document.implementation.createHTMLDocument(''); + const inert=make(windowless); windowless.body.appendChild(inert.box); + const methods=['selectNode','selectNodeContents','setEnd','setEndAfter','setEndBefore','setStart','setStartAfter','setStartBefore']; + const throws = (fn, name) => { try { fn(); return false; } catch (error) { return error.name === name; } }; + const empty = (selection, label) => { + check(selection.rangeCount===0 && selection.type==='None' && selection.direction==='none' && selection.isCollapsed, label+': empty state'); + check(selection.anchorNode===null && selection.anchorOffset===0 && selection.focusNode===null && selection.focusOffset===0, label+': empty endpoints'); + check(selection.getComposedRanges().length===0, label+': empty composed ranges'); + check(throws(()=>selection.getRangeAt(0),'IndexSizeError'), label+': getRangeAt rejects'); + }; + for (const owner of [window,other]) for (const caller of [window,other]) for (const creator of [window,other]) { + const own=owner===window?a:b, foreign=owner===window?b:a; + const selection=owner.getSelection(), unrelated=(owner===window?other:window).getSelection(); + for (const method of methods) for (const kind of ['same','shadow','foreign','foreign-shadow','fragment','windowless','detached-shadow']) { + const target=({same:own.node,shadow:own.shadow,foreign:foreign.node,'foreign-shadow':foreign.shadow,fragment:detached.node,windowless:inert.node,'detached-shadow':detached.shadow})[kind]; + const label=[owner===window?'parent':'child',caller===window?'parent':'child',creator===window?'parent':'child',method,kind].join(':'); + selection.removeAllRanges(); unrelated.removeAllRanges(); + const range=new creator.Range(); range.selectNodeContents(own.box); selection.addRange(range); + unrelated.selectAllChildren(foreign.node); + const retained=unrelated.getRangeAt(0); + caller.Range.prototype[method].call(range,target,0); + const belongs=kind==='same'||kind==='shadow'; + if (belongs) { + check(selection.type==='Range' && selection.direction==='forward', label+': composed range state'); + check(selection.rangeCount===1 && selection.getRangeAt(0)===range, label+': retain associated range'); + check(selection.anchorNode===range.startContainer && selection.anchorOffset===range.startOffset && + selection.focusNode===range.endContainer && selection.focusOffset===range.endOffset, label+': sync projected endpoints'); + } else empty(selection,label); + check(range.startContainer.getRootNode()===target.getRootNode() && range.endContainer.getRootNode()===target.getRootNode(), label+': native range remains usable'); + caller.Range.prototype.selectNodeContents.call(range,own.box); + if (!belongs) empty(selection,label+': return does not reattach'); + selection.addRange(range); + check(selection.getRangeAt(0)===range && selection.anchorNode===own.box && selection.anchorOffset===0 && + selection.focusNode===own.box && selection.focusOffset===4, label+': explicit addRange'); + check(unrelated.getRangeAt(0)===retained && unrelated.anchorNode===foreign.node && unrelated.anchorOffset===0 && + unrelated.focusNode===foreign.node && unrelated.focusOffset===2, label+': unrelated selection retained'); + } + for (const method of methods) { + selection.selectAllChildren(own.node); + const range=selection.getRangeAt(0), clone=range.cloneRange(); + caller.Range.prototype[method].call(clone,foreign.node,0); + check(selection.getRangeAt(0)===range && selection.anchorNode===own.node && selection.focusOffset===2, method+': unassociated clone'); + selection.selectAllChildren(own.box); + const replacement=selection.getRangeAt(0); + caller.Range.prototype[method].call(range,foreign.node,0); + check(selection.getRangeAt(0)===replacement && selection.anchorNode===own.box && selection.focusOffset===4, method+': replaced range'); + } + // DOM boundary validation must finish before changing the associated Range. + // A foreign node with an invalid offset must preserve the existing selection. + for (const method of methods) { + const label=[owner===window?'parent':'child',caller===window?'parent':'child',creator===window?'parent':'child',method].join(':'); + selection.selectAllChildren(own.node); + const range=selection.getRangeAt(0); + const target=method==='selectNodeContents'?owner.document.implementation.createDocumentType('html','',''): + method==='setStart'||method==='setEnd'?foreign.node:owner.document.createElement('div'); + const error=method==='setStart'||method==='setEnd'?'IndexSizeError':'InvalidNodeTypeError'; + check(throws(()=>caller.Range.prototype[method].call(range,target,99),error), label+': invalid boundary'); + check(selection.rangeCount===1 && selection.getRangeAt(0)===range && selection.anchorNode===own.node && selection.anchorOffset===0 && + selection.focusNode===own.node && selection.focusOffset===2, label+': error preserves selection'); + } + } + // Neither Range association nor Document membership may invoke author getters. + const selected=other.getSelection(); + let getterCalls=0; + const poisoned = [[other.document,'defaultView'],[b.node,'ownerDocument'],[b.node,'isConnected']]; + for (const [object,key] of poisoned) Object.defineProperty(object,key,{configurable:true,get(){getterCalls++;throw Error(key);}}); + for (const method of methods) { + selected.selectAllChildren(b.box); + const range=selected.getRangeAt(0); + Range.prototype[method].call(range,b.node,0); + check(selected.getRangeAt(0)===range, method+': native ownership'); + } + for (const [object,key] of poisoned) delete object[key]; + check(getterCalls===0,'ownership checks must not run author getters'); + a.box.remove(); frame.remove(); + return failures.join('\n'); +})()