From b5d2f8db61adff2e2881c12ec74151e8d325327b Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sat, 26 Sep 2026 11:09:50 +0800 Subject: [PATCH] fix(selection): sync Range mutations with the owning document Resolve an associated Selection through the native Range record and its owning Document, independently of the Range creation realm or borrowed method realm. Share native document membership checks with addRange and getRangeAt, and clear associations when explicit Range mutations leave that document. Route direct and relative boundary setters through the same adjustment and synchronization path. Keep composed direction and type when a DOM Range projects a span across shadow roots. Cover cross-realm ownership, detached trees, explicit reattachment, cloned and replaced ranges, invalid boundaries and author getters. Validate 448 ownership combinations and 36 direction/type scenarios against Chromium, while preserving validation-before-mutation semantics for invalid foreign offsets. --- .../src/context_bootstrap/selection.rs | 6 +- .../src/context_bootstrap/selection/values.rs | 80 +++++++++++----- .../context_bootstrap/selection_callbacks.rs | 8 +- .../selection_callbacks/accessors.rs | 4 +- .../selection_callbacks/ranges.rs | 33 +------ .../context_host/selection_records.rs | 27 ++++++ .../event_dispatch_and_targets.rs | 12 +++ .../fixtures/selection-range-ownership.js | 94 +++++++++++++++++++ 8 files changed, 200 insertions(+), 64 deletions(-) create mode 100644 moli-renderer-v8/tests/fixtures/selection-range-ownership.js 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'); +})()