Skip to content

Replace hand-built JS DOM binding plumbing in blitz-vibey-script with typed class layers and a per-instance sized own-data registry - #814

Draft
jerry4718 wants to merge 16 commits into
DioxusLabs:mainfrom
jerry4718:darft/vibey-script
Draft

jerry4718 wants to merge 16 commits into
DioxusLabs:mainfrom
jerry4718:darft/vibey-script

Conversation

@jerry4718

@jerry4718 jerry4718 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

This rewrites the JS DOM class definitions in blitz-vibey-script onto a typed layer ("ExtendLayer") scheme: every DOM interface is a layer whose own data lives in a single per-instance sized-slot registry, prototypes are linked with ES class semantics, and own-block access is a plain Rust-side slot borrow plus a TypeId downcast. Everything is internal to blitz-vibey-script; the DOM/JS behavior surface is unchanged (covered by the existing dom.rs / preact.rs integration tests).

Design

Layers

Each interface is an ExtendLayer chained through a compile-time Parent type, with its own data behind an accessor:

  • Node = Extended<NodeLayer { node_id }> (root layer); CharacterData / Element / Document extend NodeLayer
  • Event = Extended<EventLayer>: configuration (type, target, bubbles, cancelable) and dispatch flags (prevented, stopped, currentTarget via GcRefCell) live in the own block
  • CSSStyleDeclaration / ComputedStyle carry the styled node id

Prototypes are wired with link_prototype (child.prototype.__proto__ = parent.prototype, child.constructor.__proto__ = parent.constructor). Node, Document, Element, CharacterData and Event are registered classes, so new Event(type, init) is a working constructor, instanceof Node/Element/CharacterData answers truthfully through the linked prototype chain (HTMLElement aliases Element), and on<event> IDL properties live on the Node prototype.

Node wrappers are built from their layer chain via from_chain!, backed by the RuntimeState::node_wrappers identity cache.

OwnDataRegistry

Each instance's native data slot holds an OwnDataRegistry (Vec<GcRefCell<Option<Box<dyn OwnSlot>>>>), sized at attach time by the leaf layer's compile-time OwnBlock::DEPTH; each layer addresses its slot with OwnBlock::IDX. The design is ported from blitz-boa-demo.

  • with_own / with_own_mut / set_own_block operate purely on the registry: slot borrows + TypeId downcasts, entirely on the Rust side. Wrapper construction fills the registry slots through from_chain!.
  • GC safety: slots are Box<dyn OwnSlot> with Trace on the trait, so JsValues inside layers (e.g. EventLayer.target) stay reachable. Slot access goes through Option::as_deref to reach &dyn OwnSlot directly — resolving as_any_ref on &Box<dyn OwnSlot> would hit the blanket impl on the box itself and break every downcast (the blanket impl also applies to the box, since Box<dyn OwnSlot>: Any + Trace)

Tested

  • cargo test -p blitz-vibey-script: 21/21 dom.rs, 2/2 preact.rs (real Preact render + todo interaction), doctests pass
  • cargo check --workspace clean
  • cargo build -p browser --features javascript builds

WPT results

1 newly passing, 15 newly failing (net -14), 23 other status changes.

