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 7 commits into
DioxusLabs:mainfrom
jerry4718:darft/vibey-script
Draft

Replace hand-built JS DOM binding plumbing in blitz-vibey-script with typed class layers and a per-instance sized own-data registry#814
jerry4718 wants to merge 7 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

5 newly passing, 1 newly failing (net +4), 33 other status changes.

Full diff (39 changed tests)
+ Timeout => Pass css/css-anchor-position/position-area-parsing.html
! Timeout => Fail css/css-backgrounds/animations/background-position-origin-interpolation.html
! Timeout => Fail css/css-backgrounds/animations/background-size-interpolation.html
! Timeout => Fail css/css-backgrounds/animations/border-image-source-interpolation.html
! Timeout => Fail css/css-color/parsing/color-computed-color-mix-function.html
! Timeout => Fail css/css-color/parsing/color-valid-color-mix-function.html
+ Timeout => Pass css/css-color/parsing/color-valid-relative-color.html
! Timeout => Fail css/css-fonts/animations/font-size-adjust-composition.html
! Timeout => Fail css/css-fonts/animations/font-size-adjust-interpolation.html
! Timeout => Fail css/css-fonts/test_font_family_parsing.html
! Timeout => Fail css/css-gaps/animation/column-rule-inset-interpolation.html
! Timeout => Fail css/css-gaps/animation/row-rule-inset-interpolation.html
! Timeout => Fail css/css-grid/animation/grid-no-interpolation.html
- Pass => Crash css/css-grid/grid-model/grid-layout-stale-002.html
+ Timeout => Pass css/css-images/parsing/gradient-interpolation-method-computed.html
+ Timeout => Pass css/css-images/parsing/gradient-interpolation-method-valid.html
! Timeout => Fail css/css-lists/css-lists-no-interpolation.html
! Timeout => Fail css/css-masking/animations/clip-interpolation.html
! Timeout => Fail css/css-masking/animations/clip-path-interpolation-shape-control-points.html
! Timeout => Fail css/css-masking/animations/mask-border-slice-interpolation.html
! Timeout => Fail css/css-masking/animations/mask-border-width-interpolation.html
! Timeout => Fail css/css-masking/animations/mask-image-interpolation.html
! Timeout => Fail css/css-shapes/animation/shape-outside-path-interpolation.html
! Timeout => Fail css/css-shapes/animation/shape-outside-shape-interpolation.html
! Timeout => Fail css/css-shapes/shape-outside/values/shape-outside-inset-001.html
! Timeout => Fail css/css-sizing/animation/max-height-interpolation.html
! Timeout => Fail css/css-sizing/animation/max-width-interpolation.html
! Timeout => Fail css/css-sizing/animation/min-height-interpolation.html
! Timeout => Fail css/css-sizing/animation/min-width-interpolation.html
! Timeout => Fail css/css-transforms/animation/rotate-interpolation.html
! Timeout => Fail css/css-transforms/animation/scale-interpolation.html
! Timeout => Fail css/css-transforms/animation/transform-interpolation-004.html
! Timeout => Fail css/css-transforms/animation/translate-interpolation.html
! Timeout => Fail css/css-values/calc-size/animation/interpolate-size-max-width-interpolation.html
+ Fail => Pass css/cssom/cssstyledeclaration-properties.html
! Timeout => Fail css/filter-effects/animation/backdrop-filter-composition-001.html
! Timeout => Fail css/filter-effects/animation/filter-interpolation-003.html
! Timeout => Fail css/motion/animation/offset-path-interpolation-001.html
! Timeout => Fail css/motion/animation/offset-rotate-interpolation.html

Generated by the WPT workflow.

…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
@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

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.

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
@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

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.

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