Skip to main content

blitz_dom/node/
script_custom_element.rs

1//! Native indexing and mutation records for script custom elements.
2//!
3//! This is independent of the embedder's Rust custom element controllers.
4//! No JavaScript handles or callbacks are stored in the DOM.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8
9use crate::{BaseDocument, LocalName, NodeData, NodeId, QualName, ns};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum State {
13    Undefined,
14    Upgrading,
15    Custom,
16    Failed,
17}
18
19struct Candidate {
20    name: Arc<str>,
21    local_name: LocalName,
22    state: State,
23}
24
25struct Definition {
26    local_name: LocalName,
27    observed: Arc<HashSet<String>>,
28}
29
30#[derive(Clone)]
31pub enum ReactionKind {
32    Upgrade,
33    Connected,
34    Disconnected,
35    Moved,
36    Attribute {
37        name: QualName,
38        old_value: Option<String>,
39        new_value: Option<String>,
40    },
41    Adopted {
42        old_document: NodeId,
43        new_document: NodeId,
44    },
45}
46
47#[derive(Clone)]
48pub struct Reaction {
49    pub node: NodeId,
50    pub name: Arc<str>,
51    pub kind: ReactionKind,
52}
53
54#[derive(Default)]
55pub struct Registry {
56    candidates: HashMap<NodeId, Candidate>,
57    names: HashMap<Arc<str>, HashSet<NodeId>>,
58    definitions: HashMap<Arc<str>, Definition>,
59    reactions: Vec<Reaction>,
60}
61
62impl BaseDocument {
63    pub(crate) fn register_script_custom_element(&mut self, node_id: NodeId) {
64        let Some(element) = self.nodes[node_id].element_data() else {
65            return;
66        };
67        if element.name.ns != ns!(html) {
68            return;
69        }
70        let name: Arc<str> = if element.name.local.as_ref().contains('-') {
71            Arc::from(element.name.local.as_ref())
72        } else if let Some(name) = element.attr(LocalName::from("is")) {
73            Arc::from(name)
74        } else {
75            return;
76        };
77        let local_name = element.name.local.clone();
78        self.nodes[node_id].custom_element_subtree_count = 1;
79        self.script_custom_elements
80            .names
81            .entry(Arc::clone(&name))
82            .or_default()
83            .insert(node_id);
84        self.script_custom_elements.candidates.insert(
85            node_id,
86            Candidate {
87                name,
88                local_name,
89                state: State::Undefined,
90            },
91        );
92        if self.script_custom_element_is_defined(node_id) {
93            self.record_script_custom_element(node_id, ReactionKind::Upgrade);
94        }
95    }
96
97    pub(crate) fn forget_script_custom_element(&mut self, node_id: NodeId) {
98        if let Some(candidate) = self.script_custom_elements.candidates.remove(&node_id) {
99            if let Some(nodes) = self.script_custom_elements.names.get_mut(&candidate.name) {
100                nodes.remove(&node_id);
101                if nodes.is_empty() {
102                    self.script_custom_elements.names.remove(&candidate.name);
103                }
104            }
105        }
106    }
107
108    pub fn script_custom_element_name(&self, node_id: NodeId) -> Option<&str> {
109        self.script_custom_elements
110            .candidates
111            .get(&node_id)
112            .map(|candidate| candidate.name.as_ref())
113    }
114
115    pub fn script_custom_element_state(&self, node_id: NodeId) -> State {
116        self.script_custom_elements
117            .candidates
118            .get(&node_id)
119            .map_or(State::Undefined, |candidate| candidate.state)
120    }
121
122    pub fn set_script_custom_element_state(&mut self, node_id: NodeId, state: State) {
123        if let Some(candidate) = self.script_custom_elements.candidates.get_mut(&node_id) {
124            candidate.state = state;
125        }
126    }
127
128    pub fn script_custom_element_is_defined(&self, node_id: NodeId) -> bool {
129        let Some(candidate) = self.script_custom_elements.candidates.get(&node_id) else {
130            return false;
131        };
132        self.script_custom_elements
133            .definitions
134            .get(&candidate.name)
135            .is_some_and(|definition| definition.local_name == candidate.local_name)
136    }
137
138    /// DOM connectivity includes shadow trees and documents in the shared arena.
139    pub fn script_node_is_connected(&self, node_id: NodeId) -> bool {
140        let mut current = Some(node_id);
141        while let Some(id) = current {
142            let Some(node) = self.get_node(id) else {
143                return false;
144            };
145            if matches!(node.data, NodeData::Document(_)) {
146                return true;
147            }
148            current = node.parent;
149        }
150        false
151    }
152
153    fn record_script_custom_element(&mut self, node_id: NodeId, kind: ReactionKind) {
154        if let Some(candidate) = self.script_custom_elements.candidates.get(&node_id) {
155            self.script_custom_elements.reactions.push(Reaction {
156                node: node_id,
157                name: Arc::clone(&candidate.name),
158                kind,
159            });
160        }
161    }
162
163    pub fn take_script_custom_element_reactions(&mut self) -> Vec<Reaction> {
164        let mut reactions = std::mem::take(&mut self.script_custom_elements.reactions);
165        // Only a document with a browsing context upgrades. An element created and then
166        // moved into template contents, a clone of template contents, or an element of a
167        // createHTMLDocument document keeps its upgrade until it is adopted back.
168        let root = self.root_node_id;
169        reactions.retain(|reaction| {
170            !matches!(reaction.kind, ReactionKind::Upgrade)
171                || self
172                    .nodes
173                    .get(reaction.node)
174                    .is_some_and(|node| node.owner_document == Some(root))
175        });
176        reactions
177    }
178
179    /// Register native matching metadata and return only this name's connected
180    /// upgrade candidates, in shadow-including tree order.
181    pub fn define_script_custom_element(
182        &mut self,
183        name: &str,
184        local_name: LocalName,
185        observed: Arc<HashSet<String>>,
186    ) -> Vec<NodeId> {
187        let name: Arc<str> = Arc::from(name);
188        self.script_custom_elements.definitions.insert(
189            Arc::clone(&name),
190            Definition {
191                local_name: local_name.clone(),
192                observed,
193            },
194        );
195        let mut matches: Vec<_> = self
196            .script_custom_elements
197            .names
198            .get(&name)
199            .into_iter()
200            .flatten()
201            .copied()
202            .filter(|id| {
203                let candidate = &self.script_custom_elements.candidates[id];
204                candidate.state == State::Undefined
205                    && candidate.local_name == local_name
206                    && self
207                        .get_node(*id)
208                        .is_some_and(|node| node.owner_document == Some(self.root_node_id))
209                    && self.script_node_is_connected(*id)
210            })
211            .map(|id| (self.script_tree_order_key(id), id))
212            .collect();
213        matches.sort_by(|left, right| left.0.cmp(&right.0));
214        matches.into_iter().map(|(_, id)| id).collect()
215    }
216
217    fn script_tree_order_key(&self, node_id: NodeId) -> Vec<usize> {
218        let mut key = Vec::new();
219        let mut current = node_id;
220        while let Some(parent_id) = self.get_node(current).and_then(|node| node.parent) {
221            let Some(parent) = self.get_node(parent_id) else {
222                break;
223            };
224            // Shadow roots precede the host's light children.
225            let index = parent
226                .children
227                .iter()
228                .position(|id| *id == current)
229                .map_or(0, |index| index + 1);
230            key.push(index);
231            current = parent_id;
232        }
233        key.reverse();
234        key
235    }
236
237    /// Walk only branches containing candidates. Plain subtrees allocate
238    /// nothing and do not require a traversal.
239    pub fn script_custom_element_candidates(&self, root: NodeId) -> Vec<NodeId> {
240        if self
241            .get_node(root)
242            .is_none_or(|node| node.custom_element_subtree_count == 0)
243        {
244            return Vec::new();
245        }
246        let mut found = Vec::new();
247        let mut stack = vec![root];
248        while let Some(id) = stack.pop() {
249            let Some(node) = self.get_node(id) else {
250                continue;
251            };
252            if node.custom_element_subtree_count == 0 {
253                continue;
254            }
255            if self.script_custom_elements.candidates.contains_key(&id) {
256                found.push(id);
257            }
258            stack.extend(node.children.iter().rev().copied());
259            #[cfg(feature = "shadow-dom")]
260            if let Some(shadow_root) = node.shadow_root_id() {
261                stack.push(shadow_root);
262            }
263        }
264        found
265    }
266
267    fn change_script_custom_element_count(
268        &mut self,
269        mut ancestor: Option<NodeId>,
270        count: usize,
271        add: bool,
272    ) {
273        if count == 0 {
274            return;
275        }
276        while let Some(id) = ancestor {
277            let Some(node) = self.get_node_mut(id) else {
278                break;
279            };
280            if add {
281                node.custom_element_subtree_count += count;
282            } else {
283                node.custom_element_subtree_count -= count;
284            }
285            ancestor = node.parent;
286        }
287    }
288
289    /// Called before cutting a parent edge, including moves within one parent.
290    pub(crate) fn detach_script_custom_element_subtree(&mut self, root: NodeId) {
291        let Some(node) = self.get_node(root) else {
292            return;
293        };
294        let count = node.custom_element_subtree_count;
295        let parent = node.parent;
296        if count == 0 {
297            return;
298        }
299        if parent.is_some() && self.script_node_is_connected(root) {
300            for id in self.script_custom_element_candidates(root) {
301                if matches!(
302                    self.script_custom_element_state(id),
303                    State::Custom | State::Upgrading
304                ) {
305                    self.record_script_custom_element(id, ReactionKind::Disconnected);
306                }
307            }
308        }
309        self.change_script_custom_element_count(parent, count, false);
310    }
311
312    /// Drop the disconnect recorded for `node_id` by the detach in this mutation.
313    fn take_pending_disconnect(&mut self, node_id: NodeId) -> bool {
314        let Some(index) = self
315            .script_custom_elements
316            .reactions
317            .iter()
318            .rposition(|reaction| {
319                reaction.node == node_id && matches!(reaction.kind, ReactionKind::Disconnected)
320            })
321        else {
322            return false;
323        };
324        self.script_custom_elements.reactions.remove(index);
325        true
326    }
327
328    /// Called after establishing a new parent edge.
329    pub(crate) fn attach_script_custom_element_subtree(&mut self, root: NodeId, parent: NodeId) {
330        let owner = self.nodes[parent].owner_document.unwrap_or(parent);
331        let same_document = self.nodes[root].owner_document == Some(owner);
332        self.adopt_script_subtree(root, owner);
333        let count = self.nodes[root].custom_element_subtree_count;
334        self.change_script_custom_element_count(Some(parent), count, true);
335        if count == 0 || !self.script_node_is_connected(parent) {
336            return;
337        }
338        for id in self.script_custom_element_candidates(root) {
339            match self.script_custom_element_state(id) {
340                State::Custom | State::Upgrading => {
341                    // A same-document reparent that stays connected is one move.
342                    // The disconnect from this mutation's detach is the signal;
343                    // a node that ends disconnected keeps that reaction.
344                    if same_document && self.take_pending_disconnect(id) {
345                        self.record_script_custom_element(id, ReactionKind::Moved);
346                    } else {
347                        self.record_script_custom_element(id, ReactionKind::Connected);
348                    }
349                }
350                State::Undefined
351                    if self.nodes[id].owner_document == Some(self.root_node_id)
352                        && self.script_custom_element_is_defined(id) =>
353                {
354                    self.record_script_custom_element(id, ReactionKind::Upgrade);
355                }
356                _ => {}
357            }
358        }
359    }
360
361    /// Owner changes cost one walk of the adopted subtree. Ordinary insertion
362    /// into the same document only performs the owner comparison.
363    pub fn adopt_script_subtree(&mut self, root: NodeId, new_document: NodeId) {
364        if self.get_node(root).is_none_or(|node| {
365            matches!(node.data, NodeData::Document(_)) || node.owner_document == Some(new_document)
366        }) {
367            return;
368        }
369        let mut stack = vec![root];
370        while let Some(id) = stack.pop() {
371            let Some(node) = self.get_node_mut(id) else {
372                continue;
373            };
374            let old_document = node.owner_document;
375            let attribute_id = node
376                .element_data()
377                .and_then(|element| element.id.as_ref())
378                .map(ToString::to_string);
379            node.owner_document = Some(new_document);
380            stack.extend(node.children.iter().rev().copied());
381            if let Some(contents) = node
382                .element_data()
383                .and_then(|element| element.template_contents)
384            {
385                stack.push(contents);
386            }
387            #[cfg(feature = "shadow-dom")]
388            if let Some(shadow_root) = node.shadow_root_id() {
389                stack.push(shadow_root);
390            }
391            if let Some(attribute_id) = attribute_id {
392                if let Some(old_document) = old_document {
393                    self.remove_from_document_id_map(old_document, &attribute_id, id);
394                }
395                self.add_to_document_id_map(new_document, &attribute_id, id);
396            }
397            if matches!(
398                self.script_custom_element_state(id),
399                State::Custom | State::Upgrading
400            ) && let Some(old_document) = old_document
401                && old_document != new_document
402            {
403                self.record_script_custom_element(
404                    id,
405                    ReactionKind::Adopted {
406                        old_document,
407                        new_document,
408                    },
409                );
410            }
411        }
412    }
413
414    /// Capture the old value before the attribute store changes. Equal-value
415    /// writes still react; removing an absent attribute does not.
416    pub(crate) fn record_script_custom_element_attribute(
417        &mut self,
418        node_id: NodeId,
419        name: &QualName,
420        new_value: Option<&str>,
421    ) {
422        // Upgrade queues the existing attributes before calling the constructor.
423        // Constructor writes occur in the precustomized state and must not add
424        // another set of attribute reactions behind those upgrade callbacks.
425        if self.script_custom_element_state(node_id) != State::Custom {
426            return;
427        }
428        let candidate = &self.script_custom_elements.candidates[&node_id];
429        let Some(definition) = self.script_custom_elements.definitions.get(&candidate.name) else {
430            return;
431        };
432        let local: &str = name.local.as_ref();
433        if !definition.observed.contains(local) {
434            return;
435        }
436        let old_value = self.nodes[node_id]
437            .element_data()
438            .and_then(|element| {
439                element.attrs.iter().find(|attribute| {
440                    attribute.name.ns == name.ns && attribute.name.local == name.local
441                })
442            })
443            .map(|attribute| attribute.value.to_string());
444        if old_value.is_none() && new_value.is_none() {
445            return;
446        }
447        self.record_script_custom_element(
448            node_id,
449            ReactionKind::Attribute {
450                name: name.clone(),
451                old_value,
452                new_value: new_value.map(str::to_owned),
453            },
454        );
455    }
456}