Full diff (39 changed tests)
! Fail => Timeout css/css-anchor-position/anchor-position-inline-001.html
! Fail => Timeout css/css-anchor-position/anchor-position-inline-002.html
! Fail => Timeout css/css-anchor-position/anchor-position-inline-003.html
- Pass => Fail css/css-color/nested-color-mix-with-currentcolor.html
! Fail => Crash css/css-display/display-contents-dynamic-fieldset-legend-001.html
- Pass => Fail css/css-env/env-in-custom-properties.tentative.html
- Pass => Crash css/css-flexbox/dynamic-bsize-change.html
! Fail => Crash css/css-fonts/font-display/font-display-feature-policy-02.tentative.html
! Fail => Crash css/css-fonts/font-display/font-display.html
! Fail => Crash css/css-fonts/variations/font-parse-numeric-stretch-style-weight.html
- Pass => Crash css/css-grid/grid-definition/grid-change-intrinsic-size-with-auto-repeat-tracks-001.html
- Pass => Crash css/css-grid/grid-model/grid-layout-stale-002.html
! Fail => Crash css/css-images/image-orientation/image-orientation-none-cross-origin-svg.html
! Fail => Crash css/css-overflow/dynamic-visible-to-clip-001.html
- Pass => Crash css/css-overflow/dynamic-visible-to-clip-002.html
! Fail => Crash css/css-overflow/overflow-padding.html
! Skip => Fail css/css-overscroll-behavior/overscroll-behavior-single-axis-keyboard.html
! Skip => Fail css/css-overscroll-behavior/overscroll-behavior-single-axis.html
- Pass => Crash css/css-position/hypothetical-dynamic-change-001.html
- Pass => Crash css/css-position/hypothetical-dynamic-change-002.html
! Fail => Crash css/css-position/hypothetical-dynamic-change-003.html
- Pass => Fail css/css-position/position-absolute-crash-chrome-006.html
- Pass => Crash css/css-text/text-align/text-align-match-parent-03.html
- Pass => Fail css/css-text/text-align/text-align-match-parent-04.html
! Skip => Fail css/css-transforms/hittest-preserve-3d.html
- Pass => Fail css/css-values/calc-nesting.html
- Pass => Crash css/cssom-view/long_scroll_composited.html
- Pass => Fail css/cssom-view/offsetTopLeftInScrollableParent.html
+ Fail => Pass css/cssom/cssstyledeclaration-properties.html
! Skip => Fail css/selectors/active-display-none-001.html
! Fail => Crash css/selectors/dir-style-01b.html
! Fail => Crash css/selectors/dir-style-03b.html
- Pass => Crash css/selectors/invalidation/any-link-attribute-removal.html
! Fail => Timeout svg/animations/beginevents-1.html
! Fail => Timeout svg/animations/eventbase-non-svg-element.html
! Fail => Timeout svg/animations/scripted/eventbase-after-removal.html
! Fail => Timeout svg/animations/slider-switch.html
! Fail => Timeout svg/linking/scripted/a.rel-noreferrer-policy.html
! Fail => Crash svg/struct/scripted/svg-getIntersectionList-003.svg

Generated by the WPT workflow.

@jerry4718

Copy link
Copy Markdown
Contributor Author

I am not sure what form Boa will take to enhance the ability to describe inheritance hierarchies on the Rust side, but this approach should facilitate future migration.

@jerry4718

jerry4718 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

There are also some other things here, like Event, EventTarget blitz-boa-demo; I’m not sure if they are appropriate, but I can introduce them gradually if needed.

@jerry4718

Copy link
Copy Markdown
Contributor Author

And I'm also not entirely sure if this counts as being somewhat over-engineered.

@jerry4718 jerry4718 changed the title Migrate blitz-vibey-script DOM bindings onto the Extended<T> layer scheme with sized own-data slots Replace hand-built JS DOM binding plumbing in blitz-vibey-script with typed class layers and a per-instance sized own-data registry Aug 30, 2026
@jerry4718

jerry4718 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Known issue: el.addEventListener and on<event> do not yet participate in event dispatch with standard semantics.

- JS-side dispatch (el.dispatchEvent(...)): only the listeners registered via addEventListener are fired; the on{event} property handler is not consulted at all.
- Rust-driven dispatch (blitz DomEvent → runtime): the on<event> handler is collected separately and always invoked after every addEventListener listener, so the firing order is identical regardless of assignment timing and never assignment-order-sensitive.
- The two paths behave inconsistently for the same event object. The on<event> properties are currently plain data properties on Node.prototype with no registration side effect on assignment, leaving the semantics without a carrier.

Plan: Turn on<event> into accessor properties that register a replaceable listener entry at assignment time and read the current property value at dispatch time, and unify both dispatch paths onto a single listener list.

@jerry4718

jerry4718 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Known issue: Boa does not yet offer a public weak-reference API, which means every Rust-side structure holding a JsObject must hold it strongly — there is no way to decouple a reference's lifetime from the GC heap.

