1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
//! Shared state accessible from both the Rust side (`ScriptDocument`) and the
//! JavaScript side (native functions registered with the Boa `Context`).
use blitz_dom::NodeId;
use std::cell::Cell;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use blitz_dom::BaseDocument;
use boa_engine::object::{JsObject, WeakJsObject};
use boa_engine::{Finalize, JsData, Trace};
use rustc_hash::FxHashMap;
use crate::timers::TimerQueue;
/// Prototype objects for the DOM wrapper classes
pub(crate) struct DomProtos {
pub node: JsObject,
pub element: JsObject,
pub character_data: JsObject,
pub document: JsObject,
pub event: JsObject,
pub style: JsObject,
}
/// An event listener registered via `addEventListener`
#[derive(Clone)]
pub(crate) struct Listener {
pub callback: JsObject,
pub once: bool,
}
pub(crate) type ListenerMap = HashMap<String, Vec<Listener>>;
/// A node listener is owned by its JavaScript node wrapper, not by this native
/// lookup index. Keeping a second strong callback here creates an uncollectable
/// cycle when a closure captures its node: registry -> callback -> wrapper.
#[derive(Clone)]
pub(crate) struct NodeListener {
pub callback: WeakJsObject,
pub capture: bool,
pub once: bool,
}
pub(crate) type NodeListenerMap = HashMap<String, Vec<NodeListener>>;
pub(crate) type IpcHandler = std::rc::Rc<dyn Fn(String)>;
/// State owned by the script runtime but shared (via `Rc`) with the native
/// functions exposed to JavaScript.
///
/// Note: this struct stores Boa GC handles (`JsObject`s) in ordinary Rust
/// collections. That is sound because Boa GC handles held outside of the GC
/// heap act as roots (they keep their referents alive).
#[derive(Default)]
pub(crate) struct RuntimeState {
/// Prototypes for DOM wrapper objects. Set once during runtime initialisation.
pub protos: Option<DomProtos>,
/// Cache of JS wrapper objects, keyed by node id.
///
/// DOM wrappers must be cached so that a given DOM node is always represented
/// by the *same* JS object: scripts rely on object identity (`===`) and on
/// expando properties persisting across accesses.
///
/// Keys are node ids the document allocated, not anything a page controls,
/// so there is nothing here for HashDoS resistance to defend and the
/// default SipHash is pure cost. This map is hit on every node touch, so
/// that cost is paid thousands of times per mount.
/// Weak, so that the cache is not the reason a wrapper stays alive.
///
/// Held strongly, this map pinned every node script had ever touched, and
/// `createElement` touches all of them. That made "can script still reach
/// this node" unanswerable, so `removeChild` kept every removed node for
/// the life of the document: measured on a real application, 98,646 nodes
/// where a fresh window holds 635, 91% of them detached and boxless.
///
/// Weak handles hand the question to the collector, which already knows.
/// An entry whose wrapper has been collected upgrades to `None`, and
/// [`node_wrapper`](crate::dom::node_wrapper) rebuilds one on demand, so
/// identity still holds for every wrapper anything is actually holding.
pub node_wrappers: FxHashMap<NodeId, WeakJsObject>,
/// Cache of `DOMStringMap` proxy objects returned by `Element.dataset`.
pub dataset_wrappers: FxHashMap<NodeId, WeakJsObject>,
/// Cache of `DOMTokenList` objects returned by `Element.classList`.
pub class_list_wrappers: FxHashMap<NodeId, WeakJsObject>,
/// Event listeners registered on nodes, keyed by node id then event type.
pub node_listeners: FxHashMap<NodeId, NodeListenerMap>,
/// Strong roots for wrappers while their nodes are in the live document.
///
/// DOM nodes own their JavaScript wrappers in a browser. Keeping that
/// relationship here preserves arbitrary expando properties such as
/// Solid's delegated `$$click` handler. Detaching a subtree removes these
/// roots after linking its wrappers together inside Boa's heap.
pub connected_wrappers: FxHashMap<NodeId, JsObject>,
/// Nodes detached from the document but not yet freed.
///
/// A node is removed while script may still hold its wrapper, and whether
/// it does cannot be known at that instant: the wrapper is almost always
/// still alive right after the call that removed it. So the id is parked
/// here, and the sweep in [`ScriptDocument::poll`] drops the ones whose
/// wrapper the collector has since taken.
///
/// Without this, every removed node stayed for the life of the document.
/// Measured on a real application: 98,646 nodes where a fresh window holds
/// 635, one abandoned subtree per list row that scrolled out of view.
pub detached_nodes: Vec<NodeId>,
/// How many of `detached_nodes` the last sweep kept because script still
/// holds them. Only nodes detached since then count toward a forced GC.
pub detached_kept: usize,
/// Event listeners registered on `window`.
pub window_listeners: ListenerMap,
/// Active Pointer Events capture target, keyed by the web-facing pointer id.
pub pointer_capture: FxHashMap<u64, NodeId>,
/// Host callback backing `window.ipc.postMessage`, installed by an embedder.
pub ipc_handler: Option<IpcHandler>,
/// Pending timers (`setTimeout`/`setInterval`/`requestAnimationFrame`)
pub timers: TimerQueue,
/// Constructors registered through `customElements.define`, by tag name.
///
/// Separate from `blitz-dom`'s own custom element registry, which holds
/// Rust `Box<dyn CustomElement>` definitions an embedder supplies. This one
/// is what a page can reach.
pub custom_element_definitions: FxHashMap<String, JsObject>,
}
impl RuntimeState {
pub fn protos(&self) -> &DomProtos {
self.protos
.as_ref()
.expect("DOM prototypes not initialised")
}
}
/// Cloneable handle to the document and the runtime state. This is stored as
/// host-defined data on the Boa [`Context`](boa_engine::Context) so that native
/// functions can access the DOM.
#[derive(Clone, Trace, Finalize, JsData)]
pub(crate) struct DomCtx {
#[unsafe_ignore_trace]
pub doc: Rc<RefCell<BaseDocument>>,
#[unsafe_ignore_trace]
pub state: Rc<RefCell<RuntimeState>>,
/// Whether the DOM has been mutated since layout last ran.
///
/// Browsers flush layout synchronously when script reads geometry, which
/// is why `element.scrollHeight` immediately after an insertion returns
/// the new height. Blitz read `final_layout` directly, so the same read
/// returned the height from *before* the mutation. Code that measures,
/// mutates, then re-measures to restore scroll position therefore did its
/// arithmetic on stale numbers and put the viewport in the wrong place,
/// which is what a reader sees as the view jumping.
#[unsafe_ignore_trace]
pub layout_dirty: Rc<std::cell::Cell<bool>>,
/// Deep profiling mode selected by the enclosing script boundary.
///
/// DOM bindings are the hottest collector call sites. They read this local
/// cell instead of reloading the process-wide atomic for every attribute,
/// node, style, or geometry operation.
#[unsafe_ignore_trace]
deep_profiling: Rc<Cell<bool>>,
#[unsafe_ignore_trace]
profiling_boundary_depth: Rc<Cell<u32>>,
}
impl DomCtx {
pub fn new(doc: Rc<RefCell<BaseDocument>>) -> Self {
Self {
doc,
state: Rc::new(RefCell::new(RuntimeState::default())),
layout_dirty: Rc::new(std::cell::Cell::new(true)),
deep_profiling: Rc::new(Cell::new(false)),
profiling_boundary_depth: Rc::new(Cell::new(0)),
}
}
/// Select deep profiling once at the outermost script boundary.
///
/// A poll hook may call `document.eval`, and script may synchronously
/// dispatch more script. Nested boundaries inherit the outer decision so
/// no inner collector rereads the process-wide flag halfway through work.
pub(crate) fn enter_profiling_boundary(&self) -> ProfilingBoundary {
let depth = self.profiling_boundary_depth.get();
if depth == 0 {
self.deep_profiling
.set(blitz_traits::profiling::deep_profiling_enabled());
}
self.profiling_boundary_depth.set(depth + 1);
ProfilingBoundary {
deep_profiling: Rc::clone(&self.deep_profiling),
depth: Rc::clone(&self.profiling_boundary_depth),
}
}
/// Read the current boundary-selected mode without touching global state.
///
/// Gated to match its only caller, `script_stats::Timed::new`. `dom-stats`
/// is off by default because a shipping build has no reader for it, which
/// makes this method dead in exactly the build CI checks, and
/// `cargo clippy --workspace -- -D warnings` fails on it.
#[cfg(feature = "dom-stats")]
pub(crate) fn deep_profiling_enabled(&self) -> bool {
self.deep_profiling.get()
}
}
pub(crate) struct ProfilingBoundary {
deep_profiling: Rc<Cell<bool>>,
depth: Rc<Cell<u32>>,
}
impl ProfilingBoundary {
pub(crate) fn enabled(&self) -> bool {
self.deep_profiling.get()
}
}
impl Drop for ProfilingBoundary {
fn drop(&mut self) {
let depth = self.depth.get().saturating_sub(1);
self.depth.set(depth);
if depth == 0 {
self.deep_profiling.set(false);
}
}
}
impl DomCtx {
/// Note that script changed the DOM, so the next geometry read flushes.
pub fn mark_layout_dirty(&self) {
self.layout_dirty.set(true);
}
/// Borrow the document for a mutation that can move layout.
///
/// The dirty flag was originally set at each call site, and the call sites
/// it reached were the tree ones. Attribute writes through the reflected
/// properties (`className`, `id`), every inline style write, `nodeValue`
/// and `remove()` all mutated without setting it, so the stale-geometry
/// bug this flag exists to prevent was still reachable through them: set a
/// class, read `offsetHeight`, get the height from before the change.
///
/// Going through here means a mutation cannot forget. Node *creation* is
/// deliberately not routed through it: a detached node changes no layout
/// until it is inserted, and marking there would flush on the next read
/// for nothing.
pub fn mutate_doc(&self) -> std::cell::RefMut<'_, BaseDocument> {
self.layout_dirty.set(true);
let doc = self.doc.borrow_mut();
// Ask for a frame as well as marking layout stale.
//
// Marking only helps the next geometry read. Nothing else was asking
// the shell to draw, so a mutation with no event behind it, a cleanup
// removing a dialog, a promise resolving, a timer callback, updated the
// tree and then waited for some unrelated thing to schedule a frame.
// The visible form was a dismissed dialog whose pixels stayed on
// screen and a layout that did not reflow into the space it left.
//
// Coalesced by the shell: `request_redraw` is a no-op while a redraw is
// already pending, so a mutation loop asks once per frame, not once per
// node.
doc.shell_provider.request_redraw();
doc
}
/// Bring layout up to date before script observes geometry.
///
/// Only resolves when something actually changed: a reader that measures
/// in a loop pays once, not once per element. Incremental layout makes the
/// flush itself cheap, which is what makes doing this at all affordable.
pub fn flush_layout(&self) {
if !self.layout_dirty.replace(false) {
return;
}
// Attributed separately from the read that triggered it. A geometry
// read is nanoseconds; the resolve it forces is not, and rolling the
// two together reports an accessor as expensive when what is expensive
// is the mutation that preceded it. The count matters as much as the
// total: a handler that measures, mutates and measures again pays this
// once per cycle, and the cycles are what to remove.
let _t = crate::script_stats::Timed::new(self, "layout:flush_from_script");
if let Ok(mut doc) = self.doc.try_borrow_mut() {
doc.resolve(0.0);
} else {
// Already borrowed further up the stack, so a resolve here would
// panic. Leave the flag set so the next read tries again rather
// than silently serving stale geometry forever.
self.layout_dirty.set(true);
}
}
}