mirror of
https://github.com/lexmount/moli.git
synced 2026-11-13 08:08:10 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<v8::Local<'s, v8::Object>> {
|
||||
let global = scope.get_current_context().global(scope);
|
||||
get_private_value(scope, global, WINDOW_SELECTION_SLOT)
|
||||
.and_then(|value| v8::Local::<v8::Object>::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<SelectionRangeUpdateState<'s>> {
|
||||
// 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::<v8::Object>::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>(
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -599,10 +599,37 @@ impl JsContextHost {
|
||||
.is_collapsed(dom_host, handle)
|
||||
}
|
||||
|
||||
pub(crate) fn selection_document_for_range(
|
||||
&self,
|
||||
range: RangeRecordHandle,
|
||||
) -> Option<DomHandle> {
|
||||
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<DocumentSelectionSnapshot> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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"),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = '<span>before</span><p><b>middle</b><i>tail</i></p><span>after</span><div></div>';
|
||||
const root = box.lastChild.attachShadow({mode:'closed'});
|
||||
root.innerHTML = '<p><b>shadow</b></p>';
|
||||
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');
|
||||
})()
|
||||
Reference in New Issue
Block a user