- Wrapper cache (RuntimeState::node_wrappers): once a node has been touched by script, its wrapper is held strongly forever. Even after the node is removed from the document (removeChild, innerHTML rewrites, template re-renders), the wrapper — together with its expando properties and captured closures — has no reclamation path. In long-lived SPA sessions, memory grows monotonically with the number of nodes script has ever touched, and no cleanup strategy can fix this without breaking === identity semantics.
- Listener storage (EventTargetLayer own block): the listener list holds callbacks strongly; when the target object dies, its listeners keep the callbacks alive, and there is no way to deregister them when the target is collected.
- Event objects (DispatchTarget::Direct / lazy target cache): an event.currentTarget retained by script keeps the (possibly detached) node's wrapper, and with it its listener list, alive.

Plan: What the cache needs is a strong/weak switching reference: one reference per node whose keep-alive strength follows the node's document membership.

- Strong = in document. An attached node's wrapper carries Rust-side state (registered listeners), so it must stay alive. Initial mode at first wrap: strong iff the node is in the document (Document node exempt, always strong); freshly created or cloned detached nodes wrap weak.
- Weak = detached. Demote the subtree before removal (removeChild / remove / replaceWith / innerHTML-detach) — the in-document test walks the parent chain, so demoting after the fact is a no-op and the entry leaks strong forever. Promote on insertion completion (appendChild / insertBefore / replaceWith) with the same in-document gate, recursively over the subtree, so inserting into another detached tree stays weak.
- Entry invalidation. When a weak entry dies, drop the cache record; re-insertion goes through the promotion path and re-wraps.

This can only proceed(on Rust-side) after Boa provides a weak reference API.
Pinned Boa to 10ef23505ecae734a4984d7839fec99a7ca8482c

…r scheme

Port the `Extended<T>` inheritance design from blitz-boa-gui: each DOM
interface is an `ExtendLayer` whose own data lives in a per-layer Symbol
slot, prototypes are linked via `link_prototype`, and node wrappers are
built from their layer chain with `from_chain!`.

- Add `shared/` infrastructure: `extends.rs` (Extended<T>/Super/
  Constructed/layer chains), error macros, member-definition macros and
  native-function helpers
- Convert Node, CharacterData, Element, Document, Event,
  CSSStyleDeclaration and ComputedStyle to layers; `new Event()` is now
  a real constructor and dispatch state (currentTarget, flags) lives in
  the EventLayer own block
- Replace the hand-built prototype objects (DomProtos, init_protos,
  NodeRef, define_method/define_accessor) with class registration;
  node_wrapper keeps its identity cache and builds via from_chain!
Replace the per-layer Symbol slots with a single per-instance
`OwnDataRegistry`: one `GcRefCell` slot per real layer, addressed by the
compile-time `OwnBlock::DEPTH`/`IDX` layout (ported from
napi-blitz/crates/napi-inherit). The registry takes over the instance's
native data slot; there are no Symbols and no per-layer JS objects.

- `with_own`/`with_own_mut`/`set_own_block` become pure Rust-side slot
  borrows + `TypeId` downcasts and no longer take `&mut Context`;
  all DOM accessor call sites drop the context argument accordingly
- `OwnSlot` carries `Any` downcasting on a blanket impl (not the trait
  itself) so `dyn OwnSlot: OwnSlot` holds; slot access goes through
  `Option::as_deref` to reach the trait object directly - taking
  `&Box<dyn OwnSlot>` would resolve `as_any_ref` to the blanket impl on
  the box itself and break every downcast
- `Extended<T>` keeps only its class-handle role; `own_symbol` and
  `wrap_own` are gone
The slot list is sized once at attach time and never resized, so a
boxed slice carries the same heap layout as a Vec while dropping the
capacity field from the registry header.

Also tune `#[inline]` placement: drop it from the generic accessors
and drivers (monomorphized bodies are already visible to callers) and
add it to the non-generic tiny members (`SuperDone::this`, the
`RootLayer` chain terminators); `EmitOwn::Chain` now precedes its
methods.
- Add the EventTarget layer as Node's parent; add/removeEventListener
  move there from Node
