RVST currently uses lightningcss for parsing + a hand-written selector matcher + manual property application. This works for simple cases but breaks on:
- Complex selectors (
:is(),:where(),:has(), nested selectors) - Proper cascade layers (
@layer) - CSS custom property inheritance across shadow-like boundaries
- Container queries (
@container) - Incremental restyle (currently re-matches all rules on every change)
- Specificity edge cases in Svelte-generated CSS
Stylo is Firefox's production CSS engine, extracted as a standalone crate. Dioxus Blitz already uses it successfully with Taffy for layout. It gives us browser-grade correctness for free.
Stylo is a workspace of ~12 crates. The key ones for integration:
| Crate | crates.io name | Purpose |
|---|---|---|
style/ |
stylo (lib name: style) |
Main engine: parsing, cascade, computed values |
selectors/ |
selectors |
Selector parsing + matching (generic over DOM) |
style_traits/ |
stylo_traits |
Shared types (ToCss, etc) |
stylo_dom/ |
stylo_dom |
ElementState, DocumentState bitflags |
stylo_atoms/ |
stylo_atoms |
Interned strings (Atom) for CSS/HTML names |
servo_arc/ |
servo_arc |
Arc variant with thin-arc and copy-on-write |
malloc_size_of/ |
stylo_malloc_size_of |
Heap measurement (required dep) |
Current published version: 0.16.0 (workspace version).
Feature flag: servo (not gecko). The servo feature enables the standalone path
without Firefox/Gecko bindings.
Stylo operates on its own DOM trait hierarchy:
TDocument -- owns SharedRwLock, quirks mode
TNode -- parent/child/sibling traversal, Copy + Clone + Debug
TElement -- selector matching, style data storage, class/id/attr access
TShadowRoot -- shadow DOM (can be a no-op stub)
Key constraint: TNode and TElement must be Copy + Clone. They are lightweight
handles (pointers/indices), not owned data. Stylo traverses the tree by copying these
handles around.
RVST's tree is a HashMap<NodeId, Node> with NodeId = u32. This maps naturally to
Stylo's handle model: our adapter types will be (NodeId, &Tree) pairs.
// Lightweight Copy handle -- satisfies TNode's Copy requirement
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct RvstNode<'a> {
id: NodeId,
tree: &'a TreeData, // shared ref to tree + style data
}
// Same type serves as both TNode and TElement
// (RVST has no distinction -- all nodes can have styles)
// TElement is only returned for element nodes (not text nodes)
pub type RvstElement<'a> = RvstNode<'a>;
// Stub -- RVST has no shadow DOM
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RvstShadowRoot;
// Document handle
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RvstDocument<'a> {
tree: &'a TreeData,
}Stylo's TElement::ensure_data() needs somewhere to store ElementData per node.
We cannot put it inside rvst_tree::Node without coupling rvst-tree to Stylo.
Instead, rvst-stylo maintains a parallel map:
pub struct TreeData {
pub tree: Tree, // the rvst-tree
pub element_data: HashMap<NodeId, AtomicRefCell<ElementData>>,
pub lock: SharedRwLock, // Stylo's shared lock
}This is the Blitz pattern: store Stylo's ElementData alongside (not inside) the DOM.
| Method | RVST mapping |
|---|---|
local_name() |
NodeType -> atom: View="div", Button="button", Input="input", etc |
has_namespace() |
Always HTML namespace |
id() |
node.styles.get("id") |
each_class() |
Parse node.styles.get("class") by whitespace |
style_attribute() |
Build PropertyDeclarationBlock from node.styles (inline styles) |
parent_element() |
tree.nodes[node.parent] |
prev_sibling_element() / next_sibling_element() |
Walk parent's children list |
has_dirty_descendants() |
Check node.dirty flag |
ensure_data() |
Insert into TreeData::element_data |
borrow_data() / mutate_data() |
Borrow from AtomicRefCell |
state() |
Map hovered/focused to ElementState::HOVER/ElementState::FOCUS |
animation_rule() / transition_rule() |
Return None initially (animations come later) |
op_load_css(text) -> lightningcss::StyleSheet::parse() -> CssEngine.rules (Vec<ParsedRule>)
op_load_css(text) -> style::stylesheets::Stylesheet::from_str()
-> stylist.append_stylesheet()
-> stylist.flush() // rebuilds cascade data
Stylo's Stylesheet::from_str() takes CSS text, a URL origin, and a SharedRwLock.
The Stylist manages the full cascade (UA + user + author origins).
use style::stylesheets::{Stylesheet, Origin, DocumentStyleSheet};
use style::shared_lock::SharedRwLock;
pub fn load_css(stylist: &mut Stylist, css_text: &str, lock: &SharedRwLock) {
let sheet = Stylesheet::from_str(
css_text,
UrlExtraData::from(...), // base URL
Origin::Author,
Arc::new(lock.wrap(MediaList::empty())),
lock.clone(),
/* loader */ None,
/* error_reporter */ None,
QuirksMode::NoQuirks,
AllowImportRules::Yes,
);
let doc_sheet = DocumentStyleSheet(Arc::new(sheet));
stylist.append_stylesheet(doc_sheet, &guards);
stylist.flush(&guards);
}CssEngine::match_node(tree, node_id) -> HashMap<String, String>
then layout.rs parses strings manually: "16px" -> 16.0f32
// After traversal, each element has computed styles:
let data = element.borrow_data().unwrap();
let style = data.styles.primary(); // Arc<ComputedValues>
// Access typed values directly:
let display = style.get_box().display; // Display enum
let width = style.get_position().width; // Size<LengthPercentageAuto>
let color = style.get_color().color; // AbsoluteColor
let margin = style.get_margin().margin_top; // LengthPercentageOrAuto
let padding = style.get_padding().padding_top; // LengthPercentage
let font_size = style.get_font().font_size; // FontSize
No string parsing. Stylo gives us typed, resolved, inherited computed values.
The values.rs module converts Stylo's computed values to Taffy's layout types.
This is the same pattern Blitz uses in their stylo_to_taffy module.
Key conversions:
// Display
style.get_box().display -> taffy::Display { Flex, Grid, Block, None }
// Dimensions
style.get_position().width -> taffy::Dimension { Length, Percent, Auto }
style.get_position().height -> same
// Margin/Padding/Border
style.get_margin().margin_* -> taffy::LengthPercentageAuto
style.get_padding().padding_* -> taffy::LengthPercentage
style.get_border().border_*_width -> taffy::LengthPercentage
// Flex
style.get_position().flex_direction -> taffy::FlexDirection
style.get_position().flex_wrap -> taffy::FlexWrap
style.get_position().flex_grow/shrink -> f32
style.get_position().flex_basis -> taffy::Dimension
style.get_position().justify_content -> taffy::JustifyContent
style.get_position().align_items -> taffy::AlignItems
style.get_position().gap -> taffy::Size<LengthPercentage>
// Grid
style.get_position().grid_template_columns -> taffy::TrackSizingFunction
style.get_position().grid_template_rows -> sameThe values.rs module will provide:
pub fn computed_to_taffy_style(computed: &ComputedValues) -> taffy::Style { ... }
pub fn computed_to_paint_props(computed: &ComputedValues) -> PaintProps { ... }PaintProps is a new struct holding color, background, border-radius, opacity, etc.
for the compositor -- replacing the current string-based property extraction in composite.rs.
Stylo has built-in incremental restyle via its invalidation system:
- When a node's class changes: call
element.note_state_change()or mark snapshot - When a node is added/removed: mark parent dirty
- Call
style::traversal::recalc_style_at()which only recomputes dirty subtrees
The TreeData wrapper exposes:
pub fn mark_node_dirty(&mut self, id: NodeId) { ... }
pub fn restyle(&mut self) { ... } // runs Stylo's incremental traversalFor the initial implementation, we can do full restyle on every frame (same as current behavior). Incremental optimization comes in a follow-up.
[dependencies]
stylo = { version = "0.16", default-features = false, features = ["servo"] }
selectors = "0.37"
servo_arc = "0.4"
stylo_atoms = "0.16"
stylo_dom = "0.16"
stylo_traits = "0.16"
stylo_static_prefs = "0.16"
stylo_malloc_size_of = "0.16"| RVST dep | Stylo dep | Conflict? |
|---|---|---|
| lightningcss 1.0.0-alpha.71 | cssparser 0.36 | No conflict (different crates) |
| taffy 0.9 | (none) | No conflict |
| serde 1 | serde 1 | Compatible |
lightningcss and Stylo use different CSS parsers (lightningcss has its own; Stylo uses cssparser). They can coexist during migration. After migration, lightningcss is removed from rvst-engine.
- Stylo is large (~200 source files). Expect 30-60s added compile time.
- The
servofeature pulls instring_cache,encoding_rs,url-- moderate dep tree. - Stylo requires a build script that generates property tables. This is self-contained.
- Create rvst-stylo crate with adapter skeleton
- rvst-engine keeps lightningcss-based css.rs untouched
- No behavior changes
- Implement TElement/TNode fully
- Wire
op_load_cssto feed Stylo's Stylist - Run Stylo traversal to produce ComputedValues per node
- Write
values.rsto convert ComputedValues to Taffy styles - Keep css.rs as fallback (feature-gated)
- Replace layout.rs string parsing with
computed_to_taffy_style() - Replace composite.rs string parsing with
computed_to_paint_props() - Remove manual
parse_abs_len_px()and friends
- Hook node mutations (class change, add/remove) to Stylo's invalidation
- Use
RecalcStyle::pre_traverse()for dirty checking - Only restyle changed subtrees
- Remove lightningcss dependency from rvst-engine
- Remove css.rs (ParsedRule, SelectorChain, manual matcher)
- Remove layout.rs string parsing functions
- Nothing. Stylo replaces the entire pipeline: parsing, matching, cascade, computed values.
- The transition logic (
transition.rs) stays -- it operates on computed value changes.
rvst-shell/src/css.rshas a duplicate CssEngine -- same migration appliesrvst-web/src/lib.rsuses CssEngine -- will switch to rvst-stylo API- Node's
styles: HashMap<String, String>remains for inline styles (JS-set props) but is read by the Stylo adapter instead of the manual matcher
Blitz (DioxusLabs/blitz) is the primary reference. Their blitz-dom crate:
- Stores
ElementDatain aStyloDatafield on each node - Implements TElement/TNode on
&Node(borrowed reference) - Converts Stylo computed values to Taffy in a
stylo_to_taffymodule - Uses
SharedRwLock+StylesheetGuardsfor thread-safe style data access - Runs Stylo's parallel traversal via Rayon
Key difference: Blitz owns a full HTML DOM (slab-allocated). RVST has a simpler
HashMap<NodeId, Node> tree driven by Svelte 5 ops. Our adapter is thinner.