- Add the event class layers (UIEvent, MouseEvent, PointerEvent,
  WheelEvent, KeyboardEvent, InputEvent) built per DomEventData variant
- Split CharacterData/Text/Comment out of node.rs; text/comment
  wrappers now satisfy instanceof Text/Comment
- Give ExtendLayer::build a default 'Failed to construct ...: Illegal
  constructor' implementation and drop the handwritten ones
- Add DispatchTarget (None / Direct / Callable with cached resolve): event
  construction no longer materializes target/currentTarget wrappers; they
  are built lazily on first getter read through the shared wrapper cache
- Move per-event dispatch state (target, currentTarget, phase, canceled,
  stopPropagation flags) into EventLayer's GcRefCell<EventState> block
- Store listeners in the EventTargetLayer own block instead of the global
  node_listeners map, making new EventTarget() a standard, working target;
  add the standard dispatchEvent method
- Drive the DOM chain walk in three phases (capture / target / bubble)
  with real eventPhase values and reset the transient state afterwards
- Report phase-plan-per-receiver via a DispatchStep; keep the vibey-side
  method bodies (options parsing, once handling, error reporting,
  on<event> handlers, change synthesis) unchanged
- Add tests/events.rs for the event class layers (no DOM involvement,
  results reported via __blitz_send_message) and DOM-dispatch tests in
  tests/dom.rs
- ListenerEntry.callback becomes ListenerCallback: Function,
  HandlerObject { obj, handle_event } (an addEventListener'd event-listener
  object), and AttributeFunction (the on<event> handler, always a function)
- add/removeEventListener register and match only the first two shapes,
  comparing by shape + JS identity; invocation binds `this` per shape —
  the listener object for handleEvent, currentTarget otherwise
- on<event> attribute handlers ride the same listener list through the
  new set/remove_attribute_listener pair; the dispatcher drops its
  dynamic on<event> read, and the property getter reflects the
  registered handler (null once cleared)
…bleRef and GC-driven finalizers

Track node wrappers in a NodeWrappers cache keyed by NodeId, holding each
entry as a SwitchableRef enum of JsObject (strong, while the node is in the
document) and WeakJsObject (weak, once detached), mirroring napi-blitz's
NodeCache and SwitchableRef.

A manual Finalize impl on NodeLayer carries the finalizer logic: each
wrapper's native data holds Weak handles to the runtime state and base
document (captured via Rc::downgrade at construction), so when the GC
collects an unreachable wrapper the finalizer clears the cache entry and,
if no live descendant keeps the subtree alive, drops the detached node
storage via remove_and_drop_node.

Wire strength switching into every DOM mutation entry point
(append_child, insert_before, remove_child, remove, replace_child,
replace_with, set_text_content, set_inner_html, append, prepend, before,
after, replace_children) and port the SharedDocument methods as DomCtx
helpers: is_in_document, make_subtree_strong, make_subtree_weak,
make_in_document_subtree_strong, make_in_document_subtree_weak and
detach_children.

Add ScriptDocument::run_gc (boa_gc::force_collect), pin boa to
10ef23505ecae734a4984d7839fec99a7ca8482c via [patch.crates-io], and relax
layer_chain!'s repetition operators so an empty layer list is accepted.

Add tests/gc.rs covering both GC survival and reclamation: listeners and
expando identity surviving forced GCs on in-document nodes, weak wrappers
revived by re-attachment, detached subtrees kept alive by a live
descendant, every mutation entry point switching entry strength both ways,
and FinalizationRegistry cross-checks (the cleanup-callback test is
#[ignore]d until the executor keeps its pending future alive).
# Conflicts:
#	packages/blitz-vibey-script/src/dom/mod.rs
@jerry4718

Copy link
Copy Markdown
Contributor Author

Pinned Boa to 10ef23505ecae734a4984d7839fec99a7ca8482c

…gation, once and handleEvent

- `node.dispatchEvent()` now walks the full capture/target/bubble chain over
  the node's DOM ancestors (and the window listeners for bubbling events)
  through the shared `dispatch_event_on_chain` walk; a plain `EventTarget`
  still dispatches to itself only
- `stopImmediatePropagation()` takes effect everywhere: between the capture
  and non-capture flavors on the same receiver, at the top of each listener
  loop (a listener that halted the dispatch no longer lets the next one run),
  and across the window-listener loop
- Capture-registered listeners on the target run before the non-capture ones
  in `dispatchEvent`, matching the DOM-walk target phase
- Dispatching an event that is already being dispatched throws an
  `InvalidStateError` DOMException; the previously write-only `dispatching`
  flag is now read
- `once` listeners are removed right before their call instead of in a
  pre-dispatch sweep, so a listener halted by `stopImmediatePropagation`
  stays registered and fires on the next dispatch; `window.addEventListener`
  now parses the `{ once }` option (it was hardcoded to `false`)
- A listener object's `handleEvent` is read off the object at dispatch time
  instead of being cached at registration, so a replacement takes effect
- The dispatch walk no longer materializes wrappers for chain receivers
  without a cached wrapper: listeners live in the wrapper's own block, so
  such nodes have nothing to invoke

New tests cover chain propagation, flavor ordering, cross-flavor halting,
reentrant rejection, once survival, and handleEvent replacement.
# Conflicts:
#	packages/blitz-vibey-script/src/dom/node.rs
#	packages/blitz-vibey-script/src/state.rs
- The global object is born in the realm's host hooks
  (`ScriptHooks::create_global_object`) from pure layer data via the new
  `HostGlobal` trait: `WindowLayer::host_global` attaches the own-data
  registry and fills the Root -> EventTarget -> Window chain through
  `host_fill`. `Window` is now `Extended<WindowLayer>` with every member
  defined by `define_members` on the class prototype, replacing
  runtime.rs's flat `register_global_*` registry (timers, viewport,
  location/navigator, CSS, `__blitz_*`, etc.)
- `HostGlobal` is an explicit opt-in for host-owned globals, implemented
  by `WindowLayer` only: it leaves `EmitOwn` untouched, and its provided
  `host_global` constructs the registry inside shared, so no private API
  leaks
- `location`/`navigator` are [SameObject] getters backed by the window's
  own block, built from the runtime's base URL on first access in the
  current realm
- `addEventListener`/`removeEventListener` follow WebIDL semantics: an
  interface operation called with no receiver (or detached) receives
  `undefined` as `this` and binds the global this (the window). The
  binding lives in the base class's `add_event_listener` /
  `remove_event_listener`, so the window inherits the methods instead of
  defining its own wrappers
- The `on<event>` attribute definition moves from dom/node.rs into the
  generic `define_on_event_attributes(types)` in event_target.rs, each
  class supplying its own event-type set (Node, Window)
- Drop the stale `window_listeners` bookkeeping from state.rs (window
  listeners live in its EventTarget own block) and the whole
  `register_global_*` surface in runtime.rs

New tests cover the window as the global object and event target,
prototype members, [SameObject] location/navigator, bare and detached
listener binding, timers and window event dispatch.
A bare or detached dispatchEvent call sees a nullish this; substitute the
global object per the ordinary-call this rule, as the listener
registration/removal paths already do, instead of failing on `undefined`.
Also drops the `crate::shared::` prefix on `native_error!` uses and adds
a window.rs test for the detached dispatch.
make_weak no longer swaps the cache entry in place while a RuntimeState or
document borrow may be alive; Strong entries are queued (pending_weak) and
switched at flush_wrapper_switches, called at the end of each JS entry point
(execute_scripts/eval/dispatch_dom_event/handle_ui_event/poll) where neither
RefCell is borrowed. The checkpoint then runs force_collect and
reclaim_detached_node, taking over the cleanup that used to run from
NodeLayer's Finalize impl - now removed along with its state/doc back-refs.

This keeps boa_gc's synchronous collections (triggered by WeakJsObject::new
allocation or the checkpoint's force_collect) from re-entering borrowed
RefCells, eliminating the "RefCell already borrowed" panics. WPT full run:
crashes 386 -> 22, all remaining are the unrelated BorrowMutError family in
boa's property setters.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant