diff --git a/.github/workflows/vdom-fuzz-comment.yml b/.github/workflows/vdom-fuzz-comment.yml new file mode 100644 index 0000000000..340028653b --- /dev/null +++ b/.github/workflows/vdom-fuzz-comment.yml @@ -0,0 +1,66 @@ +name: VDOM Fuzz Comment + +# Downloads the `fuzz-coverage` artifact produced by `VDOM Fuzz` and posts +# its rendered markdown to the originating PR as a tagged comment that +# upserts on rerun. Runs in base-repo context with `pull-requests: write` +# so it can comment on PRs from forks. +# +# This workflow never checks out the head SHA or runs fork code; its only +# fork input is the markdown body inside the artifact. + +on: + workflow_run: + workflows: ["VDOM Fuzz"] + types: [completed] + +permissions: + pull-requests: write + actions: read + +jobs: + comment: + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Download fuzz-coverage artifact + uses: actions/download-artifact@v6 + with: + name: fuzz-coverage + path: artifact + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Resolve PR number + id: pr + uses: actions/github-script@v7 + with: + script: | + // workflow_run.pull_requests is populated only for same-repo + // PRs; for fork PRs we look the PR up by its head ref. + const run = context.payload.workflow_run; + if (run.pull_requests && run.pull_requests.length > 0) { + core.setOutput('number', run.pull_requests[0].number); + return; + } + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${run.head_repository.owner.login}:${run.head_branch}`, + }); + if (prs.length === 0) { + core.setFailed( + `no open PR found for head ${run.head_repository.owner.login}:${run.head_branch}`, + ); + return; + } + core.setOutput('number', prs[0].number); + + - name: Post fuzz coverage comment + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3 + with: + pr-number: ${{ steps.pr.outputs.number }} + filePath: artifact/fuzz-coverage.md + comment-tag: fuzz-coverage diff --git a/.github/workflows/vdom-fuzz.yml b/.github/workflows/vdom-fuzz.yml new file mode 100644 index 0000000000..9a98f0d184 --- /dev/null +++ b/.github/workflows/vdom-fuzz.yml @@ -0,0 +1,229 @@ +name: VDOM Fuzz + +on: + push: + branches: + - main + paths: + - ".github/workflows/vdom-fuzz.yml" + - "Cargo.lock" + - "Cargo.toml" + - "codecov.yml" + - "packages/fuzz/**" + - "packages/oracle/**" + - "packages/core/**" + - "packages/core-types/**" + - "packages/dioxus/**" + - "packages/ssr/**" + + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: + - main + paths: + - ".github/workflows/vdom-fuzz.yml" + - "Cargo.lock" + - "Cargo.toml" + - "codecov.yml" + - "packages/fuzz/**" + - "packages/oracle/**" + - "packages/core/**" + - "packages/core-types/**" + - "packages/dioxus/**" + - "packages/ssr/**" + + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_INCREMENTAL: 0 + CARGO_TERM_COLOR: always + FUZZ_DIR: packages/fuzz/fuzz + FUZZ_TARGET: vdom_ops + # Directory the coverage report and PR comment are scoped to. The fuzz + # target exercises core's diffing algorithm, so the meaningful number is + # how much of that algorithm got executed. + FUZZ_COVERAGE_SOURCES: packages/core/src/diff + RUST_BACKTRACE: 1 + rust_nightly: nightly-2025-10-05 + # `cargo fuzz run` (smoke test) and `cargo fuzz coverage` both + # produce binaries that trip LSan on `generational_box`'s and the + # fuzz harness's intentional `Box::leak` sites. Apply the same + # suppression file to every step that runs the fuzzer. + LSAN_OPTIONS: suppressions=${{ github.workspace }}/packages/fuzz/fuzz/lsan.supp,print_suppressions=1 + +jobs: + test-and-coverage: + if: github.event.pull_request.draft == false + name: "Fuzz | Test and coverage" + runs-on: warp-ubuntu-latest-x64-4x + timeout-minutes: 45 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v5 + + - name: Install Rust ${{ env.rust_nightly }} + uses: dtolnay/rust-toolchain@nightly + with: + toolchain: ${{ env.rust_nightly }} + components: llvm-tools-preview + + - uses: dtolnay/install@cargo-fuzz + + - uses: Swatinem/rust-cache@v2 + with: + cache-all-crates: "true" + cache-workspace-crates: "true" + cache-provider: "warpbuild" + + - name: Test fuzz support crate + run: cargo test -p dioxus-vdom-fuzz --lib --examples + + - name: Resolve LSan symbolizer + # Both the smoke test and `cargo fuzz coverage` need this on PATH + # for LSan to demangle symbols (and therefore for the `leak:` + # suppressions to match). Write it to $GITHUB_ENV so every later + # step inherits it. + run: | + target_triple="$(rustc +${{ env.rust_nightly }} -vV | sed -n 's/^host: //p')" + rustup_symbolizer="$(rustc +${{ env.rust_nightly }} --print sysroot)/lib/rustlib/$target_triple/bin/llvm-symbolizer" + if [ -x "$rustup_symbolizer" ]; then + symbolizer="$rustup_symbolizer" + elif command -v llvm-symbolizer >/dev/null; then + symbolizer="$(command -v llvm-symbolizer)" + else + symbolizer="$(ls /usr/bin/llvm-symbolizer-* 2>/dev/null | sort -V | tail -n1)" + fi + if [ -z "$symbolizer" ] || [ ! -x "$symbolizer" ]; then + echo "no usable llvm-symbolizer found" >&2 + exit 1 + fi + echo "ASAN_SYMBOLIZER_PATH=$symbolizer" | tee -a "$GITHUB_ENV" + + - name: Smoke test fuzz target + run: | + mkdir -p "$RUNNER_TEMP/fuzz-corpus" "$RUNNER_TEMP/fuzz-artifacts" + cargo +${{ env.rust_nightly }} fuzz run --fuzz-dir "$FUZZ_DIR" "$FUZZ_TARGET" "$RUNNER_TEMP/fuzz-corpus" -- \ + -runs=256 \ + -artifact_prefix="$RUNNER_TEMP/fuzz-artifacts/" + + - name: Generate fuzz coverage + id: coverage + env: + # Smoke test already runs LSan; the coverage build is purely for + # llvm-cov instrumentation. With leak detection on, cargo-fuzz's + # MERGE-mode child exits non-zero even when every leak is + # suppressed, which fails the step despite the profdata being + # written successfully. + ASAN_OPTIONS: detect_leaks=0 + run: | + cargo +${{ env.rust_nightly }} fuzz coverage --fuzz-dir "$FUZZ_DIR" "$FUZZ_TARGET" "$RUNNER_TEMP/fuzz-corpus" -- -runs=0 + + target_triple="$(rustc +${{ env.rust_nightly }} -vV | sed -n 's/^host: //p')" + llvm_cov="$(rustc +${{ env.rust_nightly }} --print sysroot)/lib/rustlib/$target_triple/bin/llvm-cov" + # cargo-fuzz's coverage mode overrides --target-dir to + # `/target//coverage` (see project.rs::target_dir), + # so the binary lands in the workspace-root `target/`, not in + # `$FUZZ_DIR/target/`. + coverage_binary="target/$target_triple/coverage/$target_triple/release/$FUZZ_TARGET" + coverage_profile="$FUZZ_DIR/coverage/$FUZZ_TARGET/coverage.profdata" + coverage_lcov="$RUNNER_TEMP/fuzz.lcov" + coverage_report="$RUNNER_TEMP/fuzz-coverage.txt" + coverage_comment="$RUNNER_TEMP/fuzz-coverage.md" + + test -x "$coverage_binary" + test -s "$coverage_profile" + + "$llvm_cov" report \ + --instr-profile="$coverage_profile" \ + "$coverage_binary" \ + --sources "$FUZZ_COVERAGE_SOURCES" \ + | tee "$coverage_report" + + "$llvm_cov" export \ + --format=lcov \ + --instr-profile="$coverage_profile" \ + "$coverage_binary" \ + --sources "$FUZZ_COVERAGE_SOURCES" \ + > "$coverage_lcov" + + test -s "$coverage_lcov" + test -s "$coverage_report" + + COVERAGE_REPORT="$coverage_report" \ + COVERAGE_COMMENT="$coverage_comment" \ + python3 - <<'PY' + import os + import sys + from pathlib import Path + + report_path = Path(os.environ["COVERAGE_REPORT"]) + comment_path = Path(os.environ["COVERAGE_COMMENT"]) + output_path = Path(os.environ["GITHUB_OUTPUT"]) + + total = next( + (line for line in report_path.read_text(encoding="utf-8").splitlines() if line.startswith("TOTAL")), + None, + ) + if total is None: + print("llvm-cov report did not include a TOTAL row", file=sys.stderr) + sys.exit(1) + + fields = total.split() + if len(fields) < 10: + print(f"Unexpected llvm-cov TOTAL row: {total}", file=sys.stderr) + sys.exit(1) + + comment = f"""## Dioxus VDOM fuzz coverage + + Coverage of `{os.environ["FUZZ_COVERAGE_SOURCES"]}` from `cargo fuzz coverage` for the `{os.environ["FUZZ_TARGET"]}` smoke corpus run. + + | Metric | Coverage | + | --- | ---: | + | Regions | {fields[3]} | + | Functions | {fields[6]} | + | Lines | {fields[9]} | + """ + + comment_path.write_text(comment, encoding="utf-8") + + with output_path.open("a", encoding="utf-8") as output: + output.write("comment< Element { + let mut rng = SmallRng::from_os_rng(); + + rsx! ( + table { + tbody { + for f in 0..10_000_usize { + table_row { + row_id: f, + label: Label::new(&mut rng) + } + } + } + } + ) +} + +#[derive(PartialEq, Props, Clone, Copy)] +struct SyntheticRowProps { + row_id: usize, + label: Label, +} +fn table_row(props: SyntheticRowProps) -> Element { + let [adj, col, noun] = props.label.0; + + rsx! { + tr { + td { class:"col-md-1", "{props.row_id}" } + td { class:"col-md-1", onclick: move |_| { /* run onselect */ }, + a { class: "lbl", "{adj}" "{col}" "{noun}" } + } + td { class: "col-md-1", + a { class: "remove", onclick: move |_| {/* remove */}, + span { class: "glyphicon glyphicon-remove remove", aria_hidden: "true" } + } + } + td { class: "col-md-6" } + } + } +} + +#[derive(PartialEq, Clone, Copy)] +struct Label([&'static str; 3]); + +impl Label { + fn new(rng: &mut SmallRng) -> Self { + Label([ + ADJECTIVES.choose(rng).unwrap(), + COLOURS.choose(rng).unwrap(), + NOUNS.choose(rng).unwrap(), + ]) + } +} + +static ADJECTIVES: &[&str] = &[ + "pretty", + "large", + "big", + "small", + "tall", + "short", + "long", + "handsome", + "plain", + "quaint", + "clean", + "elegant", + "easy", + "angry", + "crazy", + "helpful", + "mushy", + "odd", + "unsightly", + "adorable", + "important", + "inexpensive", + "cheap", + "expensive", + "fancy", +]; + +static COLOURS: &[&str] = &[ + "red", "yellow", "blue", "green", "pink", "brown", "purple", "brown", "white", "black", + "orange", +]; + +static NOUNS: &[&str] = &[ + "table", "chair", "house", "bbq", "desk", "car", "pony", "cookie", "sandwich", "burger", + "pizza", "mouse", "keyboard", +]; + +fn js_framework_benchmark_core(c: &mut Criterion) { + let mut group = c.benchmark_group("js-framework-benchmark core"); + + group.bench_function("create 1,000 rows", |b| { + b.iter_batched( + JsFrameworkDom::new, + |mut app| black_box(app.run(1_000)), + BatchSize::SmallInput, + ) + }); + + group.bench_function("create 10,000 rows", |b| { + b.iter_batched( + JsFrameworkDom::new, + |mut app| black_box(app.run(10_000)), + BatchSize::SmallInput, + ) + }); + + group.bench_function("replace all rows", |b| { + b.iter_batched( + || JsFrameworkDom::with_rows(1_000), + |mut app| black_box(app.run(1_000)), + BatchSize::SmallInput, + ) + }); + + group.bench_function("append 1,000 rows", |b| { + b.iter_batched( + || JsFrameworkDom::with_rows(1_000), + |mut app| black_box(app.append(1_000)), + BatchSize::SmallInput, + ) + }); + + group.bench_function("update every 10th row", |b| { + b.iter_batched( + || JsFrameworkDom::with_rows(1_000), + |mut app| black_box(app.update_every_10th()), + BatchSize::SmallInput, + ) + }); + + group.bench_function("select row", |b| { + b.iter_batched( + || JsFrameworkDom::with_rows(1_000), + |mut app| black_box(app.select_at(1)), + BatchSize::SmallInput, + ) + }); + + group.bench_function("swap rows", |b| { + b.iter_batched( + || JsFrameworkDom::with_rows(1_000), + |mut app| black_box(app.swap_rows()), + BatchSize::SmallInput, + ) + }); + + group.bench_function("remove row", |b| { + b.iter_batched( + || JsFrameworkDom::with_rows(1_000), + |mut app| black_box(app.remove_at(3)), + BatchSize::SmallInput, + ) + }); + + group.bench_function("clear rows", |b| { + b.iter_batched( + || JsFrameworkDom::with_rows(1_000), + |mut app| black_box(app.clear()), + BatchSize::SmallInput, + ) + }); + + group.finish(); +} + +struct JsFrameworkDom { + dom: VirtualDom, + controls: Rc>>, + generator: RowGenerator, +} + +impl JsFrameworkDom { + fn new() -> Self { + let controls = Rc::new(RefCell::new(None)); + let generator = RowGenerator::new(); + let props = AppProps { + controls: controls.clone(), + generator: generator.clone(), + }; + let mut dom = VirtualDom::new_with_props(js_framework_app, props); + dom.rebuild(&mut NoOpMutations); + + Self { + dom, + controls, + generator, + } + } + + fn with_rows(count: usize) -> Self { + let mut app = Self::new(); + app.run(count); + app + } + + fn run(&mut self, count: usize) -> usize { + self.with_runtime(|controls, generator| controls.run(generator, count)); + self.render_and_count() + } + + fn append(&mut self, count: usize) -> usize { + self.with_runtime(|controls, generator| controls.append(generator, count)); + self.render_and_count() + } + + fn update_every_10th(&mut self) -> usize { + self.with_runtime(|controls, _| controls.update_every_10th()); + self.render_and_count() + } + + fn select_at(&mut self, index: usize) -> usize { + self.with_runtime(|controls, _| controls.select_at(index)); + self.render_and_count() + } + + fn swap_rows(&mut self) -> usize { + self.with_runtime(|controls, _| controls.swap_rows()); + self.render_and_count() + } + + fn remove_at(&mut self, index: usize) -> usize { + self.with_runtime(|controls, _| controls.remove_at(index)); + self.render_and_count() + } + + fn clear(&mut self) -> usize { + self.with_runtime(|controls, _| controls.clear()); + self.render_and_count() + } + + fn render_and_count(&mut self) -> usize { + self.dom.render_immediate(&mut NoOpMutations); + self.controls().row_count() + } + + fn controls(&self) -> Controls { + self.controls + .borrow() + .expect("js-framework-benchmark controls should be initialized after rebuild") + } + + fn with_runtime(&self, f: impl FnOnce(Controls, &RowGenerator) -> O) -> O { + let controls = self.controls(); + let generator = &self.generator; + self.dom + .runtime() + .in_scope(ScopeId::APP, || f(controls, generator)) + } +} + +#[derive(Clone)] +struct AppProps { + controls: Rc>>, + generator: RowGenerator, +} + +impl PartialEq for AppProps { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.controls, &other.controls) && self.generator.ptr_eq(&other.generator) + } +} + +#[derive(Clone)] +struct RowGenerator(Rc>); + +struct RowGeneratorState { + rng: SmallRng, + next_id: usize, +} + +impl RowGenerator { + fn new() -> Self { + Self(Rc::new(RefCell::new(RowGeneratorState { + rng: SmallRng::seed_from_u64(0), + next_id: 1, + }))) + } + + fn ptr_eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.0, &other.0) + } + + fn row(&self) -> RowData { + let mut state = self.0.borrow_mut(); + let adjective = select_random(ADJECTIVES, &mut state.rng); + let colour = select_random(COLOURS, &mut state.rng); + let noun = select_random(NOUNS, &mut state.rng); + let capacity = adjective.len() + colour.len() + noun.len() + 2; + let mut label = String::with_capacity(capacity); + label.push_str(adjective); + label.push(' '); + label.push_str(colour); + label.push(' '); + label.push_str(noun); + + let id = state.next_id; + state.next_id += 1; + + RowData { + id, + label: Signal::new(label), + } + } +} + +// A native copy of the keyed Dioxus js-framework-benchmark app: +// https://github.com/krausest/js-framework-benchmark/blob/master/frameworks/keyed/dioxus/src/main.rs +// The component and signal structure match the browser implementation, but +// Criterion drives the actions directly and Dioxus writes NoOpMutations so we +// only measure core. +fn js_framework_app(props: AppProps) -> Element { + let mut rows = use_signal(Vec::::new); + let selected_row: Signal> = use_signal(|| None); + #[allow(clippy::redundant_closure)] + let compare_selected = use_set_compare(move || selected_row()); + + *props.controls.borrow_mut() = Some(Controls { rows, selected_row }); + + rsx! { + div { class: "container", + div { class: "jumbotron", + div { class: "row", + div { class: "col-md-6", + h1 { "Dioxus" } + } + div { class: "col-md-6", + div { class: "row", + Button { + name: "Create 1,000 rows", + id: "run", + onclick: { + let generator = props.generator.clone(); + move |_| randomize_rows(rows, &generator, 1_000) + } + } + Button { + name: "Create 10,000 rows", + id: "runlots", + onclick: { + let generator = props.generator.clone(); + move |_| randomize_rows(rows, &generator, 10_000) + } + } + Button { + name: "Append 1,000 rows", + id: "add", + onclick: { + let generator = props.generator.clone(); + move |_| add_data(&mut rows.write(), &generator, 1_000) + } + } + Button { + name: "Update every 10th row", + id: "update", + onclick: move |_| update_every_10th(rows) + } + Button { + name: "Clear", + id: "clear", + onclick: move |_| rows.clear() + } + Button { + name: "Swap rows", + id: "swaprows", + onclick: move |_| { + if rows.len() > 998 { + rows.write().swap(1, 998); + } + } + } + } + } + } + } + + table { class: "table table-hover table-striped test-data", + tbody { id: "tbody", + for row in rows.iter() { + Row { + key: "{row.id}", + id: row.id, + label: row.label, + rows, + compare_selected, + selected_row + } + } + } + } + } + } +} + +#[component] +fn Row( + rows: Signal>, + id: usize, + label: Signal, + compare_selected: SetCompare>, + mut selected_row: Signal>, +) -> Element { + use_drop(move || { + label.manually_drop(); + }); + let selected = use_set_compare_equal(Some(id), compare_selected); + rsx! { + tr { class: if selected() { "danger" }, + td { class: "col-md-1", "{id}" } + td { + class: "col-md-4", + onclick: move |_| selected_row.set(Some(id)), + a { class: "lbl", {label} } + } + td { class: "col-md-1", + a { + class: "remove", + onclick: move |_| rows.write().retain(|other_row| other_row.id != id), + span { + class: "glyphicon glyphicon-remove remove", + aria_hidden: "true" + } + } + } + td { class: "col-md-6" } + } + } +} + +#[component] +fn Button(name: String, id: String, onclick: EventHandler) -> Element { + rsx! { + div { class: "col-sm-6 smallpad", + button { + class: "btn btn-primary btn-block", + r#type: "button", + id, + onclick: move |_| onclick(()), + "{name}" + } + } + } +} + +#[derive(PartialEq, Clone, Copy)] +struct RowData { + id: usize, + label: Signal, +} + +#[derive(Clone, Copy)] +struct Controls { + rows: Signal>, + selected_row: Signal>, +} + +impl Controls { + fn run(self, generator: &RowGenerator, count: usize) { + randomize_rows(self.rows, generator, count); + } + + fn append(mut self, generator: &RowGenerator, count: usize) { + add_data(&mut self.rows.write(), generator, count); + } + + fn update_every_10th(self) { + update_every_10th(self.rows); + } + + fn select_at(mut self, index: usize) { + if let Some(row) = self.rows.get(index) { + self.selected_row.set(Some(row.id)); + } + } + + fn swap_rows(mut self) { + if self.rows.len() > 998 { + self.rows.write().swap(1, 998); + } + } + + fn remove_at(mut self, index: usize) { + let id = self.rows.get(index).map(|row| row.id); + if let Some(id) = id { + self.rows.write().retain(|other_row| other_row.id != id); + } + } + + fn clear(mut self) { + self.rows.clear(); + } + + fn row_count(self) -> usize { + self.rows.len() + } +} + +fn randomize_rows(mut rows: Signal>, generator: &RowGenerator, count: usize) { + let mut write = rows.write(); + write.clear(); + add_data(&mut write, generator, count); +} + +fn add_data(rows: &mut Vec, generator: &RowGenerator, count: usize) { + rows.reserve_exact(count); + + for _ in 0..count { + rows.push(generator.row()); + } +} + +fn update_every_10th(rows: Signal>) { + for row in rows.iter().step_by(10) { + *row.label.write_unchecked() += " !!!"; + } +} + +fn select_random<'a>(data: &'a [&'a str], rng: &mut SmallRng) -> &'a str { + data.choose(rng).unwrap() +} diff --git a/packages/core/src/arena.rs b/packages/core/src/arena.rs index 57ac4f915c..1a1e645e89 100644 --- a/packages/core/src/arena.rs +++ b/packages/core/src/arena.rs @@ -1,4 +1,4 @@ -use crate::innerlude::ScopeOrder; +use crate::innerlude::{NoOpMutations, ScopeOrder}; use crate::{ScopeId, virtual_dom::VirtualDom}; /// An Element's unique identifier. @@ -9,6 +9,13 @@ use crate::{ScopeId, virtual_dom::VirtualDom}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] pub struct ElementId(pub usize); +pub(crate) const UNMOUNTED: usize = usize::MAX; + +impl ElementId { + pub(crate) const ROOT: Self = Self(0); + pub(crate) const UNMOUNTED: Self = Self(UNMOUNTED); +} + /// An Element that can be bubbled to's unique identifier. /// /// `BubbleId` is a `usize` that is unique across the entire VirtualDOM - but not unique across time. If a component is @@ -24,7 +31,7 @@ impl Default for MountId { } impl MountId { - pub(crate) const PLACEHOLDER: Self = Self(usize::MAX); + pub(crate) const PLACEHOLDER: Self = Self(UNMOUNTED); pub(crate) fn as_usize(self) -> Option { if self.mounted() { Some(self.0) } else { None } @@ -64,7 +71,7 @@ impl VirtualDom { pub(crate) fn try_reclaim(&mut self, el: ElementId) -> bool { // We never reclaim the unmounted elements or the root element - if el.0 == 0 || el.0 == usize::MAX { + if el == ElementId::ROOT || el == ElementId::UNMOUNTED { return true; } @@ -72,9 +79,9 @@ impl VirtualDom { elements.try_remove(el.0).is_some() } - // Drop a scope without dropping its children + // Drop a scope without dropping its children. // - // Note: This will not remove any ids from the arena + // Note: This will not remove any ids from the arena. pub(crate) fn drop_scope(&mut self, id: ScopeId) { let height = { let scope = self.scopes.remove(id.0); @@ -87,6 +94,29 @@ impl VirtualDom { // If this scope was a suspense boundary, remove it from the resolved scopes self.resolved_scopes.retain(|s| s != &id); } + + pub(crate) fn remove_scope_rendered_output_without_mutations( + &mut self, + id: ScopeId, + ) -> Option> { + let old = self.scopes[id.0].last_rendered_node.take()?; + let parent = old + .mount + .get() + .as_usize() + .and_then(|mount| { + self.runtime + .mounts + .borrow() + .get(mount) + .map(|mount| mount.parent) + }) + .flatten(); + + old.remove_node_inner(self, None::<&mut NoOpMutations>, true, None); + + Some(parent) + } } impl ElementPath { diff --git a/packages/core/src/diff/attributes.rs b/packages/core/src/diff/attributes.rs new file mode 100644 index 0000000000..0546b94356 --- /dev/null +++ b/packages/core/src/diff/attributes.rs @@ -0,0 +1,436 @@ +//! Diffing for dynamic attributes. +//! +//! Templates keep static attributes in `TemplateNode::Element` and store runtime attributes in +//! `VNode::dynamic_attrs`. Each entry in `template.attr_paths()` points at the element that owns +//! the corresponding dynamic attribute slot. Several adjacent slots may point at the same element +//! when RSX mixes named dynamic attributes and spreads. +//! +//! Creating a template can write those slots in order because later writes naturally overwrite +//! earlier writes on the real element. Diffing needs a little more context: removing a later spread +//! can reveal an earlier dynamic attribute with the same key, or the static template attribute that +//! was loaded with the template. To preserve those "last write wins" semantics, the diff: +//! +//! 1. groups all adjacent dynamic attribute slots for the same element path; +//! 2. flattens the old and new slots for that element; +//! 3. reduces each side to the effective attribute for each `(name, namespace)` key, keeping the +//! last matching attribute; and +//! 4. merges the old and new effective attribute lists to emit additions, updates, removals, and +//! static-template fallbacks. + +use core::{cmp::Ordering, iter::Peekable, ops::Range}; + +use crate::innerlude::MountId; +use crate::{ + Attribute, AttributeValue, TemplateAttribute, TemplateNode, VNode, VirtualDom, WriteMutations, + arena::ElementId, + innerlude::{ElementPath, ElementRef}, +}; + +/// Attribute identity as seen by renderers. Value changes do not affect the key, but namespace +/// changes do. +type AttributeKey = (&'static str, Option<&'static str>); + +/// Reusable scratch for the two k-way merges in `diff_attribute_list`. Allocated once per +/// `diff_attributes` call and cleared on every merge. +#[derive(Default)] +struct AttributeDiffScratch<'a> { + old_ranges: Vec<&'a [Attribute]>, + old_offsets: Vec, + new_ranges: Vec<&'a [Attribute]>, + new_offsets: Vec, +} + +impl VNode { + pub(super) fn diff_attributes( + &self, + new: &VNode, + dom: &mut VirtualDom, + to: &mut impl WriteMutations, + ) { + let mount_id = new.mount.get(); + let attr_paths = self.template.attr_paths(); + + let mut idx = 0; + let mut scratch = AttributeDiffScratch::default(); + + while idx < attr_paths.len() { + let path = attr_paths[idx]; + // Multiple dynamic attribute slots can target the same element. Diff them as a single + // group so duplicate keys obey the same overwrite order they used during creation. + let attr_group = self.dynamic_attribute_group_starting_at(idx); + // Every slot in the group is mounted to the same real element, so the first slot's id + // is enough for all mutations generated by this group. + let attribute_id = dom.get_mounted_dyn_attr(mount_id, idx); + self.diff_attribute_list( + new, + path, + attribute_id, + mount_id, + attr_group.clone(), + &mut scratch, + dom, + to, + ); + + idx = attr_group.end; + } + } + + /// Diff all dynamic attributes that can affect one mounted element. + /// + /// `from` and `to_attrs` are the flattened dynamic slots for the same template path. They may + /// contain duplicate keys from multiple spreads or from a spread overriding a named attribute. + /// Before we compare sides, each side is reduced to its effective, last-written attribute per + /// key. + fn diff_attribute_list<'a>( + &'a self, + new: &'a VNode, + path: &'static [u8], + id: ElementId, + mount: MountId, + attr_group: Range, + scratch: &mut AttributeDiffScratch<'a>, + dom: &mut VirtualDom, + to: &mut impl WriteMutations, + ) { + let AttributeDiffScratch { + old_ranges, + old_offsets, + new_ranges, + new_offsets, + } = scratch; + let sort_by = Self::compare_attribute_keys; + let mut from_iter = iter_sorted_last_wins( + self.dynamic_attrs[attr_group.clone()] + .iter() + .map(|attributes| attributes.as_ref()), + old_ranges, + old_offsets, + sort_by, + ) + .peekable(); + let mut to_iter = iter_sorted_last_wins( + new.dynamic_attrs[attr_group] + .iter() + .map(|attributes| attributes.as_ref()), + new_ranges, + new_offsets, + sort_by, + ) + .peekable(); + + while let Some((key, old, new)) = Self::next_attribute_diff(&mut from_iter, &mut to_iter) { + self.diff_dynamic_attribute(path, key, id, mount, old, new, dom, to); + } + } + + /// Merge two sorted streams of effective attributes. + /// + /// Each returned item contains the key plus the old and/or new attribute for that key. This is + /// the same shape as a map diff, but it avoids building maps because the inputs are already + /// emitted in sorted order. + fn next_attribute_diff<'a>( + from_iter: &mut Peekable>, + to_iter: &mut Peekable>, + ) -> Option<(AttributeKey, Option<&'a Attribute>, Option<&'a Attribute>)> { + match (from_iter.peek().copied(), to_iter.peek().copied()) { + (Some(from), Some(to_attr)) => match Self::compare_attribute_keys(from, to_attr) { + Ordering::Less => { + from_iter.next(); + Some((Self::attribute_key(from), Some(from), None)) + } + Ordering::Greater => { + to_iter.next(); + Some((Self::attribute_key(to_attr), None, Some(to_attr))) + } + Ordering::Equal => { + from_iter.next(); + to_iter.next(); + Some((Self::attribute_key(to_attr), Some(from), Some(to_attr))) + } + }, + (Some(from), None) => { + from_iter.next(); + Some((Self::attribute_key(from), Some(from), None)) + } + (None, Some(to_attr)) => { + to_iter.next(); + Some((Self::attribute_key(to_attr), None, Some(to_attr))) + } + (None, None) => None, + } + } + + fn diff_dynamic_attribute( + &self, + path: &'static [u8], + key: AttributeKey, + id: ElementId, + mount: MountId, + old: Option<&Attribute>, + new: Option<&Attribute>, + dom: &mut VirtualDom, + to: &mut impl WriteMutations, + ) { + let old_listener = matches!(old.map(|a| &a.value), Some(AttributeValue::Listener(_))); + let new_listener = matches!(new.map(|a| &a.value), Some(AttributeValue::Listener(_))); + + // Listener-to-listener: events dispatch by path and the handler in the vdom is already current. + if old_listener && new_listener { + return; + } + + let value_changed = old.map(|a| &a.value) != new.map(|a| &a.value); + let volatile = old.is_some_and(|a| a.volatile) || new.is_some_and(|a| a.volatile); + // If the value didn't change and neither side is volatile, then there's no need to update the attribute. + if !value_changed && !volatile { + return; + } + + // Clear the old slot when the upcoming write won't naturally overwrite it: listeners + // are torn down explicitly, and installing a listener doesn't clear a prior attribute. + match (old_listener, new_listener, old) { + // This used to be a listener but no longer is, so remove the old listener. + (true, _, Some(old)) => to.remove_event_listener(&old.name[2..], id), + // This used to be a value but is now a listener, so clear the old value that won't be overwritten by the new listener. + (false, true, Some(_)) => to.set_attribute(key.0, key.1, &AttributeValue::None, id), + _ => {} + } + + // Write the new value, or restore the static template attribute, or clear the DOM + // attribute. A removed listener has nothing attribute-shaped left to clear. + if let Some(new) = new { + self.write_attribute(path, new, id, mount, dom, to); + } else if !old_listener { + self.remove_attribute_or_write_fallback(path, key, id, to) + } + } + + /// Get the identity key for an attribute + fn attribute_key(attribute: &Attribute) -> AttributeKey { + (attribute.name, attribute.namespace) + } + + /// Compare two attributes by their key for sorting and merging purposes. + fn compare_attribute_keys(left: &Attribute, right: &Attribute) -> Ordering { + Self::attribute_key(left).cmp(&Self::attribute_key(right)) + } + + /// Return the contiguous run of dynamic attribute slots mounted to the same template path. + /// + /// Attribute paths are emitted in template order, so all slots for a single element are + /// adjacent. Grouping them here is what lets the diff handle duplicate keys across spreads. + fn dynamic_attribute_group_starting_at(&self, start: usize) -> Range { + let attr_paths = self.template.attr_paths(); + let path = attr_paths[start]; + let mut end = start + 1; + + while end < attr_paths.len() && attr_paths[end] == path { + end += 1; + } + + start..end + } + + /// Restore the static template attribute that was shadowed by a dynamic attribute or clear the attribute. + /// + /// This is needed when an attribute from a spread disappears. The template load already wrote + /// the static value during creation, but the dynamic attribute may have overwritten or removed + /// it on a previous render. + fn remove_attribute_or_write_fallback( + &self, + path: &'static [u8], + key: AttributeKey, + id: ElementId, + to: &mut impl WriteMutations, + ) { + if let Some(value) = self.static_template_attribute_value(path, key) { + let value = AttributeValue::Text(value.to_string()); + to.set_attribute(key.0, key.1, &value, id); + } else { + to.set_attribute(key.0, key.1, &AttributeValue::None, id); + } + } + + /// Find the static template attribute value for a given key, if it exists. + fn static_template_attribute_value( + &self, + path: &'static [u8], + key: AttributeKey, + ) -> Option<&'static str> { + let attrs = self.template_node_at_path(path).element_attrs(); + // Static attributes are stored first and sorted by name. Search only that prefix, then + // filter by namespace because the ordering guarantee is by name. + let start = attrs.partition_point(|attr| match attr { + TemplateAttribute::Static { name, .. } => *name < key.0, + TemplateAttribute::Dynamic { .. } => false, + }); + + attrs[start..] + .iter() + .take_while( + |attr| matches!(attr, TemplateAttribute::Static { name, .. } if *name == key.0), + ) + .filter_map(|attr| match attr { + TemplateAttribute::Static { + value, namespace, .. + } if *namespace == key.1 => Some(*value), + _ => None, + }) + .last() + } + + /// Resolve the template element that owns a dynamic attribute path. + fn template_node_at_path(&self, path: &'static [u8]) -> &'static TemplateNode { + let (root_idx, child_path) = path + .split_first() + .expect("template attribute paths should not be empty"); + let mut node = &self.template.roots()[*root_idx as usize]; + + for child_idx in child_path { + node = node.element_child(*child_idx as usize); + } + + node + } + + /// Write one dynamic attribute to an already mounted element. + /// + /// Listener attributes also need an `ElementRef` in the runtime so event dispatch can find + /// the VNode that owns the handler. + pub(super) fn write_attribute( + &self, + path: &'static [u8], + attribute: &Attribute, + id: ElementId, + mount: MountId, + dom: &mut VirtualDom, + to: &mut impl WriteMutations, + ) { + match &attribute.value { + AttributeValue::Listener(_) => { + let element_ref = ElementRef { + path: ElementPath { path }, + mount, + }; + let mut elements = dom.runtime.elements.borrow_mut(); + elements[id.0] = Some(element_ref); + to.create_event_listener(&attribute.name[2..], id); + } + _ => { + to.set_attribute(attribute.name, attribute.namespace, &attribute.value, id); + } + } + } +} + +/// K-way merge over attribute slots that are each individually sorted by their key. +/// +/// Every dynamic attribute slot is required to be sorted by `(name, namespace)`: +/// - named attributes occupy a slot of length 1 (trivially sorted), and +/// - spread attributes are user-provided lists whose sortedness is checked in `VNode::new` under +/// `debug_assertions`. +/// +/// Duplicate keys across or within slots collapse to the last occurrence in iteration order, +/// which matches the "later write wins" semantics of RSX source order. +fn iter_sorted_last_wins<'items, 'scratch, T, F>( + slots: impl IntoIterator, + ranges: &'scratch mut Vec<&'items [T]>, + offsets: &'scratch mut Vec, + sort_by: F, +) -> SortedRangeIter<'items, 'scratch, T, F> +where + F: Fn(&T, &T) -> Ordering + Copy, +{ + ranges.clear(); + ranges.extend(slots); + offsets.clear(); + offsets.resize(ranges.len(), 0); + SortedRangeIter { + ranges, + offsets, + sort_by, + } +} + +struct SortedRangeIter<'items, 'scratch, T, F> { + ranges: &'scratch Vec<&'items [T]>, + offsets: &'scratch mut Vec, + sort_by: F, +} + +impl<'items, T, F> Iterator for SortedRangeIter<'items, '_, T, F> +where + F: Fn(&T, &T) -> Ordering + Copy, +{ + type Item = &'items T; + + fn next(&mut self) -> Option { + let mut min_value = None; + + // Find the smallest key currently visible across every range. + for (range, offset) in self.ranges.iter().zip(self.offsets.iter()) { + if let Some(item) = range.get(*offset) { + match min_value.map(|min_value| (self.sort_by)(item, min_value)) { + None | Some(Ordering::Less) => min_value = Some(item), + Some(Ordering::Equal | Ordering::Greater) => {} + } + } + } + + let min_value = min_value?; + let mut last = None; + + // Drain that key from every matching range. Later ranges come later in RSX source order, + // so the final item we see is the effective last-write-wins value. + for (range_idx, range) in self.ranges.iter().enumerate() { + while let Some(item) = range.get(self.offsets[range_idx]) { + if !matches!((self.sort_by)(item, min_value), Ordering::Equal) { + break; + } + last = Some(item); + self.offsets[range_idx] += 1; + } + } + + last + } +} + +#[test] +fn test_iter_sorted_last_wins() { + #[derive(Debug, PartialEq)] + struct Item { + value: i32, + id: usize, + } + impl Item { + fn cmp(&self, other: &Self) -> Ordering { + self.value.cmp(&other.value) + } + } + // Two sorted slots that share keys. The slot listed second wins on duplicates. + let slot_a = [ + Item { value: 1, id: 0 }, + Item { value: 2, id: 1 }, + Item { value: 3, id: 2 }, + ]; + let slot_b = [ + Item { value: 1, id: 5 }, + Item { value: 2, id: 3 }, + Item { value: 4, id: 4 }, + ]; + let mut ranges = Vec::new(); + let mut offsets = Vec::new(); + let mut iter = iter_sorted_last_wins( + [slot_a.as_slice(), slot_b.as_slice()], + &mut ranges, + &mut offsets, + Item::cmp, + ); + assert_eq!(*iter.next().unwrap(), Item { value: 1, id: 5 }); + assert_eq!(*iter.next().unwrap(), Item { value: 2, id: 3 }); + assert_eq!(*iter.next().unwrap(), Item { value: 3, id: 2 }); + assert_eq!(*iter.next().unwrap(), Item { value: 4, id: 4 }); + assert!(iter.next().is_none()); +} diff --git a/packages/core/src/diff/component.rs b/packages/core/src/diff/component.rs index 4359f91ef6..ef5a596933 100644 --- a/packages/core/src/diff/component.rs +++ b/packages/core/src/diff/component.rs @@ -7,8 +7,8 @@ use crate::{ Element, SuspenseContext, any_props::AnyProps, innerlude::{ - ElementRef, MountId, ScopeOrder, SuspenseBoundaryProps, SuspenseBoundaryPropsWithOwner, - VComponent, WriteMutations, + ElementRef, MountId, NoOpMutations, ScopeOrder, SuspenseBoundaryProps, + SuspenseBoundaryPropsWithOwner, VComponent, WriteMutations, }, nodes::VNode, scopes::{LastRenderedNode, ScopeId}, @@ -30,6 +30,14 @@ impl VirtualDom { } } + pub(crate) fn scope_render_target<'a, M: WriteMutations>( + &self, + scope: ScopeId, + to: Option<&'a mut M>, + ) -> Option<&'a mut M> { + to.filter(|_| self.runtime.scope_should_render(scope)) + } + #[tracing::instrument(skip(self, to), level = "trace", name = "VirtualDom::diff_scope")] fn diff_scope( &mut self, @@ -49,7 +57,7 @@ impl VirtualDom { // If there are suspended scopes, we need to check if the scope is suspended before we diff it // If it is suspended, we need to diff it but write the mutations nothing // Note: It is important that we still diff the scope even if it is suspended, because the scope may render other child components which may change between renders - let mut render_to = to.filter(|_| self.runtime.scope_should_render(scope)); + let mut render_to = self.scope_render_target(scope, to); old.diff_node(new_real_nodes, self, render_to.as_deref_mut()); self.scopes[scope.0].last_rendered_node = Some(LastRenderedNode::new(new_nodes)); @@ -75,7 +83,7 @@ impl VirtualDom { // If there are suspended scopes, we need to check if the scope is suspended before we diff it // If it is suspended, we need to diff it but write the mutations nothing // Note: It is important that we still diff the scope even if it is suspended, because the scope may render other child components which may change between renders - let mut render_to = to.filter(|_| self.runtime.scope_should_render(scope)); + let mut render_to = self.scope_render_target(scope, to); // Create the node let nodes = new_nodes.create(self, parent, render_to.as_deref_mut()); @@ -98,19 +106,35 @@ impl VirtualDom { scope_id: ScopeId, replace_with: Option, ) { - // If this is a suspense boundary, remove the suspended nodes as well - SuspenseContext::remove_suspended_nodes::(self, scope_id, destroy_component_state); + // If this is a suspense boundary being destroyed, remove its retained + // suspended nodes as well. When moving rendered children into a parent + // suspense background, keep nested suspended nodes attached to their + // boundary so a later real unmount can still destroy their scopes. + if destroy_component_state { + SuspenseContext::remove_suspended_nodes::(self, scope_id, true); + } // Remove the component from the dom - if let Some(node) = self.scopes[scope_id.0].last_rendered_node.clone() { - node.remove_node_inner(self, to, destroy_component_state, replace_with) - }; + let node = self.scopes[scope_id.0] + .last_rendered_node + .clone() + .expect("component scope should have a rendered node before removal"); + node.remove_node_inner(self, to, destroy_component_state, replace_with); if destroy_component_state { // Now drop all the resources self.drop_scope(scope_id); } } + + pub(crate) fn clear_scope_rendered_output(&mut self, scope_id: ScopeId) { + let parent = self + .remove_scope_rendered_output_without_mutations(scope_id) + .expect("suspended scope should have rendered output to clear"); + let placeholder = LastRenderedNode::Real(VNode::placeholder()); + placeholder.create(self, parent, None::<&mut NoOpMutations>); + self.scopes[scope_id.0].last_rendered_node = Some(placeholder); + } } impl VNode { @@ -121,12 +145,12 @@ impl VNode { new: &VComponent, old: &VComponent, scope_id: ScopeId, - parent: Option, dom: &mut VirtualDom, to: Option<&mut impl WriteMutations>, ) { // Replace components that have different render fns if old.render_fn != new.render_fn { + let parent = Some(self.reference_to_dynamic_node(mount, idx)); return self.replace_vcomponent(mount, idx, new, parent, dom, to); } diff --git a/packages/core/src/diff/iterator.rs b/packages/core/src/diff/iterator.rs index 194ecec55e..41b41f1633 100644 --- a/packages/core/src/diff/iterator.rs +++ b/packages/core/src/diff/iterator.rs @@ -92,7 +92,8 @@ impl VirtualDom { new: &[VNode], parent: Option, ) { - if cfg!(debug_assertions) { + #[cfg(debug_assertions)] + { let mut keys = rustc_hash::FxHashSet::default(); let mut assert_unique_keys = |children: &[VNode]| { keys.clear(); @@ -141,12 +142,7 @@ impl VirtualDom { "New middle returned from `diff_keyed_ends` should not be empty" ); - // A few nodes in the middle were removed, just remove the old nodes - if new_middle.is_empty() { - self.remove_nodes(to, old_middle, None); - } else { - self.diff_keyed_middle(to, old_middle, new_middle, parent); - } + self.diff_keyed_middle(to, old_middle, new_middle, parent); } /// Diff both ends of the children that share keys. @@ -361,11 +357,8 @@ impl VirtualDom { // If the node existed in the old list, diff it if let Some(old_node) = old.get(old_index) { old_node.diff_node(new_node, vdom, to.as_deref_mut()); - if let Some(to) = to.as_deref_mut() { - new_node.push_all_root_nodes(vdom, to) - } else { - 0 - } + to.as_deref_mut() + .map_or(0, |to| new_node.push_all_root_nodes(vdom, to)) } else { // Otherwise, just add it to the stack new_node.create(vdom, parent, to.as_deref_mut()) @@ -442,10 +435,12 @@ impl VirtualDom { fn insert_before(&mut self, to: Option<&mut impl WriteMutations>, new: usize, before: &VNode) { if let Some(to) = to { - if new > 0 { - let id = before.find_first_element(self); - to.insert_nodes_before(id, new); - } + debug_assert!( + new > 0, + "we currently always insert at least one placeholder node. if we did not, this would result in insert before failing" + ); + let id = before.find_first_element(self); + to.insert_nodes_before(id, new); } } diff --git a/packages/core/src/diff/mod.rs b/packages/core/src/diff/mod.rs index 7a7a89ee7b..7baa2e6848 100644 --- a/packages/core/src/diff/mod.rs +++ b/packages/core/src/diff/mod.rs @@ -10,13 +10,14 @@ #![allow(clippy::too_many_arguments)] use crate::{ - ElementId, TemplateNode, + ElementId, arena::MountId, innerlude::{ElementRef, WriteMutations}, nodes::VNode, virtual_dom::VirtualDom, }; +mod attributes; mod component; mod iterator; mod node; @@ -88,31 +89,3 @@ impl VirtualDom { } } } - -/// We can apply various optimizations to dynamic nodes that are the single child of their parent. -/// -/// IE -/// - for text - we can use SetTextContent -/// - for clearing children we can use RemoveChildren -/// - for appending children we can use AppendChildren -#[allow(dead_code)] -fn is_dyn_node_only_child(node: &VNode, idx: usize) -> bool { - let template = node.template; - let path = template.node_paths()[idx]; - - // use a loop to index every static node's children until the path has run out - // only break if the last path index is a dynamic node - let mut static_node = &template.roots()[path[0] as usize]; - - for i in 1..path.len() - 1 { - match static_node { - TemplateNode::Element { children, .. } => static_node = &children[path[i] as usize], - _ => return false, - } - } - - match static_node { - TemplateNode::Element { children, .. } => children.len() == 1, - _ => false, - } -} diff --git a/packages/core/src/diff/node.rs b/packages/core/src/diff/node.rs index c39a2bebf8..03ce6e4b90 100644 --- a/packages/core/src/diff/node.rs +++ b/packages/core/src/diff/node.rs @@ -1,16 +1,26 @@ +use crate::DynamicNode::*; use crate::innerlude::MountId; -use crate::{Attribute, AttributeValue, DynamicNode::*}; use crate::{VNode, VirtualDom, WriteMutations}; use core::iter::Peekable; use crate::{ TemplateNode, - arena::ElementId, + arena::{ElementId, UNMOUNTED}, innerlude::{ElementPath, ElementRef, VNodeMount, VText}, nodes::DynamicNode, scopes::ScopeId, }; +fn mounted_mount(node: &VNode, dom: &VirtualDom) -> MountId { + let mount = node.mount.get(); + let mount = mount + .as_usize() + .map(MountId) + .expect("node should already be mounted"); + debug_assert!(dom.runtime.mounts.borrow().contains(mount.0)); + mount +} + impl VNode { pub(crate) fn diff_node( &self, @@ -18,19 +28,14 @@ impl VNode { dom: &mut VirtualDom, mut to: Option<&mut impl WriteMutations>, ) { + let mount_id = self.mount.get(); + // The node we are diffing from should always be mounted - debug_assert!( - dom.runtime - .mounts - .borrow() - .get(self.mount.get().0) - .is_some() - || to.is_none() - ); + debug_assert!(mount_id.mounted()); + debug_assert!(dom.runtime.mounts.borrow().get(mount_id.0).is_some()); // If the templates are different, we need to replace the entire template if self.template != new.template { - let mount_id = self.mount.get(); let parent = dom.get_mounted_parent(mount_id); return self.replace(std::slice::from_ref(new), parent, dom, to); } @@ -46,7 +51,9 @@ impl VNode { // Start with the attributes // Since the attributes are only side effects, we can skip diffing them entirely if the node is suspended and we aren't outputting mutations if let Some(to) = to.as_deref_mut() { - self.diff_attributes(new, dom, to); + if !self.template.attr_paths().is_empty() { + self.diff_attributes(new, dom, to); + } } // Now diff the dynamic nodes @@ -66,13 +73,12 @@ impl VNode { let mount_id = self.mount.take(); new.mount.set(mount_id); - if mount_id.mounted() { - let mut mounts = dom.runtime.mounts.borrow_mut(); - let mount = &mut mounts[mount_id.0]; + debug_assert!(mount_id.mounted()); + let mut mounts = dom.runtime.mounts.borrow_mut(); + let mount = &mut mounts[mount_id.0]; - // Update the reference to the node for bubbling events - mount.node = new.clone(); - } + // Update the reference to the node for bubbling events + mount.node = new.clone(); } fn diff_dynamic_node( @@ -102,16 +108,7 @@ impl VNode { ), (Component(old), Component(new)) => { let scope_id = ScopeId(dom.get_mounted_dyn_node(mount, idx)); - self.diff_vcomponent( - mount, - idx, - new, - old, - scope_id, - Some(self.reference_to_dynamic_node(mount, idx)), - dom, - to, - ) + self.diff_vcomponent(mount, idx, new, old, scope_id, dom, to) } (old, new) => { // TODO: we should pass around the mount instead of the mount id @@ -246,13 +243,15 @@ impl VNode { right: &[VNode], parent: Option, dom: &mut VirtualDom, - mut to: Option<&mut impl WriteMutations>, + to: Option<&mut impl WriteMutations>, destroy_component_state: bool, ) { + let mut to = to; let m = dom.create_children(to.as_deref_mut(), right, parent); + let replace_with = to.is_some().then_some(m); // Instead of *just* removing it, we can use the replace mutation - self.remove_node_inner(dom, to, destroy_component_state, Some(m)) + self.remove_node_inner(dom, to, destroy_component_state, replace_with) } /// Remove a node from the dom and potentially replace it with the top m nodes from the stack @@ -273,10 +272,7 @@ impl VNode { destroy_component_state: bool, replace_with: Option, ) { - let mount = self.mount.get(); - if !mount.mounted() { - return; - } + let mount = mounted_mount(self, dom); // Clean up any attributes that have claimed a static node as dynamic for mount/unmounts // Will not generate mutations! @@ -320,17 +316,19 @@ impl VNode { dynamic_node, replace_with.filter(|_| last_node), ); - } else if let Some(to) = to.as_deref_mut() { - let id = dom.get_mounted_root_node(mount, idx); - if let (true, Some(replace_with)) = (last_node, replace_with) { - to.replace_node_with(id, replace_with); - } else { - to.remove_node(id); - } - dom.reclaim(id); } else { let id = dom.get_mounted_root_node(mount, idx); + if let Some(to) = to.as_deref_mut() { + if let (true, Some(replace_with)) = (last_node, replace_with) { + to.replace_node_with(id, replace_with); + } else { + to.remove_node(id); + } + } dom.reclaim(id); + // Stamp the slot so a later traversal cannot mistake the + // reclaimed id for a live element. + dom.set_mounted_root_node(mount, idx, ElementId::UNMOUNTED); } } } @@ -375,27 +373,48 @@ impl VNode { dom.remove_component_node(to, destroy_component_state, scope_id, replace_with); } Text(_) | Placeholder(_) => { - let id = ElementId(dom.get_mounted_dyn_node(mount, idx)); - if let Some(to) = to { - if let Some(replace_with) = replace_with { - to.replace_node_with(id, replace_with); - } else { - to.remove_node(id); - } - } - dom.reclaim(id) + Self::remove_anchor(dom, to, mount, idx, replace_with); } Fragment(nodes) => { for node in &nodes[..nodes.len() - 1] { node.remove_node_inner(dom, to.as_deref_mut(), destroy_component_state, None) } - if let Some(last_node) = nodes.last() { - last_node.remove_node_inner(dom, to, destroy_component_state, replace_with) - } + let last_node = nodes + .last() + .expect("fragment dynamic nodes should be normalized to non-empty fragments"); + last_node.remove_node_inner(dom, to, destroy_component_state, replace_with) } }; } + fn remove_anchor( + dom: &mut VirtualDom, + to: Option<&mut impl WriteMutations>, + mount: MountId, + idx: usize, + replace_with: Option, + ) { + let id = ElementId(dom.get_mounted_dyn_node(mount, idx)); + let removing_live_anchor = to.is_some() && replace_with.is_none(); + if id != ElementId::UNMOUNTED { + if let Some(to) = to { + if let Some(replace_with) = replace_with { + to.replace_node_with(id, replace_with); + } else { + to.remove_node(id); + } + } + } + debug_assert!( + id != ElementId::UNMOUNTED || !removing_live_anchor, + "attempted to remove an unmounted dynamic anchor from the live DOM" + ); + dom.reclaim(id); + // Stamp the slot so a later traversal cannot mistake the reclaimed id + // for a live anchor. + dom.set_mounted_dyn_node(mount, idx, UNMOUNTED); + } + pub(super) fn reclaim_attributes(&self, mount: MountId, dom: &mut VirtualDom) { let mut next_id = None; for (idx, path) in self.template.attr_paths().iter().enumerate() { @@ -410,131 +429,7 @@ impl VNode { dom.reclaim(new_id); next_id = Some(new_id); } - } - } - - pub(super) fn diff_attributes( - &self, - new: &VNode, - dom: &mut VirtualDom, - to: &mut impl WriteMutations, - ) { - let mount_id = new.mount.get(); - for (idx, (old_attrs, new_attrs)) in self - .dynamic_attrs - .iter() - .zip(new.dynamic_attrs.iter()) - .enumerate() - { - let mut old_attributes_iter = old_attrs.iter().peekable(); - let mut new_attributes_iter = new_attrs.iter().peekable(); - let attribute_id = dom.get_mounted_dyn_attr(mount_id, idx); - let path = self.template.attr_paths()[idx]; - - loop { - match (old_attributes_iter.peek(), new_attributes_iter.peek()) { - (Some(old_attribute), Some(new_attribute)) => { - // check which name is greater - match old_attribute.name.cmp(new_attribute.name) { - // The two attributes are the same, so diff them - std::cmp::Ordering::Equal => { - let old = old_attributes_iter.next().unwrap(); - let new = new_attributes_iter.next().unwrap(); - // Volatile attributes are attributes that the browser may override so we always update them - let volatile = old.volatile; - // We only need to write the attribute if the attribute is volatile or the value has changed - // and this is not an event listener. - // Interpreters reference event listeners by name and element id, so we don't need to write them - // even if the closure has changed. - let attribute_changed = match (&old.value, &new.value) { - (AttributeValue::Text(l), AttributeValue::Text(r)) => l != r, - (AttributeValue::Float(l), AttributeValue::Float(r)) => l != r, - (AttributeValue::Int(l), AttributeValue::Int(r)) => l != r, - (AttributeValue::Bool(l), AttributeValue::Bool(r)) => l != r, - (AttributeValue::Any(l), AttributeValue::Any(r)) => { - !l.as_ref().any_cmp(r.as_ref()) - } - (AttributeValue::None, AttributeValue::None) => false, - (AttributeValue::Listener(_), AttributeValue::Listener(_)) => { - false - } - _ => true, - }; - if volatile || attribute_changed { - self.write_attribute( - path, - new, - attribute_id, - mount_id, - dom, - to, - ); - } - } - // In a sorted list, if the old attribute name is first, then the new attribute is missing - std::cmp::Ordering::Less => { - let old = old_attributes_iter.next().unwrap(); - self.remove_attribute(old, attribute_id, to) - } - // In a sorted list, if the new attribute name is first, then the old attribute is missing - std::cmp::Ordering::Greater => { - let new = new_attributes_iter.next().unwrap(); - self.write_attribute(path, new, attribute_id, mount_id, dom, to); - } - } - } - (Some(_), None) => { - let left = old_attributes_iter.next().unwrap(); - self.remove_attribute(left, attribute_id, to) - } - (None, Some(_)) => { - let right = new_attributes_iter.next().unwrap(); - self.write_attribute(path, right, attribute_id, mount_id, dom, to) - } - (None, None) => break, - } - } - } - } - - fn remove_attribute(&self, attribute: &Attribute, id: ElementId, to: &mut impl WriteMutations) { - match &attribute.value { - AttributeValue::Listener(_) => { - to.remove_event_listener(&attribute.name[2..], id); - } - _ => { - to.set_attribute( - attribute.name, - attribute.namespace, - &AttributeValue::None, - id, - ); - } - } - } - - fn write_attribute( - &self, - path: &'static [u8], - attribute: &Attribute, - id: ElementId, - mount: MountId, - dom: &mut VirtualDom, - to: &mut impl WriteMutations, - ) { - match &attribute.value { - AttributeValue::Listener(_) => { - let element_ref = ElementRef { - path: ElementPath { path }, - mount, - }; - let mut elements = dom.runtime.elements.borrow_mut(); - elements[id.0] = Some(element_ref); - to.create_event_listener(&attribute.name[2..], id); - } - _ => { - to.set_attribute(attribute.name, attribute.namespace, &attribute.value, id); - } + dom.set_mounted_dyn_attr(mount, idx, ElementId::UNMOUNTED); } } @@ -561,7 +456,7 @@ impl VNode { root_ids: vec![ElementId(0); template.roots().len()].into_boxed_slice(), mounted_attributes: vec![ElementId(0); template.attr_paths().len()] .into_boxed_slice(), - mounted_dynamic_nodes: vec![usize::MAX; template.node_paths().len()] + mounted_dynamic_nodes: vec![UNMOUNTED; template.node_paths().len()] .into_boxed_slice(), }); } @@ -579,9 +474,7 @@ impl VNode { .get() .as_usize() .expect("node should already be mounted"), - ), - "Tried to find mount {:?} in dom.mounts, but it wasn't there", - self.mount.get() + ) ); let mount = self.mount.get(); @@ -642,7 +535,11 @@ impl VNode { impl VNode { /// Get a reference back into a dynamic node - fn reference_to_dynamic_node(&self, mount: MountId, dynamic_node_id: usize) -> ElementRef { + pub(super) fn reference_to_dynamic_node( + &self, + mount: MountId, + dynamic_node_id: usize, + ) -> ElementRef { ElementRef { path: ElementPath { path: self.template.node_paths()[dynamic_node_id], @@ -730,10 +627,7 @@ impl VNode { while let Some((idx, p)) = dynamic_nodes.next_if(|(_, p)| matches!(p, [idx, ..] if *idx == root_idx)) { - if p.len() == 1 { - continue; - } - + debug_assert!(p.len() > 1); end = idx; } @@ -769,11 +663,13 @@ impl VNode { ); if let Some(to) = to.as_deref_mut() { // If we actually created real new nodes, we need to replace the placeholder for this dynamic node with the new dynamic nodes - if m > 0 { - // The path is one shorter because the top node is the root - let path = &self.template.node_paths()[dynamic_node_id][1..]; - to.replace_placeholder_with_nodes(path, m); - } + debug_assert!( + m > 0, + "Create dynamic node will always create at least once placeholder node on the stack" + ); + // The path is one shorter because the top node is the root + let path = &self.template.node_paths()[dynamic_node_id][1..]; + to.replace_placeholder_with_nodes(path, m); } } } diff --git a/packages/core/src/events.rs b/packages/core/src/events.rs index b611b1facc..3f21698400 100644 --- a/packages/core/src/events.rs +++ b/packages/core/src/events.rs @@ -662,7 +662,11 @@ impl ListenerCallback { /// calling this method. pub fn call(&self, event: Event) { Runtime::current().with_scope_on_stack(self.origin, || { - (self.callback.borrow_mut())(event); + if let Ok(mut borrow_mut) = self.callback.try_borrow_mut() { + borrow_mut(event); + } else { + tracing::warn!("ListenerCallback was called recursively, ignoring recursive call to avoid re-entrance issues"); + } }); } diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs index 2abc70dfa0..ace668af55 100644 --- a/packages/core/src/lib.rs +++ b/packages/core/src/lib.rs @@ -40,6 +40,8 @@ pub mod internal { HotReloadTemplateWithLocation, HotReloadedTemplate, HotreloadedLiteral, NamedAttribute, TemplateGlobalKey, }; + #[doc(hidden)] + pub use crate::nodes::sort_template_attributes; #[allow(non_snake_case)] #[doc(hidden)] diff --git a/packages/core/src/nodes.rs b/packages/core/src/nodes.rs index 10e673545b..95040b0510 100644 --- a/packages/core/src/nodes.rs +++ b/packages/core/src/nodes.rs @@ -59,6 +59,10 @@ pub struct VNodeInner { /// This is a list of positions in the template where dynamic attributes can be inserted. /// /// The inner list *must* be in the format [static named attributes, remaining dynamically named attributes]. + /// More than one slot can point at the same template element when named dynamic attributes and + /// spread attributes are mixed. Creation writes those slots in order, and diffing groups slots + /// with the same attribute path so duplicate keys keep the same last-write-wins behavior and + /// removed dynamic overrides can reveal the static template attribute underneath. /// /// For example: /// ```rust @@ -154,9 +158,34 @@ impl VNode { pub fn new( key: Option, template: Template, - dynamic_nodes: Box<[DynamicNode]>, + mut dynamic_nodes: Box<[DynamicNode]>, dynamic_attrs: Box<[Box<[Attribute]>]>, ) -> Self { + for node in &mut dynamic_nodes { + if matches!(node, DynamicNode::Fragment(nodes) if nodes.is_empty()) { + *node = DynamicNode::Placeholder(Default::default()); + } + } + // The diff assumes every dynamic attribute slot is sorted by `(name, namespace)`. Named + // attributes are trivially sorted (one entry per slot); spread attributes are user-provided + // and the only realistic source of violations. + #[cfg(debug_assertions)] + for slot in &dynamic_attrs { + for pair in slot.windows(2) { + let left = (pair[0].name, pair[0].namespace); + let right = (pair[1].name, pair[1].namespace); + if left > right { + tracing::warn!( + "spread attributes in `rsx!` must be sorted by (name, namespace); \ + found {:?} before {:?}. The diff assumes sorted input and may produce \ + incorrect updates otherwise.", + left, + right, + ); + break; + } + } + } Self { vnode: Rc::new(VNodeInner { key, @@ -543,6 +572,7 @@ pub enum TemplateNode { /// A list of possibly dynamic attributes for this element /// /// An attribute on a DOM node, such as `id="my-thing"` or `href="https://example.com"`. + /// Static attributes must come first, sorted by name, followed by dynamic attributes in id order. #[cfg_attr( feature = "serialize", serde(deserialize_with = "deserialize_leaky", bound = "") @@ -580,6 +610,20 @@ impl TemplateNode { _ => None, } } + + pub(crate) fn element_child(&self, child_idx: usize) -> &'static TemplateNode { + let TemplateNode::Element { children, .. } = self else { + unreachable!("template attribute paths only pass through elements") + }; + &children[child_idx] + } + + pub(crate) fn element_attrs(&self) -> &'static [TemplateAttribute] { + let TemplateNode::Element { attrs, .. } = self else { + unreachable!("template attribute paths only point to elements") + }; + attrs + } } /// A node created at runtime @@ -748,7 +792,7 @@ impl From> for VText { pub struct VPlaceholder {} /// An attribute of the TemplateNode, created at compile time -#[derive(Debug, PartialEq, Hash, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq, PartialOrd, Ord)] #[cfg_attr( feature = "serialize", derive(serde::Serialize, serde::Deserialize), @@ -792,6 +836,70 @@ pub enum TemplateAttribute { }, } +#[doc(hidden)] +/// Sort static template attributes by their emitted name while leaving dynamic attributes in place. +/// +/// The diffing code binary-searches the static prefix by `TemplateAttribute::Static::name` when a +/// dynamic spread stops overriding a static value. The RSX syntax name is not always the emitted +/// DOM name (`r#as` emits `as`, `http_equiv` emits `http-equiv`), so this runs after macro +/// expansion has produced the actual static names. +pub const fn sort_template_attributes( + mut attrs: [TemplateAttribute; N], +) -> [TemplateAttribute; N] { + // The macro emits static attrs first and dynamic attrs second. Only the static prefix is + // sorted because dynamic attrs are addressed by id from the VNode's dynamic attribute list. + let mut static_len = 0; + while static_len < N { + match attrs[static_len] { + TemplateAttribute::Static { .. } => static_len += 1, + TemplateAttribute::Dynamic { .. } => break, + } + } + + // Attribute lists are small, and insertion sort is const-friendly on stable Rust. + let mut i = 1; + while i < static_len { + let mut j = i; + while j > 0 && template_attribute_name_less(attrs[j], attrs[j - 1]) { + let previous = attrs[j - 1]; + attrs[j - 1] = attrs[j]; + attrs[j] = previous; + j -= 1; + } + i += 1; + } + + attrs +} + +const fn template_attribute_name_less(left: TemplateAttribute, right: TemplateAttribute) -> bool { + match (left, right) { + ( + TemplateAttribute::Static { name: left, .. }, + TemplateAttribute::Static { name: right, .. }, + ) => static_str_less(left, right), + _ => false, + } +} + +const fn static_str_less(left: StaticStr, right: StaticStr) -> bool { + let left = left.as_bytes(); + let right = right.as_bytes(); + let mut idx = 0; + + while idx < left.len() && idx < right.len() { + if left[idx] < right[idx] { + return true; + } + if left[idx] > right[idx] { + return false; + } + idx += 1; + } + + left.len() < right.len() +} + /// An attribute on a DOM node, such as `id="my-thing"` or `href="https://example.com"` #[derive(Debug, Clone, PartialEq)] pub struct Attribute { @@ -1082,13 +1190,7 @@ where I: IntoVNode, { fn into_dyn_node(self) -> DynamicNode { - let children: Vec<_> = self.into_iter().map(|node| node.into_vnode()).collect(); - - if children.is_empty() { - DynamicNode::default() - } else { - DynamicNode::Fragment(children) - } + DynamicNode::Fragment(self.into_iter().map(|node| node.into_vnode()).collect()) } } diff --git a/packages/core/src/scopes.rs b/packages/core/src/scopes.rs index 014148cef7..da576a7e11 100644 --- a/packages/core/src/scopes.rs +++ b/packages/core/src/scopes.rs @@ -1,5 +1,5 @@ use crate::{ - Element, RenderError, Runtime, VNode, any_props::BoxedAnyProps, + Element, RenderError, Runtime, VNode, any_props::BoxedAnyProps, arena::UNMOUNTED, reactive_context::ReactiveContext, scope_context::Scope, }; use std::{cell::Ref, rc::Rc}; @@ -60,7 +60,7 @@ impl ScopeId { // ScopeId(0) is the root scope wrapper pub const ROOT: ScopeId = ScopeId(0); - pub(crate) const PLACEHOLDER: ScopeId = ScopeId(usize::MAX); + pub(crate) const PLACEHOLDER: ScopeId = ScopeId(UNMOUNTED); pub(crate) fn is_placeholder(&self) -> bool { *self == Self::PLACEHOLDER diff --git a/packages/core/src/suspense/component.rs b/packages/core/src/suspense/component.rs index d4662da6e8..57b20e3301 100644 --- a/packages/core/src/suspense/component.rs +++ b/packages/core/src/suspense/component.rs @@ -298,11 +298,10 @@ impl SuspenseBoundaryProps { } dom.runtime.clone().with_scope_on_stack(scope_id, || { let scope_state = &mut dom.scopes[scope_id.0]; + let suspense_context = scope_state.state().suspense_boundary().unwrap(); let props = Self::downcast_from_props(&mut *scope_state.props).unwrap(); - let suspense_context = - SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id) - .unwrap(); + let fallback = props.fallback; let children = props.children.clone(); // First always render the children in the background. Rendering the children may cause this boundary to suspend @@ -315,30 +314,14 @@ impl SuspenseBoundaryProps { let props = Self::downcast_from_props(&mut *scope_state.props).unwrap(); props.children.clone_from(&children); - let scope_state = &mut dom.scopes[scope_id.0]; - let suspense_context = scope_state - .state() - .suspense_location() - .suspense_context() - .unwrap() - .clone(); - // If there are suspended futures, render the fallback - if !suspense_context.suspended_futures().is_empty() { let (node, nodes_created) = suspense_context.in_suspense_placeholder(&dom.runtime(), || { - let scope_state = &mut dom.scopes[scope_id.0]; - let props = Self::downcast_from_props(&mut *scope_state.props).unwrap(); - let suspense_context = - SuspenseContext::downcast_suspense_boundary_from_scope( - &dom.runtime, - scope_id, - ) - .unwrap(); + remove_stale_background_nodes::(&suspense_context, dom, &children); suspense_context.set_suspended_nodes(children.as_vnode().clone()); let suspense_placeholder = - LastRenderedNode::new(props.fallback.call(suspense_context)); + LastRenderedNode::new(fallback.call(suspense_context.clone())); let nodes_created = suspense_placeholder.create(dom, parent, to); (suspense_placeholder, nodes_created) }); @@ -350,14 +333,16 @@ impl SuspenseBoundaryProps { } else { // Otherwise just render the children in the real dom debug_assert!(children.mount.get().mounted()); + // Clear any stale suspended nodes BEFORE rendering the children, so + // nested scopes under this boundary observe `is_suspended() == false` + // via `scope_should_render`. Otherwise `create_scope` would return a + // node count without emitting matching `load_template`/`create_*` + // mutations, leaving the caller's stack accounting off by that count. + remove_stale_background_nodes::(&suspense_context, dom, &children); let nodes_created = suspense_context .under_suspense_boundary(&dom.runtime(), || children.create(dom, parent, to)); let scope_state = &mut dom.scopes[scope_id.0]; scope_state.last_rendered_node = children.into(); - let suspense_context = - SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id) - .unwrap(); - suspense_context.take_suspended_nodes(); mark_suspense_resolved(&suspense_context, dom, scope_id); nodes_created @@ -383,12 +368,7 @@ impl SuspenseBoundaryProps { }; // Reset the suspense context - let suspense_context = scope_state - .state() - .suspense_location() - .suspense_context() - .unwrap() - .clone(); + let suspense_context = scope_state.state().suspense_boundary().unwrap(); suspense_context.inner.suspended_tasks.borrow_mut().clear(); // Get the parent of the suspense boundary to later create children with the right parent @@ -406,9 +386,6 @@ impl SuspenseBoundaryProps { // Unmount any children to reset any scopes under this suspense boundary let children = props.children.clone(); - let suspense_context = - SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id) - .unwrap(); // Take the suspended nodes out of the suspense boundary so the children know that the boundary is not suspended while diffing let suspended = suspense_context.take_suspended_nodes(); @@ -443,7 +420,7 @@ impl SuspenseBoundaryProps { pub(crate) fn diff( scope_id: ScopeId, dom: &mut VirtualDom, - to: Option<&mut M>, + mut to: Option<&mut M>, ) { dom.runtime.clone().with_scope_on_stack(scope_id, || { let scope = &mut dom.scopes[scope_id.0]; @@ -457,92 +434,132 @@ impl SuspenseBoundaryProps { fallback, children, .. } = myself; - let suspense_context = scope.state().suspense_boundary().unwrap().clone(); + let suspense_context = scope.state().suspense_boundary().unwrap(); let suspended_nodes = suspense_context.suspended_nodes(); let suspended = !suspense_context.suspended_futures().is_empty(); match (suspended_nodes, suspended) { - // We already have suspended nodes that still need to be suspended - // Just diff the normal and suspended nodes + // fallback -> fallback while background children are still suspended (Some(suspended_nodes), true) => { let new_suspended_nodes: VNode = children.as_vnode().clone(); - // Diff the placeholder nodes in the dom - let new_placeholder = - suspense_context.in_suspense_placeholder(&dom.runtime(), || { - let old_placeholder = last_rendered_node; - let new_placeholder = - LastRenderedNode::new(fallback.call(suspense_context.clone())); - - old_placeholder.diff_node(&new_placeholder, dom, to); - new_placeholder - }); - - // Set the last rendered node to the placeholder - dom.scopes[scope_id.0].last_rendered_node = Some(new_placeholder); - // Diff the suspended nodes in the background suspense_context.under_suspense_boundary(&dom.runtime(), || { suspended_nodes.diff_node(&new_suspended_nodes, dom, None::<&mut M>); }); - let suspense_context = SuspenseContext::downcast_suspense_boundary_from_scope( - &dom.runtime, - scope_id, - ) - .unwrap(); - suspense_context.set_suspended_nodes(new_suspended_nodes); + if suspense_context.suspended_futures().is_empty() { + suspense_context.take_suspended_nodes(); + + replace_placeholder_with_node( + &last_rendered_node, + &new_suspended_nodes, + dom, + to, + ); + store_rendered_suspense_children( + scope_id, + dom, + children_with_background_nodes(&children, new_suspended_nodes), + ); + + mark_suspense_resolved(&suspense_context, dom, scope_id); + } else { + // Diff the placeholder nodes in the dom + let new_placeholder = + suspense_context.in_suspense_placeholder(&dom.runtime(), || { + let old_placeholder = last_rendered_node; + let new_placeholder = + LastRenderedNode::new(fallback.call(suspense_context.clone())); + + old_placeholder.diff_node(&new_placeholder, dom, to); + new_placeholder + }); + + // Set the last rendered node to the placeholder + dom.scopes[scope_id.0].last_rendered_node = Some(new_placeholder); + + store_suspense_children_from_background( + &suspense_context, + scope_id, + dom, + &children, + new_suspended_nodes, + ); + } } - // We have no suspended nodes, and we are not suspended. Just diff the children like normal + // rendered children -> rendered children, unless a child suspends during diff (None, false) => { let old_children = last_rendered_node; let new_children = children; suspense_context.under_suspense_boundary(&dom.runtime(), || { - old_children.diff_node(&new_children, dom, to); + old_children.diff_node(&new_children, dom, to.as_deref_mut()); }); - // Set the last rendered node to the new children - dom.scopes[scope_id.0].last_rendered_node = new_children.into(); + if suspense_context.suspended_futures().is_empty() { + store_rendered_suspense_children(scope_id, dom, new_children); + } else { + let newly_suspended_scopes = suspense_context + .suspended_futures() + .iter() + .map(|future| future.origin) + .collect::>(); + let suspended_nodes = new_children.as_vnode().clone(); + + move_rendered_children_to_fallback( + scope_id, + dom, + to.as_deref_mut(), + &suspense_context, + &new_children, + fallback, + ); + + for scope in newly_suspended_scopes { + dom.clear_scope_rendered_output(scope); + } + + store_suspense_children_from_background( + &suspense_context, + scope_id, + dom, + &new_children, + suspended_nodes, + ); + + un_resolve_suspense(dom, scope_id); + } } - // We have no suspended nodes, but we just became suspended. Move the children to the background + // rendered children -> fallback because this boundary was already marked suspended (None, true) => { let old_children = last_rendered_node; let new_children: VNode = children.as_vnode().clone(); - let new_placeholder = - LastRenderedNode::new(fallback.call(suspense_context.clone())); - - // Move the children to the background - let mount = old_children.mount.get(); - let parent = dom.get_mounted_parent(mount); - - suspense_context.in_suspense_placeholder(&dom.runtime(), || { - old_children.move_node_to_background( - std::slice::from_ref(&new_placeholder), - parent, - dom, - to, - ); - }); + move_rendered_children_to_fallback( + scope_id, + dom, + to, + &suspense_context, + &old_children, + fallback, + ); // Then diff the new children in the background suspense_context.under_suspense_boundary(&dom.runtime(), || { old_children.diff_node(&new_children, dom, None::<&mut M>); }); - // Set the last rendered node to the new suspense placeholder - dom.scopes[scope_id.0].last_rendered_node = Some(new_placeholder); - - let suspense_context = SuspenseContext::downcast_suspense_boundary_from_scope( - &dom.runtime, + store_suspense_children_from_background( + &suspense_context, scope_id, - ) - .unwrap(); - suspense_context.set_suspended_nodes(new_children); + dom, + &children, + new_children, + ); un_resolve_suspense(dom, scope_id); } - // We have suspended nodes, but we just got out of suspense. Move the suspended nodes to the foreground + // fallback -> rendered children when suspension resolves or is cancelled (Some(_), false) => { // Take the suspended nodes out of the suspense boundary so the children know that the boundary is not suspended while diffing let old_suspended_nodes = suspense_context.take_suspended_nodes().unwrap(); @@ -553,19 +570,10 @@ impl SuspenseBoundaryProps { suspense_context.under_suspense_boundary(&dom.runtime(), || { old_suspended_nodes.diff_node(&new_children, dom, None::<&mut M>); - // Then replace the placeholder with the new children - let mount = old_placeholder.mount.get(); - let parent = dom.get_mounted_parent(mount); - old_placeholder.replace( - std::slice::from_ref(&new_children), - parent, - dom, - to, - ); + replace_placeholder_with_node(&old_placeholder, &new_children, dom, to); }); - // Set the last rendered node to the new children - dom.scopes[scope_id.0].last_rendered_node = Some(new_children); + store_rendered_suspense_children(scope_id, dom, new_children); mark_suspense_resolved(&suspense_context, dom, scope_id); } @@ -574,6 +582,88 @@ impl SuspenseBoundaryProps { } } +fn move_rendered_children_to_fallback( + scope_id: ScopeId, + dom: &mut VirtualDom, + to: Option<&mut M>, + suspense_context: &SuspenseContext, + currently_rendered: &LastRenderedNode, + fallback: Callback, +) { + let new_placeholder = LastRenderedNode::new(fallback.call(suspense_context.clone())); + let parent = dom.get_mounted_parent(currently_rendered.mount.get()); + + suspense_context.in_suspense_placeholder(&dom.runtime(), || { + currently_rendered.move_node_to_background( + std::slice::from_ref(&new_placeholder), + parent, + dom, + to, + ); + }); + + dom.scopes[scope_id.0].last_rendered_node = Some(new_placeholder); +} + +fn replace_placeholder_with_node( + placeholder: &LastRenderedNode, + node: &VNode, + dom: &mut VirtualDom, + to: Option<&mut M>, +) { + if let Some(to) = to { + let parent = dom.get_mounted_parent(placeholder.mount.get()); + placeholder.replace(std::slice::from_ref(node), parent, dom, Some(to)); + } else { + placeholder.remove_node(dom, None::<&mut M>, None); + } +} + +fn remove_stale_background_nodes( + suspense_context: &SuspenseContext, + dom: &mut VirtualDom, + children: &LastRenderedNode, +) { + let Some(stale_suspended_nodes) = suspense_context.take_suspended_nodes() else { + return; + }; + + if stale_suspended_nodes.mount.get() != children.mount.get() { + stale_suspended_nodes.remove_node_inner(dom, None::<&mut M>, true, None); + } +} + +fn store_rendered_suspense_children( + scope_id: ScopeId, + dom: &mut VirtualDom, + children: LastRenderedNode, +) { + let scope = &mut dom.scopes[scope_id.0]; + let props = SuspenseBoundaryProps::downcast_from_props(&mut *scope.props).unwrap(); + props.children = children.clone(); + scope.last_rendered_node = Some(children); +} + +fn store_suspense_children_from_background( + suspense_context: &SuspenseContext, + scope_id: ScopeId, + dom: &mut VirtualDom, + children: &LastRenderedNode, + suspended_nodes: VNode, +) { + suspense_context.set_suspended_nodes(suspended_nodes.clone()); + let scope = &mut dom.scopes[scope_id.0]; + let props = SuspenseBoundaryProps::downcast_from_props(&mut *scope.props).unwrap(); + props.children = children_with_background_nodes(children, suspended_nodes); +} + +fn children_with_background_nodes(children: &LastRenderedNode, nodes: VNode) -> LastRenderedNode { + match children { + LastRenderedNode::Real(_) => LastRenderedNode::Real(nodes), + LastRenderedNode::Placeholder(_, err) => LastRenderedNode::Placeholder(nodes, err.clone()), + } +} + /// Move to a resolved suspense state fn mark_suspense_resolved( suspense_context: &SuspenseContext, diff --git a/packages/core/src/suspense/mod.rs b/packages/core/src/suspense/mod.rs index 45b02ce0c6..f8ab974d95 100644 --- a/packages/core/src/suspense/mod.rs +++ b/packages/core/src/suspense/mod.rs @@ -155,7 +155,9 @@ impl SuspenseContext { .suspended_tasks .borrow_mut() .retain(|t| t.task != task.id); - self.inner.rt.needs_update(self.inner.id.get()); + if let Some(scope) = self.inner.rt.try_get_state(self.inner.id.get()) { + scope.needs_update(); + } } /// Get all suspended tasks diff --git a/packages/core/tests/attr_cleanup.rs b/packages/core/tests/attr_cleanup.rs index e6b44605fc..5f5cd4e07e 100644 --- a/packages/core/tests/attr_cleanup.rs +++ b/packages/core/tests/attr_cleanup.rs @@ -3,81 +3,58 @@ //! This tests to ensure we clean it up use dioxus::prelude::*; -use dioxus_core::{ElementId, IntoAttributeValue, Mutation::*, generation}; +use dioxus_core::{ScopeId, generation}; +use dioxus_renderer_oracle::RendererOracle; #[test] fn attrs_cycle() { tracing_subscriber::fmt::init(); - let mut dom = VirtualDom::new(|| { - let id = generation(); - match id % 2 { - 0 => rsx! { div {} }, - 1 => rsx! { - div { h1 { class: "{id}", id: "{id}" } } - }, - _ => unreachable!(), + fn app() -> Element { + match generation() { + 1 => { + let id = 1; + rsx! { div { h1 { class: "{id}", id: "{id}" } } } + } + 3 => { + let id = 3; + rsx! { div { h1 { class: "{id}", id: "{id}" } } } + } + _ => rsx! { div {} }, } - }); + } - assert_eq!( - dom.rebuild_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1,) }, - AppendChildren { m: 1, id: ElementId(0) }, - ] - ); + fn expected_1() -> Element { + rsx! { div { h1 { class: "1", id: "1" } } } + } + + fn expected_3() -> Element { + rsx! { div { h1 { class: "3", id: "3" } } } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(2,) }, - AssignId { path: &[0,], id: ElementId(3,) }, - SetAttribute { name: "class", value: "1".into_value(), id: ElementId(3,), ns: None }, - SetAttribute { name: "id", value: "1".into_value(), id: ElementId(3,), ns: None }, - ReplaceWith { id: ElementId(1,), m: 1 }, - ] - ); + let summary = oracle.render(&mut dom); + oracle.assert_matches(expected_1); + assert_eq!(summary.set_attrs, 2); + assert_eq!(summary.replaces, 1); dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1) }, - ReplaceWith { id: ElementId(2), m: 1 } - ] - ); + let summary = oracle.render(&mut dom); + oracle.assert_matches(app); + assert_eq!(summary.replaces, 1); dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(2) }, - AssignId { path: &[0], id: ElementId(3) }, - SetAttribute { - name: "class", - value: dioxus_core::AttributeValue::Text("3".to_string()), - id: ElementId(3), - ns: None - }, - SetAttribute { - name: "id", - value: dioxus_core::AttributeValue::Text("3".to_string()), - id: ElementId(3), - ns: None - }, - ReplaceWith { id: ElementId(1), m: 1 } - ] - ); + let summary = oracle.render(&mut dom); + oracle.assert_matches(expected_3); + assert_eq!(summary.set_attrs, 2); + assert_eq!(summary.replaces, 1); - // we take the node taken by attributes since we reused it dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1) }, - ReplaceWith { id: ElementId(2), m: 1 } - ] - ); + let summary = oracle.render(&mut dom); + oracle.assert_matches(app); + assert_eq!(summary.replaces, 1); } diff --git a/packages/core/tests/boolattrs.rs b/packages/core/tests/boolattrs.rs index 9e14dae1e3..b1a855f758 100644 --- a/packages/core/tests/boolattrs.rs +++ b/packages/core/tests/boolattrs.rs @@ -1,21 +1,14 @@ -use dioxus::dioxus_core::{ElementId, Mutation::*}; use dioxus::prelude::*; +use dioxus_renderer_oracle::RendererOracle; #[test] fn bool_test() { - let mut app = VirtualDom::new(|| rsx!(div { hidden: false })); + fn app() -> Element { + rsx! { div { hidden: false } } + } - assert_eq!( - app.rebuild_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1) }, - SetAttribute { - name: "hidden", - value: dioxus_core::AttributeValue::Bool(false), - id: ElementId(1,), - ns: None - }, - AppendChildren { m: 1, id: ElementId(0) }, - ] - ); + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(app); } diff --git a/packages/core/tests/context_api.rs b/packages/core/tests/context_api.rs index f35d474db8..38990c4545 100644 --- a/packages/core/tests/context_api.rs +++ b/packages/core/tests/context_api.rs @@ -1,6 +1,6 @@ -use dioxus::dioxus_core::{ElementId, Mutation::*}; use dioxus::prelude::*; use dioxus_core::{consume_context_from_scope, generation}; +use dioxus_renderer_oracle::RendererOracle; #[test] fn state_shares() { @@ -19,38 +19,43 @@ fn state_shares() { rsx!("Value is {value}") } + fn expected_0() -> Element { + rsx!("Value is 0") + } + + fn expected_2() -> Element { + rsx!("Value is 2") + } + + fn expected_3() -> Element { + rsx!("Value is 3") + } + let mut dom = VirtualDom::new(app); - assert_eq!( - dom.rebuild_to_vec().edits, - [ - CreateTextNode { value: "Value is 0".to_string(), id: ElementId(1,) }, - AppendChildren { m: 1, id: ElementId(0) }, - ] - ); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected_0); dom.mark_dirty(ScopeId::APP); - _ = dom.render_immediate_to_vec(); + oracle.render(&mut dom); dom.in_runtime(|| { assert_eq!(consume_context_from_scope::(ScopeId::APP).unwrap(), 1); }); dom.mark_dirty(ScopeId::APP); - _ = dom.render_immediate_to_vec(); + oracle.render(&mut dom); dom.in_runtime(|| { assert_eq!(consume_context_from_scope::(ScopeId::APP).unwrap(), 2); }); dom.mark_dirty(ScopeId(ScopeId::APP.0 + 2)); - assert_eq!( - dom.render_immediate_to_vec().edits, - [SetText { value: "Value is 2".to_string(), id: ElementId(1,) },] - ); + let summary = oracle.render(&mut dom); + oracle.assert_matches(expected_2); + assert_eq!(summary.set_texts, 1); dom.mark_dirty(ScopeId::APP); dom.mark_dirty(ScopeId(ScopeId::APP.0 + 2)); - let edits = dom.render_immediate_to_vec(); - assert_eq!( - edits.edits, - [SetText { value: "Value is 3".to_string(), id: ElementId(1,) },] - ); + let summary = oracle.render(&mut dom); + oracle.assert_matches(expected_3); + assert_eq!(summary.set_texts, 1); } diff --git a/packages/core/tests/create_dom.rs b/packages/core/tests/create_dom.rs index 009cad65d7..85af372965 100644 --- a/packages/core/tests/create_dom.rs +++ b/packages/core/tests/create_dom.rs @@ -1,133 +1,86 @@ #![allow(unused, non_upper_case_globals, non_snake_case)] //! Prove that the dom works normally through virtualdom methods. -//! -//! This methods all use "rebuild_to_vec" which completely bypasses the scheduler. -//! Hard rebuild_to_vecs don't consume any events from the event queue. -use dioxus::dioxus_core::Mutation::*; use dioxus::prelude::*; -use dioxus_core::ElementId; +use dioxus_renderer_oracle::RendererOracle; #[test] fn test_original_diff() { - let mut dom = VirtualDom::new(|| { - rsx! { - div { div { "Hello, world!" } } - } - }); - - let edits = dom.rebuild_to_vec(); - - assert_eq!( - edits.edits, - [ - // add to root - LoadTemplate { index: 0, id: ElementId(1) }, - AppendChildren { m: 1, id: ElementId(0) } - ] - ) + fn app() -> Element { + rsx! { div { div { "Hello, world!" } } } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(app); } #[test] fn create() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { rsx! { div { div { "Hello, world!" div { div { - Fragment { "hello""world" } + Fragment { "hello" "world" } } } } } } - }); - - let _edits = dom.rebuild_to_vec(); - - // todo: we don't test template mutations anymore since the templates are passed along - - // assert_eq!( - // edits.templates, - // [ - // // create template - // CreateElement { name: "div" }, - // CreateElement { name: "div" }, - // CreateStaticText { value: "Hello, world!" }, - // CreateElement { name: "div" }, - // CreateElement { name: "div" }, - // CreateStaticPlaceholder {}, - // AppendChildren { m: 1 }, - // AppendChildren { m: 1 }, - // AppendChildren { m: 2 }, - // AppendChildren { m: 1 }, - // SaveTemplate { m: 1 }, - // // The fragment child template - // CreateStaticText { value: "hello" }, - // CreateStaticText { value: "world" }, - // SaveTemplate { m: 2 }, - // ] - // ); + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(app); } #[test] fn create_list() { - let mut dom = VirtualDom::new(|| rsx! {{(0..3).map(|f| rsx!( div { "hello" } ))}}); - - let _edits = dom.rebuild_to_vec(); - - // note: we dont test template edits anymore - // assert_eq!( - // edits.templates, - // [ - // // create template - // CreateElement { name: "div" }, - // CreateStaticText { value: "hello" }, - // AppendChildren { m: 1 }, - // SaveTemplate { m: 1 } - // ] - // ); + fn app() -> Element { + rsx! {{(0..3).map(|_| rsx!( div { "hello" } ))}} + } + + fn expected() -> Element { + rsx! { + div { "hello" } + div { "hello" } + div { "hello" } + } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected); } #[test] fn create_simple() { - let mut dom = VirtualDom::new(|| { - rsx! { - div {} - div {} - div {} - div {} - } - }); - - let edits = dom.rebuild_to_vec(); - - // note: we dont test template edits anymore - // assert_eq!( - // edits.templates, - // [ - // // create template - // CreateElement { name: "div" }, - // CreateElement { name: "div" }, - // CreateElement { name: "div" }, - // CreateElement { name: "div" }, - // // add to root - // SaveTemplate { m: 4 } - // ] - // ); + fn app() -> Element { + rsx! { div {} div {} div {} div {} } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(app); } + #[test] fn create_components() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { rsx! { Child { "abc1" } Child { "abc2" } Child { "abc3" } } - }); + } #[derive(Props, Clone, PartialEq)] struct ChildProps { @@ -142,14 +95,29 @@ fn create_components() { } } - let _edits = dom.rebuild_to_vec(); + fn expected() -> Element { + rsx! { + h1 {} + div { "abc1" } + p {} + h1 {} + div { "abc2" } + p {} + h1 {} + div { "abc3" } + p {} + } + } - // todo: test this + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected); } #[test] fn anchors() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { rsx! { if true { div { "hello" } @@ -158,29 +126,45 @@ fn anchors() { div { "goodbye" } } } - }); - - // note that the template under "false" doesn't show up since it's not loaded - let edits = dom.rebuild_to_vec(); - - // note: we dont test template edits anymore - // assert_eq!( - // edits.templates, - // [ - // // create each template - // CreateElement { name: "div" }, - // CreateStaticText { value: "hello" }, - // AppendChildren { m: 1 }, - // SaveTemplate { m: 1, name: "template" }, - // ] - // ); - - assert_eq!( - edits.edits, - [ - LoadTemplate { index: 0, id: ElementId(1) }, - CreatePlaceholder { id: ElementId(2) }, - AppendChildren { m: 2, id: ElementId(0) } - ] - ) + } + + fn expected() -> Element { + rsx! { + div { "hello" } + } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected); +} + +#[test] +fn empty_fragment_root_via_direct_vnode_api_is_diffable() { + // `VNode::new` normalizes `DynamicNode::Fragment(Vec::new())` to + // `DynamicNode::Placeholder(..)` so the diff path never sees an empty fragment. + // Without that normalization, callers using the direct `VNode::new(..)` API would + // bypass the rsx macro's `IntoDynNode` collapse and trip + // `index out of bounds: the len is 0 but the index is 0` on the second rerender. + use dioxus_core::{DynamicNode, ScopeId, Template, TemplateNode, VNode, VirtualDom}; + use dioxus_renderer_oracle::RendererOracle; + + fn app() -> Element { + let template = Template::new(&[TemplateNode::Dynamic { id: 0 }], &[&[0u8] as &[u8]], &[]); + Ok(VNode::new( + None, + template, + Box::new([DynamicNode::Fragment(Vec::new())]), + Vec::>::new().into_boxed_slice(), + )) + } + + let mut vdom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + vdom.rebuild(&mut oracle); + vdom.mark_dirty(ScopeId::APP); + vdom.render_immediate(&mut oracle); + vdom.mark_dirty(ScopeId::APP); + vdom.render_immediate(&mut oracle); } diff --git a/packages/core/tests/create_fragments.rs b/packages/core/tests/create_fragments.rs index 9890d70099..e95d77af2e 100644 --- a/packages/core/tests/create_fragments.rs +++ b/packages/core/tests/create_fragments.rs @@ -1,8 +1,7 @@ //! Do we create fragments properly across complex boundaries? -use dioxus::dioxus_core::Mutation::*; use dioxus::prelude::*; -use dioxus_core::ElementId; +use dioxus_renderer_oracle::RendererOracle; #[test] fn empty_fragment_creates_nothing() { @@ -10,36 +9,30 @@ fn empty_fragment_creates_nothing() { rsx!({}) } - let mut vdom = VirtualDom::new(app); - let edits = vdom.rebuild_to_vec(); - - assert_eq!( - edits.edits, - [ - CreatePlaceholder { id: ElementId(1) }, - AppendChildren { id: ElementId(0), m: 1 } - ] - ); + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(app); } #[test] fn root_fragments_work() { - let mut vdom = VirtualDom::new(|| { - rsx!( + fn app() -> Element { + rsx! { div { "hello" } div { "goodbye" } - ) - }); + } + } - assert_eq!( - vdom.rebuild_to_vec().edits.last().unwrap(), - &AppendChildren { id: ElementId(0), m: 2 } - ); + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(app); } #[test] fn fragments_nested() { - let mut vdom = VirtualDom::new(|| { + fn app() -> Element { rsx!( div { "hello" } div { "goodbye" } @@ -56,12 +49,25 @@ fn fragments_nested() { }} }} ) - }); + } - assert_eq!( - vdom.rebuild_to_vec().edits.last().unwrap(), - &AppendChildren { id: ElementId(0), m: 8 } - ); + fn expected() -> Element { + rsx! { + div { "hello" } + div { "goodbye" } + div { "hello" } + div { "goodbye" } + div { "hello" } + div { "goodbye" } + div { "hello" } + div { "goodbye" } + } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected); } #[test] @@ -80,10 +86,23 @@ fn fragments_across_components() { rsx! { "hellO!" {world} } } - assert_eq!( - VirtualDom::new(app).rebuild_to_vec().edits.last().unwrap(), - &AppendChildren { id: ElementId(0), m: 8 } - ); + fn expected() -> Element { + rsx! { + "hellO!" + "world" + "hellO!" + "world" + "hellO!" + "world" + "hellO!" + "world" + } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected); } #[test] @@ -94,8 +113,21 @@ fn list_fragments() { {(0..6).map(|f| rsx!( span { "{f}" }))} ) } - assert_eq!( - VirtualDom::new(app).rebuild_to_vec().edits.last().unwrap(), - &AppendChildren { id: ElementId(0), m: 7 } - ); + + fn expected() -> Element { + rsx! { + h1 { "hello" } + span { "0" } + span { "1" } + span { "2" } + span { "3" } + span { "4" } + span { "5" } + } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected); } diff --git a/packages/core/tests/create_lists.rs b/packages/core/tests/create_lists.rs index fa42345334..1cce64db02 100644 --- a/packages/core/tests/create_lists.rs +++ b/packages/core/tests/create_lists.rs @@ -1,7 +1,5 @@ -use dioxus::dioxus_core::Mutation::*; use dioxus::prelude::*; -use dioxus_core::ElementId; -use pretty_assertions::assert_eq; +use dioxus_renderer_oracle::RendererOracle; // A real-world usecase of templates at peak performance // In react, this would be a lot of node creation. @@ -24,53 +22,27 @@ fn app() -> Element { #[test] fn list_renders() { - let mut dom = VirtualDom::new(app); - - let edits = dom.rebuild_to_vec(); - - // note: we dont test template edits anymore - // assert_eq!( - // edits.templates, - // [ - // // Create the outer div - // CreateElement { name: "div" }, - // // todo: since this is the only child, we should just use - // // append when modify the values (IE no need for a placeholder) - // CreateStaticPlaceholder, - // AppendChildren { m: 1 }, - // SaveTemplate { m: 1 }, - // // Create the inner template div - // CreateElement { name: "div" }, - // CreateElement { name: "h1" }, - // CreateStaticText { value: "hello world! " }, - // AppendChildren { m: 1 }, - // CreateElement { name: "p" }, - // CreateTextPlaceholder, - // AppendChildren { m: 1 }, - // AppendChildren { m: 2 }, - // SaveTemplate { m: 1 } - // ], - // ); + fn expected() -> Element { + rsx! { + div { + div { + h1 { "hello world! " } + p { "0" } + } + div { + h1 { "hello world! " } + p { "1" } + } + div { + h1 { "hello world! " } + p { "2" } + } + } + } + } - assert_eq!( - edits.edits, - [ - // Load the outer div - LoadTemplate { index: 0, id: ElementId(1) }, - // Load each template one-by-one, rehydrating it - LoadTemplate { index: 0, id: ElementId(2) }, - CreateTextNode { value: "0".to_string(), id: ElementId(3) }, - ReplacePlaceholder { path: &[1, 0], m: 1 }, - LoadTemplate { index: 0, id: ElementId(4) }, - CreateTextNode { value: "1".to_string(), id: ElementId(5) }, - ReplacePlaceholder { path: &[1, 0], m: 1 }, - LoadTemplate { index: 0, id: ElementId(6) }, - CreateTextNode { value: "2".to_string(), id: ElementId(7) }, - ReplacePlaceholder { path: &[1, 0], m: 1 }, - // Replace the 0th childn on the div with the 3 templates on the stack - ReplacePlaceholder { m: 3, path: &[0] }, - // Append the container div to the dom - AppendChildren { m: 1, id: ElementId(0) } - ], - ) + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected); } diff --git a/packages/core/tests/create_passthru.rs b/packages/core/tests/create_passthru.rs index 87f54a550c..a1199ebd93 100644 --- a/packages/core/tests/create_passthru.rs +++ b/packages/core/tests/create_passthru.rs @@ -1,6 +1,5 @@ -use dioxus::dioxus_core::Mutation::*; use dioxus::prelude::*; -use dioxus_core::ElementId; +use dioxus_renderer_oracle::RendererOracle; /// Should push the text node onto the stack and modify it #[test] @@ -20,16 +19,14 @@ fn nested_passthru_creates() { rsx!({ children }) } - let mut dom = VirtualDom::new(app); - let edits = dom.rebuild_to_vec(); + fn expected() -> Element { + rsx! { div { "hi" } } + } - assert_eq!( - edits.edits, - [ - LoadTemplate { index: 0, id: ElementId(1) }, - AppendChildren { m: 1, id: ElementId(0) }, - ] - ) + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected); } /// Should load all the templates and append them @@ -57,22 +54,19 @@ fn nested_passthru_creates_add() { rsx! {{children}} } - let mut dom = VirtualDom::new(app); + fn expected() -> Element { + rsx! { + "1" + "2" + "3" + div { "hi" } + } + } - assert_eq!( - dom.rebuild_to_vec().edits, - [ - // load 1 - LoadTemplate { index: 0, id: ElementId(1) }, - // load 2 - LoadTemplate { index: 0, id: ElementId(2) }, - // load 3 - LoadTemplate { index: 0, id: ElementId(3) }, - // load div that contains 4 - LoadTemplate { index: 1, id: ElementId(4) }, - AppendChildren { id: ElementId(0), m: 4 }, - ] - ); + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected); } /// note that the template is all dynamic roots - so it doesn't actually get cached as a template @@ -84,17 +78,12 @@ fn dynamic_node_as_root() { rsx! { "{a}" "{b}" } } - let mut dom = VirtualDom::new(app); - let edits = dom.rebuild_to_vec(); + fn expected() -> Element { + rsx! { "123" "456" } + } - // Since the roots were all dynamic, they should not cause any template muations - // The root node is text, so we just create it on the spot - assert_eq!( - edits.edits, - [ - CreateTextNode { value: "123".to_string(), id: ElementId(1) }, - CreateTextNode { value: "456".to_string(), id: ElementId(2) }, - AppendChildren { id: ElementId(0), m: 2 } - ] - ) + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected); } diff --git a/packages/core/tests/cycle.rs b/packages/core/tests/cycle.rs index 2888d6262b..5b1480d2fa 100644 --- a/packages/core/tests/cycle.rs +++ b/packages/core/tests/cycle.rs @@ -1,52 +1,25 @@ -use dioxus::dioxus_core::{ElementId, Mutation::*}; use dioxus::prelude::*; -use dioxus_core::generation; +use dioxus_core::{ScopeId, generation}; +use dioxus_renderer_oracle::RendererOracle; /// As we clean up old templates, the ID for the node should cycle #[test] fn cycling_elements() { - let mut dom = VirtualDom::new(|| match generation() % 2 { - 0 => rsx! { div { "wasd" } }, - 1 => rsx! { div { "abcd" } }, - _ => unreachable!(), - }); - - { - let edits = dom.rebuild_to_vec(); - assert_eq!( - edits.edits, - [ - LoadTemplate { index: 0, id: ElementId(1,) }, - AppendChildren { m: 1, id: ElementId(0) }, - ] - ); + fn app() -> Element { + match generation() % 2 { + 0 => rsx! { div { "wasd" } }, + _ => rsx! { div { "abcd" } }, + } } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(2,) }, - ReplaceWith { id: ElementId(1,), m: 1 }, - ] - ); - - // notice that the IDs cycle back to ElementId(1), preserving a minimal memory footprint - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1,) }, - ReplaceWith { id: ElementId(2,), m: 1 }, - ] - ); + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(2,) }, - ReplaceWith { id: ElementId(1,), m: 1 }, - ] - ); + for _ in 1..=3 { + dom.mark_dirty(ScopeId::APP); + let summary = oracle.render(&mut dom); + assert_eq!(summary.loads, 1); + assert_eq!(summary.replaces, 1); + } } diff --git a/packages/core/tests/diff_attr_listener_swap.rs b/packages/core/tests/diff_attr_listener_swap.rs new file mode 100644 index 0000000000..fdba11c086 --- /dev/null +++ b/packages/core/tests/diff_attr_listener_swap.rs @@ -0,0 +1,48 @@ +//! Exercise the `(false, true, Some(_))` arm of `diff_dynamic_attribute` +//! (packages/core/src/diff/attributes.rs:196), where the same dynamic +//! attribute key transitions from a value to a listener across renders. +//! +//! The fuzz harness's `dynamic_attr_name` couples a value byte's high bit to +//! both the attribute-name format and listener-ness, so the byte stream of +//! fuzz inputs can never produce a value attribute and a listener that +//! share a key. The only way to reach that arm is to hand-construct the +//! attribute lists. + +use dioxus::prelude::*; +use dioxus_core::{AttributeValue, ScopeId, generation}; +use dioxus_renderer_oracle::RendererOracle; + +#[test] +fn value_to_listener_at_same_key_clears_old_value() { + fn app() -> Element { + match generation() { + 0 => { + let attrs = vec![Attribute::new("onclick", "raw", None, false)]; + rsx! { button { ..attrs } } + } + _ => { + let listeners = vec![Attribute::new( + "onclick", + AttributeValue::listener(|_: Event<()>| {}), + None, + false, + )]; + rsx! { button { ..listeners } } + } + } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + + dom.mark_dirty(ScopeId::APP); + let summary = oracle.render(&mut dom); + // The transition installs a listener and clears the old "onclick" value + // attribute, so the diff emits one `set_attribute` (to AttributeValue::None + // on line 196) followed by the listener install. + assert!( + summary.set_attrs >= 1, + "expected at least one set_attribute call, got summary={summary:?}", + ); +} diff --git a/packages/core/tests/diff_attr_static_fallback.rs b/packages/core/tests/diff_attr_static_fallback.rs new file mode 100644 index 0000000000..0edffab59f --- /dev/null +++ b/packages/core/tests/diff_attr_static_fallback.rs @@ -0,0 +1,88 @@ +//! Exercise `remove_attribute_or_write_fallback` (attributes.rs:240-292) +//! specifically the branch where the disappearing dynamic attribute was +//! shadowing a static template attribute at the same `(name, namespace)` +//! key. After the dynamic disappears, the diff must restore the static +//! value. +//! +//! The fuzz mutator can reach this scenario via its alias-then-remove +//! primitive, but only stochastically. This test pins it down so the +//! coverage of lines 248-292 doesn't depend on fuzz luck. + +use dioxus::prelude::*; +use dioxus_core::{Attribute, ScopeId, generation}; +use dioxus_renderer_oracle::RendererOracle; + +#[test] +fn static_attribute_resurfaces_when_dynamic_disappears() { + fn app() -> Element { + // The template carries a *static* `class="from-template"` attribute. + // On the first generation we layer a *dynamic* `class="overlay"` on + // top of it via `..attrs`; on the next generation the dynamic + // attribute disappears, which must restore the static value. + let attrs: Vec = if generation() == 0 { + vec![Attribute::new("class", "overlay", None, false)] + } else { + Vec::new() + }; + + rsx! { + div { + class: "from-template", + ..attrs, + } + } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + + dom.mark_dirty(ScopeId::APP); + let summary = oracle.render(&mut dom); + + // The dynamic attribute disappears, so the diff must call + // `remove_attribute_or_write_fallback`, find the static template attr, + // and emit a `set_attribute` restoring its value. Anything ≥ 1 means + // the fallback Some(value) branch fired. + assert!( + summary.set_attrs >= 1, + "expected static template attribute to be restored, got summary={summary:?}", + ); +} + +#[test] +fn nested_static_attribute_resurfaces_when_dynamic_disappears() { + // Same scenario as above but on a deeper element path, so + // `template_node_at_path` recurses through `element_child(...)` + // (attributes.rs:291) before resolving the owning element. + fn app() -> Element { + let attrs: Vec = if generation() == 0 { + vec![Attribute::new("id", "overlay", None, false)] + } else { + Vec::new() + }; + + rsx! { + section { + div { + span { + id: "deep-static", + ..attrs, + } + } + } + } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + + dom.mark_dirty(ScopeId::APP); + let summary = oracle.render(&mut dom); + + assert!( + summary.set_attrs >= 1, + "expected deep static attribute to be restored, got summary={summary:?}", + ); +} diff --git a/packages/core/tests/diff_component.rs b/packages/core/tests/diff_component.rs index 4ac1bcfd9c..c5118ff8a0 100644 --- a/packages/core/tests/diff_component.rs +++ b/packages/core/tests/diff_component.rs @@ -1,6 +1,6 @@ -use dioxus::dioxus_core::{ElementId, Mutation::*}; use dioxus::prelude::*; -use pretty_assertions::assert_eq; +use dioxus_core::ScopeId; +use dioxus_renderer_oracle::{OracleNodeId, RendererOracle}; /// When returning sets of components, we do a light diff of the contents to preserve some react-like functionality /// @@ -49,7 +49,7 @@ fn component_swap() { fn nav_bar() -> Element { rsx! { - h1 { + h1 { id: "nav", "NavBar" for _ in 0..3 { nav_link {} @@ -70,47 +70,56 @@ fn component_swap() { rsx!( div { "results" } ) } - let mut dom = VirtualDom::new(app); - { - let edits = dom.rebuild_to_vec(); - assert_eq!( - edits.edits, - [ - LoadTemplate { index: 0, id: ElementId(1) }, - LoadTemplate { index: 0, id: ElementId(2) }, - LoadTemplate { index: 0, id: ElementId(3) }, - LoadTemplate { index: 0, id: ElementId(4) }, - ReplacePlaceholder { path: &[1], m: 3 }, - LoadTemplate { index: 0, id: ElementId(5) }, - AppendChildren { m: 2, id: ElementId(0) } - ] - ); + fn expected_dashboard() -> Element { + rsx! { + h1 { id: "nav", + "NavBar" + h1 { "nav_link" } + h1 { "nav_link" } + h1 { "nav_link" } + } + div { "dashboard" } + } } + fn expected_results() -> Element { + rsx! { + h1 { id: "nav", + "NavBar" + h1 { "nav_link" } + h1 { "nav_link" } + h1 { "nav_link" } + } + div { "results" } + } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected_results); + let nav_identity = identity_by_attr(&oracle, "id", "nav"); + dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(6) }, - ReplaceWith { id: ElementId(5), m: 1 } - ] - ); + oracle.render(&mut dom); + oracle.assert_matches(expected_dashboard); + assert_eq!(identity_by_attr(&oracle, "id", "nav"), nav_identity); dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(5) }, - ReplaceWith { id: ElementId(6), m: 1 } - ] - ); + oracle.render(&mut dom); + oracle.assert_matches(expected_results); + assert_eq!(identity_by_attr(&oracle, "id", "nav"), nav_identity); dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(6) }, - ReplaceWith { id: ElementId(5), m: 1 } - ] - ); + oracle.render(&mut dom); + oracle.assert_matches(expected_dashboard); + assert_eq!(identity_by_attr(&oracle, "id", "nav"), nav_identity); +} + +fn identity_by_attr(oracle: &RendererOracle, attr: &str, value: &str) -> OracleNodeId { + oracle + .identities_by_attr(attr) + .into_iter() + .find_map(|(current_value, id)| (current_value == value).then_some(id)) + .unwrap_or_else(|| panic!("no live element with `{attr}={value}` found in the oracle DOM")) } diff --git a/packages/core/tests/diff_dynamic_node.rs b/packages/core/tests/diff_dynamic_node.rs index 47d045f6a6..ff082148e8 100644 --- a/packages/core/tests/diff_dynamic_node.rs +++ b/packages/core/tests/diff_dynamic_node.rs @@ -1,52 +1,43 @@ -use dioxus::dioxus_core::{ElementId, Mutation::*}; use dioxus::prelude::*; -use dioxus_core::generation; -use pretty_assertions::assert_eq; +use dioxus_core::{ScopeId, generation}; +use dioxus_renderer_oracle::RendererOracle; #[test] fn toggle_option_text() { - let mut dom = VirtualDom::new(|| { - let g = generation(); - let text = if g % 2 != 0 { Some("hello") } else { None }; - println!("{:?}", text); - + fn empty() -> Element { + let text: Option<&str> = None; rsx! { div { {text} } } - }); + } - // load the div and then assign the None as a placeholder - assert_eq!( - dom.rebuild_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1,) }, - CreatePlaceholder { id: ElementId(2,) }, - ReplacePlaceholder { path: &[0], m: 1 }, - AppendChildren { id: ElementId(0), m: 1 }, - ] - ); + fn app() -> Element { + match generation() { + 1 => rsx! { div { "hello" } }, + _ => empty(), + } + } + + fn expected_hello() -> Element { + rsx! { div { "hello" } } + } + + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(empty); - // Rendering again should replace the placeholder with an text node dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - CreateTextNode { value: "hello".to_string(), id: ElementId(3,) }, - ReplaceWith { id: ElementId(2,), m: 1 }, - ] - ); + let summary = oracle.render(&mut dom); + oracle.assert_matches(expected_hello); + assert_eq!(summary.replaces, 1); - // Rendering again should replace the placeholder with an text node dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - CreatePlaceholder { id: ElementId(2,) }, - ReplaceWith { id: ElementId(3,), m: 1 }, - ] - ); + let summary = oracle.render(&mut dom); + oracle.assert_matches(empty); + assert_eq!(summary.replaces, 1); } // Regression test for https://github.com/DioxusLabs/dioxus/issues/2815 @@ -73,43 +64,27 @@ fn toggle_template() { } } - let mut dom = VirtualDom::new(app); - dom.rebuild(&mut dioxus_core::NoOpMutations); - - // Rendering again should replace the placeholder with an text node - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - CreatePlaceholder { id: ElementId(2) }, - ReplaceWith { id: ElementId(1), m: 1 }, - ] - ); + fn expected_true() -> Element { + rsx! { "true" } + } - dom.mark_dirty(ScopeId(ScopeId::APP.0 + 1)); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - CreateTextNode { value: "true".to_string(), id: ElementId(1) }, - ReplaceWith { id: ElementId(2), m: 1 }, - ] - ); + fn expected_empty() -> Element { + rsx!({}) + } - dom.mark_dirty(ScopeId(ScopeId::APP.0 + 1)); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - CreatePlaceholder { id: ElementId(2) }, - ReplaceWith { id: ElementId(1), m: 1 }, - ] - ); + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected_true); - dom.mark_dirty(ScopeId(ScopeId::APP.0 + 1)); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - CreateTextNode { value: "true".to_string(), id: ElementId(1) }, - ReplaceWith { id: ElementId(2), m: 1 }, - ] - ); + for step in 1..=4 { + dom.mark_dirty(ScopeId::APP); + let summary = oracle.render(&mut dom); + if step % 2 == 0 { + oracle.assert_matches(expected_true); + } else { + oracle.assert_matches(expected_empty); + } + assert_eq!(summary.replaces, 1); + } } diff --git a/packages/core/tests/diff_element.rs b/packages/core/tests/diff_element.rs index 441ddfd837..bd7275969d 100644 --- a/packages/core/tests/diff_element.rs +++ b/packages/core/tests/diff_element.rs @@ -1,7 +1,7 @@ -use dioxus::dioxus_core::Mutation::*; -use dioxus::dioxus_core::{AttributeValue, ElementId, NoOpMutations}; +use dioxus::dioxus_core::AttributeValue; use dioxus::prelude::*; -use dioxus_core::generation; +use dioxus_core::{ScopeId, generation}; +use dioxus_renderer_oracle::{EditSummary, RendererOracle}; #[test] fn text_diff() { @@ -10,26 +10,26 @@ fn text_diff() { rsx!( h1 { "hello {g}" } ) } - let mut vdom = VirtualDom::new(app); - vdom.rebuild(&mut NoOpMutations); + fn expected_0() -> Element { + rsx!( h1 { "hello 0" } ) + } + + fn expected_1() -> Element { + rsx!( h1 { "hello 1" } ) + } - vdom.mark_dirty(ScopeId::APP); - assert_eq!( - vdom.render_immediate_to_vec().edits, - [SetText { value: "hello 1".to_string(), id: ElementId(2) }] - ); + fn expected_2() -> Element { + rsx!( h1 { "hello 2" } ) + } - vdom.mark_dirty(ScopeId::APP); - assert_eq!( - vdom.render_immediate_to_vec().edits, - [SetText { value: "hello 2".to_string(), id: ElementId(2) }] - ); + fn expected_3() -> Element { + rsx!( h1 { "hello 3" } ) + } - vdom.mark_dirty(ScopeId::APP); - assert_eq!( - vdom.render_immediate_to_vec().edits, - [SetText { value: "hello 3".to_string(), id: ElementId(2) }] - ); + let (mut dom, mut oracle, _) = rebuild(app, expected_0); + assert_eq!(rerender(&mut dom, &mut oracle, expected_1).set_texts, 1); + assert_eq!(rerender(&mut dom, &mut oracle, expected_2).set_texts, 1); + assert_eq!(rerender(&mut dom, &mut oracle, expected_3).set_texts, 1); } #[test] @@ -44,48 +44,27 @@ fn element_swap() { } } - let mut vdom = VirtualDom::new(app); - vdom.rebuild(&mut NoOpMutations); - - vdom.mark_dirty(ScopeId::APP); - assert_eq!( - vdom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(2,) }, - ReplaceWith { id: ElementId(1,), m: 1 }, - ] - ); - - vdom.mark_dirty(ScopeId::APP); - assert_eq!( - vdom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1,) }, - ReplaceWith { id: ElementId(2,), m: 1 }, - ] - ); - - vdom.mark_dirty(ScopeId::APP); - assert_eq!( - vdom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(2,) }, - ReplaceWith { id: ElementId(1,), m: 1 }, - ] - ); - - vdom.mark_dirty(ScopeId::APP); - assert_eq!( - vdom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1,) }, - ReplaceWith { id: ElementId(2,), m: 1 }, - ] - ); + fn expected_h1() -> Element { + rsx!( h1 { "hello 1" } ) + } + + fn expected_h2() -> Element { + rsx!( h2 { "hello 2" } ) + } + + let (mut dom, mut oracle, _) = rebuild(app, expected_h1); + assert_eq!(rerender(&mut dom, &mut oracle, expected_h2).replaces, 1); + assert_eq!(rerender(&mut dom, &mut oracle, expected_h1).replaces, 1); + assert_eq!(rerender(&mut dom, &mut oracle, expected_h2).replaces, 1); + assert_eq!(rerender(&mut dom, &mut oracle, expected_h1).replaces, 1); } #[test] fn attribute_diff() { + fn attr(name: &'static str, value: &'static str) -> Attribute { + Attribute::new(name, AttributeValue::Text(value.into()), None, false) + } + fn app() -> Element { let g = generation(); @@ -124,63 +103,203 @@ fn attribute_diff() { ) } - let mut vdom = VirtualDom::new(app); - vdom.rebuild(&mut NoOpMutations); - - vdom.mark_dirty(ScopeId::APP); - assert_eq!( - vdom.render_immediate_to_vec().edits, - [ - SetAttribute { - name: "b", - value: (AttributeValue::Text("hello".into())), - id: ElementId(1,), - ns: None, - }, - SetAttribute { - name: "c", - value: (AttributeValue::Text("hello".into())), - id: ElementId(1,), - ns: None, - }, - ] - ); - - vdom.mark_dirty(ScopeId::APP); - assert_eq!( - vdom.render_immediate_to_vec().edits, - [ - SetAttribute { name: "a", value: AttributeValue::None, id: ElementId(1,), ns: None }, - SetAttribute { name: "b", value: AttributeValue::None, id: ElementId(1,), ns: None }, - SetAttribute { - name: "d", - value: AttributeValue::Text("hello".into()), - id: ElementId(1,), - ns: None, - }, - SetAttribute { - name: "e", - value: AttributeValue::Text("hello".into()), - id: ElementId(1,), - ns: None, - }, - ] - ); - - vdom.mark_dirty(ScopeId::APP); - assert_eq!( - vdom.render_immediate_to_vec().edits, - [ - SetAttribute { name: "c", value: AttributeValue::None, id: ElementId(1,), ns: None }, - SetAttribute { - name: "d", - value: AttributeValue::Text("world".into()), - id: ElementId(1,), - ns: None, - }, - SetAttribute { name: "e", value: AttributeValue::None, id: ElementId(1,), ns: None }, - ] - ); + fn expected_0() -> Element { + rsx!( div { ..vec![attr("a", "hello")], "hello" } ) + } + + fn expected_1() -> Element { + rsx!( div { ..vec![attr("a", "hello"), attr("b", "hello"), attr("c", "hello")], "hello" } ) + } + + fn expected_2() -> Element { + rsx!( div { ..vec![attr("c", "hello"), attr("d", "hello"), attr("e", "hello")], "hello" } ) + } + + fn expected_3() -> Element { + rsx!( div { ..vec![attr("d", "world")], "hello" } ) + } + + let (mut dom, mut oracle, _) = rebuild(app, expected_0); + assert_eq!(rerender(&mut dom, &mut oracle, expected_1).set_attrs, 2); + assert_eq!(rerender(&mut dom, &mut oracle, expected_2).set_attrs, 4); + assert_eq!(rerender(&mut dom, &mut oracle, expected_3).set_attrs, 3); +} + +#[test] +fn dynamic_attr_override_restores_static_attr() { + fn attr(name: &'static str, value: &'static str) -> Attribute { + Attribute::new(name, AttributeValue::Text(value.into()), None, false) + } + + fn app() -> Element { + let attrs = if generation() % 2 == 0 { + vec![attr("class", "active")] + } else { + vec![] + }; + + rsx! { + div { + class: "base", + ..attrs, + } + } + } + + fn expected_active() -> Element { + rsx! { div { class: "active" } } + } + + fn expected_base() -> Element { + rsx! { div { class: "base" } } + } + + let (mut dom, mut oracle, _) = rebuild(app, expected_active); + rerender(&mut dom, &mut oracle, expected_base); + rerender(&mut dom, &mut oracle, expected_active); +} + +#[test] +fn dynamic_attr_override_restores_raw_static_attr() { + fn attr(name: &'static str, value: &'static str) -> Attribute { + Attribute::new(name, AttributeValue::Text(value.into()), None, false) + } + + fn app() -> Element { + let attrs = if generation() % 2 == 0 { + vec![attr("as", "script")] + } else { + vec![] + }; + + rsx! { + link { + href: "/style.css", + r#as: "style", + ..attrs, + } + } + } + + fn expected_script() -> Element { + rsx! { link { href: "/style.css", r#as: "script" } } + } + + fn expected_style() -> Element { + rsx! { link { href: "/style.css", r#as: "style" } } + } + + let (mut dom, mut oracle, _) = rebuild(app, expected_script); + rerender(&mut dom, &mut oracle, expected_style); + rerender(&mut dom, &mut oracle, expected_script); +} + +#[test] +fn dynamic_attr_override_restores_aliased_static_attr() { + fn attr(name: &'static str, value: &'static str) -> Attribute { + Attribute::new(name, AttributeValue::Text(value.into()), None, false) + } + + fn app() -> Element { + let attrs = if generation() % 2 == 0 { + vec![attr("http-equiv", "refresh")] + } else { + vec![] + }; + + rsx! { + meta { + "http.z": "custom", + http_equiv: "content-type", + ..attrs, + } + } + } + + fn expected_refresh() -> Element { + rsx! { meta { "http.z": "custom", http_equiv: "refresh" } } + } + + fn expected_content_type() -> Element { + rsx! { meta { "http.z": "custom", http_equiv: "content-type" } } + } + + let (mut dom, mut oracle, _) = rebuild(app, expected_refresh); + rerender(&mut dom, &mut oracle, expected_content_type); + rerender(&mut dom, &mut oracle, expected_refresh); +} + +#[test] +fn dynamic_attr_none_removes_static_attr() { + fn app() -> Element { + let attrs = if generation() % 2 == 0 { + vec![Attribute::new("class", AttributeValue::None, None, false)] + } else { + vec![] + }; + + rsx! { + div { + class: "base", + ..attrs, + } + } + } + + fn expected_empty() -> Element { + rsx! { div {} } + } + + fn expected_base() -> Element { + rsx! { div { class: "base" } } + } + + let (mut dom, mut oracle, _) = rebuild(app, expected_empty); + rerender(&mut dom, &mut oracle, expected_base); + rerender(&mut dom, &mut oracle, expected_empty); +} + +#[test] +fn duplicate_dynamic_attr_slots_use_final_effective_attr() { + fn attr(value: &'static str) -> Attribute { + Attribute::new("class", AttributeValue::Text(value.into()), None, false) + } + + fn app() -> Element { + let generation = generation(); + let first = match generation { + 0..=2 => vec![attr("first")], + _ => vec![], + }; + let second = match generation { + 0..=1 => vec![attr("second")], + _ => vec![], + }; + + rsx! { + div { + ..first, + ..second, + } + } + } + + fn expected_second() -> Element { + rsx! { div { class: "second" } } + } + + fn expected_first() -> Element { + rsx! { div { class: "first" } } + } + + fn expected_empty() -> Element { + rsx! { div {} } + } + + let (mut dom, mut oracle, _) = rebuild(app, expected_second); + rerender(&mut dom, &mut oracle, expected_second); + rerender(&mut dom, &mut oracle, expected_first); + rerender(&mut dom, &mut oracle, expected_empty); } #[test] @@ -193,17 +312,36 @@ fn diff_empty() { } } - let mut vdom = VirtualDom::new(app); - vdom.rebuild(&mut NoOpMutations); + fn expected_div() -> Element { + rsx! { div { "hello" } } + } + + fn expected_empty() -> Element { + rsx! {} + } - vdom.mark_dirty(ScopeId::APP); - let edits = vdom.render_immediate_to_vec().edits; + let (mut dom, mut oracle, _) = rebuild(app, expected_div); + assert_eq!(rerender(&mut dom, &mut oracle, expected_empty).replaces, 1); +} + +fn rebuild( + app: fn() -> Element, + expected: fn() -> Element, +) -> (VirtualDom, RendererOracle, EditSummary) { + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + let summary = oracle.rebuild(&mut dom); + oracle.assert_matches(expected); + (dom, oracle, summary) +} - assert_eq!( - edits, - [ - CreatePlaceholder { id: ElementId(2,) }, - ReplaceWith { id: ElementId(1,), m: 1 }, - ] - ) +fn rerender( + dom: &mut VirtualDom, + oracle: &mut RendererOracle, + expected: fn() -> Element, +) -> EditSummary { + dom.mark_dirty(ScopeId::APP); + let summary = oracle.render(dom); + oracle.assert_matches(expected); + summary } diff --git a/packages/core/tests/diff_keyed_list.rs b/packages/core/tests/diff_keyed_list.rs index 4651029d8c..6bec823864 100644 --- a/packages/core/tests/diff_keyed_list.rs +++ b/packages/core/tests/diff_keyed_list.rs @@ -4,14 +4,16 @@ //! //! It does not validate that component lifecycles work properly. This is done in another test file. -use dioxus::dioxus_core::{ElementId, Mutation::*}; use dioxus::prelude::*; use dioxus_core::generation; +use dioxus_renderer_oracle::{ + EditSummary, OracleNodeId, RendererOracle, SnapshotAttr, SnapshotNode, +}; /// Should result in moves, but not removals or additions #[test] fn keyed_diffing_out_of_order() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order = match generation() % 2 { 0 => &[0, 1, 2, 3, /**/ 4, 5, 6, /**/ 7, 8, 9], 1 => &[0, 1, 2, 3, /**/ 6, 4, 5, /**/ 7, 8, 9], @@ -21,45 +23,21 @@ fn keyed_diffing_out_of_order() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - { - assert_eq!( - dom.rebuild_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1,) }, - LoadTemplate { index: 0, id: ElementId(2,) }, - LoadTemplate { index: 0, id: ElementId(3,) }, - LoadTemplate { index: 0, id: ElementId(4,) }, - LoadTemplate { index: 0, id: ElementId(5,) }, - LoadTemplate { index: 0, id: ElementId(6,) }, - LoadTemplate { index: 0, id: ElementId(7,) }, - LoadTemplate { index: 0, id: ElementId(8,) }, - LoadTemplate { index: 0, id: ElementId(9,) }, - LoadTemplate { index: 0, id: ElementId(10,) }, - AppendChildren { m: 10, id: ElementId(0) }, - ] - ); } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - PushRoot { id: ElementId(7,) }, - InsertBefore { id: ElementId(5,), m: 1 }, - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[0, 1, 2, 3, 6, 4, 5, 7, 8, 9]); + assert_move_only(summary); } /// Should result in moves only #[test] fn keyed_diffing_out_of_order_adds() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order = match generation() % 2 { 0 => &[/**/ 4, 5, 6, 7, 8 /**/], 1 => &[/**/ 8, 7, 4, 5, 6 /**/], @@ -69,29 +47,21 @@ fn keyed_diffing_out_of_order_adds() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - PushRoot { id: ElementId(5,) }, - PushRoot { id: ElementId(4,) }, - InsertBefore { id: ElementId(1,), m: 2 }, - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[4, 5, 6, 7, 8]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[8, 7, 4, 5, 6]); + assert_move_only(summary); } /// Should result in moves only #[test] fn keyed_diffing_out_of_order_adds_3() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order = match generation() % 2 { 0 => &[/**/ 4, 5, 6, 7, 8 /**/], 1 => &[/**/ 4, 8, 7, 5, 6 /**/], @@ -101,29 +71,21 @@ fn keyed_diffing_out_of_order_adds_3() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - PushRoot { id: ElementId(5,) }, - PushRoot { id: ElementId(4,) }, - InsertBefore { id: ElementId(2,), m: 2 }, - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[4, 5, 6, 7, 8]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[4, 8, 7, 5, 6]); + assert_move_only(summary); } /// Should result in moves only #[test] fn keyed_diffing_out_of_order_adds_4() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order = match generation() % 2 { 0 => &[/**/ 4, 5, 6, 7, 8 /**/], 1 => &[/**/ 4, 5, 8, 7, 6 /**/], @@ -133,29 +95,21 @@ fn keyed_diffing_out_of_order_adds_4() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - PushRoot { id: ElementId(5,) }, - PushRoot { id: ElementId(4,) }, - InsertBefore { id: ElementId(3,), m: 2 }, - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[4, 5, 6, 7, 8]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[4, 5, 8, 7, 6]); + assert_move_only(summary); } /// Should result in moves only #[test] fn keyed_diffing_out_of_order_adds_5() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order = match generation() % 2 { 0 => &[/**/ 4, 5, 6, 7, 8 /**/], 1 => &[/**/ 4, 5, 6, 8, 7 /**/], @@ -165,28 +119,21 @@ fn keyed_diffing_out_of_order_adds_5() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - PushRoot { id: ElementId(5,) }, - InsertBefore { id: ElementId(4,), m: 1 }, - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[4, 5, 6, 7, 8]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[4, 5, 6, 8, 7]); + assert_move_only(summary); } -/// Should result in moves only +/// Should add the new keyed nodes without recreating existing keyed nodes. #[test] fn keyed_diffing_additions() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order: &[_] = match generation() % 2 { 0 => &[/**/ 4, 5, 6, 7, 8 /**/], 1 => &[/**/ 4, 5, 6, 7, 8, 9, 10 /**/], @@ -196,28 +143,22 @@ fn keyed_diffing_additions() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(6) }, - LoadTemplate { index: 0, id: ElementId(7) }, - InsertAfter { id: ElementId(5), m: 2 } - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[4, 5, 6, 7, 8]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[4, 5, 6, 7, 8, 9, 10]); + assert_eq!(summary.loads, 2); + assert_eq!(summary.removes, 0); + assert_eq!(summary.replaces, 0); } #[test] fn keyed_diffing_additions_and_moves_on_ends() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order: &[_] = match generation() % 2 { 0 => &[/**/ 4, 5, 6, 7 /**/], 1 => &[/**/ 7, 4, 5, 6, 11, 12 /**/], @@ -227,32 +168,22 @@ fn keyed_diffing_additions_and_moves_on_ends() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - // create 11, 12 - LoadTemplate { index: 0, id: ElementId(5) }, - LoadTemplate { index: 0, id: ElementId(6) }, - InsertAfter { id: ElementId(3), m: 2 }, - // move 7 to the front - PushRoot { id: ElementId(4) }, - InsertBefore { id: ElementId(1), m: 1 } - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[4, 5, 6, 7]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[7, 4, 5, 6, 11, 12]); + assert_eq!(summary.loads, 2); + assert_eq!(summary.removes, 0); + assert_eq!(summary.replaces, 0); } #[test] fn keyed_diffing_additions_and_moves_in_middle() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order: &[_] = match generation() % 2 { 0 => &[/**/ 1, 2, 3, 4 /**/], 1 => &[/**/ 4, 1, 7, 8, 2, 5, 6, 3 /**/], @@ -262,37 +193,22 @@ fn keyed_diffing_additions_and_moves_in_middle() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - // LIS: 4, 5, 6 - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - // create 5, 6 - LoadTemplate { index: 0, id: ElementId(5) }, - LoadTemplate { index: 0, id: ElementId(6) }, - InsertBefore { id: ElementId(3), m: 2 }, - // create 7, 8 - LoadTemplate { index: 0, id: ElementId(7) }, - LoadTemplate { index: 0, id: ElementId(8) }, - InsertBefore { id: ElementId(2), m: 2 }, - // move 7 - PushRoot { id: ElementId(4) }, - InsertBefore { id: ElementId(1), m: 1 } - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[1, 2, 3, 4]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[4, 1, 7, 8, 2, 5, 6, 3]); + assert_eq!(summary.loads, 4); + assert_eq!(summary.removes, 0); + assert_eq!(summary.replaces, 0); } #[test] fn controlled_keyed_diffing_out_of_order() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order: &[_] = match generation() % 2 { 0 => &[4, 5, 6, 7], 1 => &[0, 5, 9, 6, 4], @@ -302,37 +218,22 @@ fn controlled_keyed_diffing_out_of_order() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - // LIS: 5, 6 - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - // remove 7 - Remove { id: ElementId(4,) }, - // move 4 to after 6 - PushRoot { id: ElementId(1) }, - InsertAfter { id: ElementId(3,), m: 1 }, - // create 9 and insert before 6 - LoadTemplate { index: 0, id: ElementId(4) }, - InsertBefore { id: ElementId(3,), m: 1 }, - // create 0 and insert before 5 - LoadTemplate { index: 0, id: ElementId(5) }, - InsertBefore { id: ElementId(2,), m: 1 }, - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[4, 5, 6, 7]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[0, 5, 9, 6, 4]); + assert_eq!(summary.loads, 2); + assert_eq!(summary.removes, 1); + assert_eq!(summary.replaces, 0); } #[test] fn controlled_keyed_diffing_out_of_order_max_test() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order: &[_] = match generation() % 2 { 0 => &[0, 1, 2, 3, 4], 1 => &[3, 0, 1, 10, 2], @@ -342,32 +243,24 @@ fn controlled_keyed_diffing_out_of_order_max_test() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - Remove { id: ElementId(5,) }, - LoadTemplate { index: 0, id: ElementId(5) }, - InsertBefore { id: ElementId(3,), m: 1 }, - PushRoot { id: ElementId(4) }, - InsertBefore { id: ElementId(1,), m: 1 }, - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[0, 1, 2, 3, 4]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[3, 0, 1, 10, 2]); + assert_eq!(summary.loads, 1); + assert_eq!(summary.removes, 1); + assert_eq!(summary.replaces, 0); } // noticed some weird behavior in the desktop interpreter // just making sure it doesnt happen in the core implementation #[test] fn remove_list() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order: &[_] = match generation() % 2 { 0 => &[9, 8, 7, 6, 5], 1 => &[9, 8], @@ -377,28 +270,22 @@ fn remove_list() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - Remove { id: ElementId(5) }, - Remove { id: ElementId(4) }, - Remove { id: ElementId(3) }, - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[9, 8, 7, 6, 5]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[9, 8]); + assert_eq!(summary.loads, 0); + assert_eq!(summary.removes, 3); + assert_eq!(summary.replaces, 0); } #[test] fn no_common_keys() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order: &[_] = match generation() % 2 { 0 => &[1, 2, 3], 1 => &[4, 5, 6], @@ -408,31 +295,22 @@ fn no_common_keys() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(4) }, - LoadTemplate { index: 0, id: ElementId(5) }, - LoadTemplate { index: 0, id: ElementId(6) }, - Remove { id: ElementId(3) }, - Remove { id: ElementId(2) }, - ReplaceWith { id: ElementId(1), m: 3 } - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[1, 2, 3]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[4, 5, 6]); + assert_eq!(summary.loads, 3); + assert_eq!(summary.removes, 2); + assert_eq!(summary.replaces, 1); } #[test] fn perfect_reverse() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order: &[_] = match generation() % 2 { 0 => &[1, 2, 3, 4, 5, 6, 7, 8], 1 => &[9, 8, 7, 6, 5, 4, 3, 2, 1, 0], @@ -442,37 +320,22 @@ fn perfect_reverse() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec().edits; - assert_eq!( - edits, - [ - LoadTemplate { index: 0, id: ElementId(9,) }, - InsertAfter { id: ElementId(1,), m: 1 }, - LoadTemplate { index: 0, id: ElementId(10,) }, - PushRoot { id: ElementId(8,) }, - PushRoot { id: ElementId(7,) }, - PushRoot { id: ElementId(6,) }, - PushRoot { id: ElementId(5,) }, - PushRoot { id: ElementId(4,) }, - PushRoot { id: ElementId(3,) }, - PushRoot { id: ElementId(2,) }, - InsertBefore { id: ElementId(1,), m: 8 }, - ] - ) + let (mut dom, mut oracle, _) = rebuild(app, &[1, 2, 3, 4, 5, 6, 7, 8]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]); + assert_eq!(summary.loads, 2); + assert_eq!(summary.removes, 0); + assert_eq!(summary.replaces, 0); } #[test] fn old_middle_empty_left_pivot() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order: &[_] = match generation() % 2 { 0 => &[/* */ /* */ 6, 7, 8, 9, 10], 1 => &[/* */ 4, 5, /* */ 6, 7, 8, 9, 10], @@ -482,29 +345,22 @@ fn old_middle_empty_left_pivot() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec().edits; - assert_eq!( - edits, - [ - LoadTemplate { index: 0, id: ElementId(6,) }, - LoadTemplate { index: 0, id: ElementId(7,) }, - InsertBefore { id: ElementId(1,), m: 2 }, - ] - ) + let (mut dom, mut oracle, _) = rebuild(app, &[6, 7, 8, 9, 10]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[4, 5, 6, 7, 8, 9, 10]); + assert_eq!(summary.loads, 2); + assert_eq!(summary.removes, 0); + assert_eq!(summary.replaces, 0); } #[test] fn old_middle_empty_right_pivot() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let order: &[_] = match generation() % 2 { 0 => &[1, 2, 3, /* */ 6, 7, 8, 9, 10], 1 => &[1, 2, 3, /* */ 4, 5, 6, 7, 8, 9, 10 /* */], @@ -517,33 +373,24 @@ fn old_middle_empty_right_pivot() { rsx!({ order.iter().map(|i| { rsx! { - div { key: "{i}" } + div { key: "{i}", id: "{i}" } } }) }) - }); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + } - dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec().edits; - assert_eq!( - edits, - [ - LoadTemplate { index: 0, id: ElementId(9) }, - LoadTemplate { index: 0, id: ElementId(10) }, - InsertBefore { id: ElementId(4), m: 2 }, - ] - ); + let (mut dom, mut oracle, _) = rebuild(app, &[1, 2, 3, 6, 7, 8, 9, 10]); + let (summary, _) = rerender(&mut dom, &mut oracle, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert_eq!(summary.loads, 2); + assert_eq!(summary.removes, 0); + assert_eq!(summary.replaces, 0); } /// Regression test for PR #5413 #[test] fn keyed_list_with_dynamic_placeholder_and_text() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let g = generation(); - let text = if g % 2 != 0 { Some("hello") } else { None }; - println!("{:?}", text); let order: &[_] = match g % 2 { 0 => &[0, 1], @@ -558,7 +405,7 @@ fn keyed_list_with_dynamic_placeholder_and_text() { } }) }) - }); + } #[component] fn iter_view(id: i32) -> Element { @@ -568,21 +415,91 @@ fn keyed_list_with_dynamic_placeholder_and_text() { } } + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + let rebuild = oracle.rebuild(&mut dom); assert_eq!( - dom.rebuild_to_vec().edits, - [ - CreateTextNode { value: "hey".to_string(), id: ElementId(1,) }, - CreatePlaceholder { id: ElementId(2,) }, - AppendChildren { id: ElementId(0,), m: 2 } - ] + oracle.snapshot(), + vec![SnapshotNode::Text("hey".to_string())] ); + assert_eq!(rebuild.loads, 0); dom.mark_dirty(ScopeId::APP); + let patch = oracle.render(&mut dom); assert_eq!( - dom.render_immediate_to_vec().edits, - [ - PushRoot { id: ElementId(2,) }, - InsertBefore { id: ElementId(1,), m: 1 } - ] + oracle.snapshot(), + vec![SnapshotNode::Text("hey".to_string())] ); + assert_eq!(patch, EditSummary::default()); +} + +fn rebuild( + app: fn() -> Element, + expected_order: &[i32], +) -> (VirtualDom, RendererOracle, Vec<(String, OracleNodeId)>) { + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + assert_keyed_order(&oracle, expected_order); + let identities = oracle.identities_by_attr("id"); + (dom, oracle, identities) +} + +fn rerender( + dom: &mut VirtualDom, + oracle: &mut RendererOracle, + expected_order: &[i32], +) -> (EditSummary, Vec<(String, OracleNodeId)>) { + let previous = oracle.identities_by_attr("id"); + dom.mark_dirty(ScopeId::APP); + let summary = oracle.render(dom); + assert_keyed_order(oracle, expected_order); + let current = oracle.identities_by_attr("id"); + assert_common_identities_preserved(&previous, ¤t); + (summary, current) +} + +fn assert_move_only(summary: EditSummary) { + assert_eq!(summary.loads, 0); + assert_eq!(summary.removes, 0); + assert_eq!(summary.replaces, 0); +} + +fn assert_keyed_order(oracle: &RendererOracle, expected: &[i32]) { + assert_eq!(oracle.snapshot(), keyed_divs(expected)); +} + +fn keyed_divs(ids: &[i32]) -> Vec { + ids.iter().map(|id| keyed_div(*id)).collect() +} + +fn keyed_div(id: i32) -> SnapshotNode { + SnapshotNode::Element { + tag: "div".to_string(), + namespace: None, + attrs: vec![SnapshotAttr { + name: "id".to_string(), + namespace: None, + value: id.to_string(), + }], + listeners: Vec::new(), + children: Vec::new(), + } +} + +fn assert_common_identities_preserved( + previous: &[(String, OracleNodeId)], + current: &[(String, OracleNodeId)], +) { + for (value, previous_id) in previous { + if let Some((_, current_id)) = current + .iter() + .find(|(current_value, _)| current_value == value) + { + assert_eq!( + previous_id, current_id, + "node identity for `id={value}` was not preserved" + ); + } + } } diff --git a/packages/core/tests/diff_unkeyed_list.rs b/packages/core/tests/diff_unkeyed_list.rs index 8496378848..4daccd1fd4 100644 --- a/packages/core/tests/diff_unkeyed_list.rs +++ b/packages/core/tests/diff_unkeyed_list.rs @@ -1,13 +1,10 @@ -use std::collections::HashSet; - -use dioxus::dioxus_core::{ElementId, Mutation::*}; use dioxus::prelude::*; -use dioxus_core::{Mutation, generation}; -use pretty_assertions::assert_eq; +use dioxus_core::generation; +use dioxus_renderer_oracle::{EditSummary, RendererOracle, SnapshotNode}; #[test] fn list_creates_one_by_one() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let g = generation(); rsx! { @@ -17,71 +14,31 @@ fn list_creates_one_by_one() { } } } - }); - - // load the div and then assign the empty fragment as a placeholder - assert_eq!( - dom.rebuild_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1,) }, - CreatePlaceholder { id: ElementId(2,) }, - ReplacePlaceholder { path: &[0], m: 1 }, - AppendChildren { id: ElementId(0), m: 1 }, - ] - ); - - // Rendering the first item should replace the placeholder with an element - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(3,) }, - CreateTextNode { value: "0".to_string(), id: ElementId(4,) }, - ReplacePlaceholder { path: &[0], m: 1 }, - ReplaceWith { id: ElementId(2,), m: 1 }, - ] - ); - - // Rendering the next item should insert after the previous - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(2,) }, - CreateTextNode { value: "1".to_string(), id: ElementId(5,) }, - ReplacePlaceholder { path: &[0], m: 1 }, - InsertAfter { id: ElementId(3,), m: 1 }, - ] - ); - - // ... and again! - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(6,) }, - CreateTextNode { value: "2".to_string(), id: ElementId(7,) }, - ReplacePlaceholder { path: &[0], m: 1 }, - InsertAfter { id: ElementId(2,), m: 1 }, - ] - ); - - // once more - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(8,) }, - CreateTextNode { value: "3".to_string(), id: ElementId(9,) }, - ReplacePlaceholder { path: &[0], m: 1 }, - InsertAfter { id: ElementId(6,), m: 1 }, - ] - ); + } + + let (mut dom, mut oracle, rebuild) = rebuild(app, numbered_outer_div(0, 1)); + assert_eq!(rebuild.loads, 1); + + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(1, 1)); + assert_eq!(summary.loads, 1); + assert_eq!(summary.replaces, 1); + + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(2, 1)); + assert_eq!(summary.loads, 1); + assert_eq!(summary.replaces, 0); + + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(3, 1)); + assert_eq!(summary.loads, 1); + assert_eq!(summary.replaces, 0); + + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(4, 1)); + assert_eq!(summary.loads, 1); + assert_eq!(summary.replaces, 0); } #[test] fn removes_one_by_one() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let g = 3 - generation() % 4; rsx! { @@ -91,80 +48,31 @@ fn removes_one_by_one() { } } } - }); - - // load the div and then assign the empty fragment as a placeholder - assert_eq!( - dom.rebuild_to_vec().edits, - [ - // The container - LoadTemplate { index: 0, id: ElementId(1) }, - // each list item - LoadTemplate { index: 0, id: ElementId(2) }, - CreateTextNode { value: "0".to_string(), id: ElementId(3) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 0, id: ElementId(4) }, - CreateTextNode { value: "1".to_string(), id: ElementId(5) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 0, id: ElementId(6) }, - CreateTextNode { value: "2".to_string(), id: ElementId(7) }, - ReplacePlaceholder { path: &[0], m: 1 }, - // replace the placeholder in the template with the 3 templates on the stack - ReplacePlaceholder { m: 3, path: &[0] }, - // Mount the div - AppendChildren { id: ElementId(0), m: 1 } - ] - ); - - // Remove div(3) - // Rendering the first item should replace the placeholder with an element - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [Remove { id: ElementId(6) }] - ); + } - // Remove div(2) - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [Remove { id: ElementId(4) }] - ); + let (mut dom, mut oracle, rebuild) = rebuild(app, numbered_outer_div(3, 1)); + assert_eq!(rebuild.loads, 4); - // Remove div(1) and replace with a placeholder - // todo: this should just be a remove with no placeholder - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - CreatePlaceholder { id: ElementId(4) }, - ReplaceWith { id: ElementId(2), m: 1 } - ] - ); - - // load the 3 and replace the placeholder - // todo: this should actually be append to, but replace placeholder is fine for now - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(2) }, - CreateTextNode { value: "0".to_string(), id: ElementId(3) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 0, id: ElementId(5) }, - CreateTextNode { value: "1".to_string(), id: ElementId(6) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 0, id: ElementId(7) }, - CreateTextNode { value: "2".to_string(), id: ElementId(8) }, - ReplacePlaceholder { path: &[0], m: 1 }, - ReplaceWith { id: ElementId(4), m: 3 } - ] - ); + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(2, 1)); + assert_eq!(summary.removes, 1); + assert_eq!(summary.replaces, 0); + + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(1, 1)); + assert_eq!(summary.removes, 1); + assert_eq!(summary.replaces, 0); + + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(0, 1)); + assert_eq!(summary.removes, 0); + assert_eq!(summary.replaces, 1); + + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(3, 1)); + assert_eq!(summary.loads, 3); + assert_eq!(summary.replaces, 1); } #[test] fn list_shrink_multiroot() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { rsx! { div { for i in 0..generation() { @@ -173,64 +81,27 @@ fn list_shrink_multiroot() { } } } - }); - - assert_eq!( - dom.rebuild_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1,) }, - CreatePlaceholder { id: ElementId(2,) }, - ReplacePlaceholder { path: &[0,], m: 1 }, - AppendChildren { id: ElementId(0), m: 1 } - ] - ); + } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(3) }, - CreateTextNode { value: "0".to_string(), id: ElementId(4) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 1, id: ElementId(5) }, - CreateTextNode { value: "0".to_string(), id: ElementId(6) }, - ReplacePlaceholder { path: &[0], m: 1 }, - ReplaceWith { id: ElementId(2), m: 2 } - ] - ); + let (mut dom, mut oracle, rebuild) = rebuild(app, numbered_outer_div(0, 2)); + assert_eq!(rebuild.loads, 1); - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(2) }, - CreateTextNode { value: "1".to_string(), id: ElementId(7) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 1, id: ElementId(8) }, - CreateTextNode { value: "1".to_string(), id: ElementId(9) }, - ReplacePlaceholder { path: &[0], m: 1 }, - InsertAfter { id: ElementId(5), m: 2 } - ] - ); + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(1, 2)); + assert_eq!(summary.loads, 2); + assert_eq!(summary.replaces, 1); - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(10) }, - CreateTextNode { value: "2".to_string(), id: ElementId(11) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 1, id: ElementId(12) }, - CreateTextNode { value: "2".to_string(), id: ElementId(13) }, - ReplacePlaceholder { path: &[0], m: 1 }, - InsertAfter { id: ElementId(8), m: 2 } - ] - ); + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(2, 2)); + assert_eq!(summary.loads, 2); + assert_eq!(summary.replaces, 0); + + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(3, 2)); + assert_eq!(summary.loads, 2); + assert_eq!(summary.replaces, 0); } #[test] fn removes_one_by_one_multiroot() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let g = 3 - generation() % 4; rsx! { @@ -241,91 +112,57 @@ fn removes_one_by_one_multiroot() { })} } } - }); - - // load the div and then assign the empty fragment as a placeholder - assert_eq!( - dom.rebuild_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(1) }, - // - LoadTemplate { index: 0, id: ElementId(2) }, - CreateTextNode { value: "0".to_string(), id: ElementId(3) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 1, id: ElementId(4) }, - CreateTextNode { value: "0".to_string(), id: ElementId(5) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 0, id: ElementId(6) }, - CreateTextNode { value: "1".to_string(), id: ElementId(7) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 1, id: ElementId(8) }, - CreateTextNode { value: "1".to_string(), id: ElementId(9) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 0, id: ElementId(10) }, - CreateTextNode { value: "2".to_string(), id: ElementId(11) }, - ReplacePlaceholder { path: &[0], m: 1 }, - LoadTemplate { index: 1, id: ElementId(12) }, - CreateTextNode { value: "2".to_string(), id: ElementId(13) }, - ReplacePlaceholder { path: &[0], m: 1 }, - ReplacePlaceholder { path: &[0], m: 6 }, - AppendChildren { id: ElementId(0), m: 1 } - ] - ); + } - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [Remove { id: ElementId(10) }, Remove { id: ElementId(12) }] - ); + let (mut dom, mut oracle, rebuild) = rebuild(app, numbered_outer_div(3, 2)); + assert_eq!(rebuild.loads, 7); - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [Remove { id: ElementId(6) }, Remove { id: ElementId(8) }] - ); + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(2, 2)); + assert_eq!(summary.removes, 2); + assert_eq!(summary.replaces, 0); - dom.mark_dirty(ScopeId::APP); - assert_eq!( - dom.render_immediate_to_vec().edits, - [ - CreatePlaceholder { id: ElementId(8) }, - Remove { id: ElementId(2) }, - ReplaceWith { id: ElementId(4), m: 1 } - ] - ); + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(1, 2)); + assert_eq!(summary.removes, 2); + assert_eq!(summary.replaces, 0); + + let summary = rerender(&mut dom, &mut oracle, numbered_outer_div(0, 2)); + assert_eq!(summary.removes, 1); + assert_eq!(summary.replaces, 1); } #[test] fn two_equal_fragments_are_equal_static() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { rsx! { for _ in 0..5 { div { "hello" } } } - }); + } - dom.rebuild(&mut dioxus_core::NoOpMutations); - assert!(dom.render_immediate_to_vec().edits.is_empty()); + let (mut dom, mut oracle, _) = rebuild(app, repeated_text_divs("hello", 5)); + let summary = rerender(&mut dom, &mut oracle, repeated_text_divs("hello", 5)); + assert_eq!(summary, EditSummary::default()); } #[test] fn two_equal_fragments_are_equal() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { rsx! { for i in 0..5 { div { "hello {i}" } } } - }); + } - dom.rebuild(&mut dioxus_core::NoOpMutations); - assert!(dom.render_immediate_to_vec().edits.is_empty()); + let (mut dom, mut oracle, _) = rebuild(app, hello_divs(5)); + let summary = rerender(&mut dom, &mut oracle, hello_divs(5)); + assert_eq!(summary, EditSummary::default()); } #[test] fn remove_many() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let num = match generation() % 3 { 0 => 0, 1 => 1, @@ -338,99 +175,31 @@ fn remove_many() { div { "hello {i}" } } } - }); - - // len = 0 - { - let edits = dom.rebuild_to_vec(); - assert_eq!( - edits.edits, - [ - CreatePlaceholder { id: ElementId(1,) }, - AppendChildren { id: ElementId(0), m: 1 }, - ] - ); } - // len = 1 - { - dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec(); - assert_eq!( - edits.edits, - [ - LoadTemplate { index: 0, id: ElementId(2,) }, - CreateTextNode { value: "hello 0".to_string(), id: ElementId(3,) }, - ReplacePlaceholder { path: &[0,], m: 1 }, - ReplaceWith { id: ElementId(1,), m: 1 }, - ] - ); - } + let (mut dom, mut oracle, rebuild) = rebuild(app, Vec::new()); + assert_eq!(rebuild, EditSummary::default()); - // len = 5 - { - dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec(); - assert_eq!( - edits.edits, - [ - LoadTemplate { index: 0, id: ElementId(1,) }, - CreateTextNode { value: "hello 1".to_string(), id: ElementId(4,) }, - ReplacePlaceholder { path: &[0,], m: 1 }, - LoadTemplate { index: 0, id: ElementId(5,) }, - CreateTextNode { value: "hello 2".to_string(), id: ElementId(6,) }, - ReplacePlaceholder { path: &[0,], m: 1 }, - LoadTemplate { index: 0, id: ElementId(7,) }, - CreateTextNode { value: "hello 3".to_string(), id: ElementId(8,) }, - ReplacePlaceholder { path: &[0,], m: 1 }, - LoadTemplate { index: 0, id: ElementId(9,) }, - CreateTextNode { value: "hello 4".to_string(), id: ElementId(10,) }, - ReplacePlaceholder { path: &[0,], m: 1 }, - InsertAfter { id: ElementId(2,), m: 4 }, - ] - ); - } + let summary = rerender(&mut dom, &mut oracle, hello_divs(1)); + assert_eq!(summary.loads, 1); + assert_eq!(summary.replaces, 1); - // len = 0 - { - dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec(); - assert_eq!(edits.edits[0], CreatePlaceholder { id: ElementId(11,) }); - let removed = edits.edits[1..5] - .iter() - .map(|edit| match edit { - Mutation::Remove { id } => *id, - _ => panic!("Expected remove"), - }) - .collect::>(); - assert_eq!( - removed, - [ElementId(7), ElementId(5), ElementId(2), ElementId(1)] - .into_iter() - .collect::>() - ); - assert_eq!(edits.edits[5..], [ReplaceWith { id: ElementId(9,), m: 1 },]); - } + let summary = rerender(&mut dom, &mut oracle, hello_divs(5)); + assert_eq!(summary.loads, 4); + assert_eq!(summary.replaces, 0); - // len = 1 - { - dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec(); - assert_eq!( - edits.edits, - [ - LoadTemplate { index: 0, id: ElementId(9,) }, - CreateTextNode { value: "hello 0".to_string(), id: ElementId(10,) }, - ReplacePlaceholder { path: &[0,], m: 1 }, - ReplaceWith { id: ElementId(11,), m: 1 }, - ] - ) - } + let summary = rerender(&mut dom, &mut oracle, Vec::new()); + assert_eq!(summary.removes, 4); + assert_eq!(summary.replaces, 1); + + let summary = rerender(&mut dom, &mut oracle, hello_divs(1)); + assert_eq!(summary.loads, 1); + assert_eq!(summary.replaces, 1); } #[test] fn replace_and_add_items() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let items = (0..generation()).map(|_| { if generation() % 2 == 0 { VNode::empty() @@ -448,72 +217,28 @@ fn replace_and_add_items() { {items} } } - }); - - // The list starts empty with a placeholder - { - let edits = dom.rebuild_to_vec(); - assert_eq!( - edits.edits, - [ - LoadTemplate { index: 0, id: ElementId(1,) }, - CreatePlaceholder { id: ElementId(2,) }, - ReplacePlaceholder { path: &[0], m: 1 }, - AppendChildren { id: ElementId(0), m: 1 }, - ] - ); } - // Rerendering adds a static template - { - dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec(); - assert_eq!( - edits.edits, - [ - LoadTemplate { index: 0, id: ElementId(3,) }, - ReplaceWith { id: ElementId(2,), m: 1 }, - ] - ); - } + let (mut dom, mut oracle, rebuild) = rebuild(app, vec![ul(Vec::new())]); + assert_eq!(rebuild.loads, 1); - // Rerendering replaces the old node with a placeholder and adds a new placeholder - { - dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec(); - assert_eq!( - edits.edits, - [ - CreatePlaceholder { id: ElementId(2,) }, - InsertAfter { id: ElementId(3,), m: 1 }, - CreatePlaceholder { id: ElementId(4,) }, - ReplaceWith { id: ElementId(3,), m: 1 }, - ] - ); - } + let summary = rerender(&mut dom, &mut oracle, vec![ul(fizz_items(1))]); + assert_eq!(summary.loads, 1); + assert_eq!(summary.replaces, 1); - // Rerendering replaces both placeholders with the static nodes and add a new static node - { - dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec(); - assert_eq!( - edits.edits, - [ - LoadTemplate { index: 0, id: ElementId(3,) }, - InsertAfter { id: ElementId(2,), m: 1 }, - LoadTemplate { index: 0, id: ElementId(5,) }, - ReplaceWith { id: ElementId(4,), m: 1 }, - LoadTemplate { index: 0, id: ElementId(4,) }, - ReplaceWith { id: ElementId(2,), m: 1 }, - ] - ); - } + let summary = rerender(&mut dom, &mut oracle, vec![ul(Vec::new())]); + assert_eq!(summary.loads, 0); + assert_eq!(summary.replaces, 1); + + let summary = rerender(&mut dom, &mut oracle, vec![ul(fizz_items(3))]); + assert_eq!(summary.loads, 3); + assert_eq!(summary.replaces, 2); } // Simplified regression test for https://github.com/DioxusLabs/dioxus/issues/4924 #[test] fn nested_unkeyed_lists() { - let mut dom = VirtualDom::new(|| { + fn app() -> Element { let content = if generation() % 2 == 0 { vec!["5\n6"] } else { @@ -527,65 +252,96 @@ fn nested_unkeyed_lists() { } } } - }); - - // The list starts with one placeholder - { - let edits = dom.rebuild_to_vec(); - assert_eq!( - edits.edits, - [ - // load the p tag template - LoadTemplate { index: 0, id: ElementId(1) }, - // Create the first text node - CreateTextNode { value: "5".into(), id: ElementId(2) }, - // Replace the placeholder inside the p tag with the text node - ReplacePlaceholder { path: &[0], m: 1 }, - // load the p tag template - LoadTemplate { index: 0, id: ElementId(3) }, - // Create the second text node - CreateTextNode { value: "6".into(), id: ElementId(4) }, - // Replace the placeholder inside the p tag with the text node - ReplacePlaceholder { path: &[0], m: 1 }, - // Add the text nodes to the root node - AppendChildren { id: ElementId(0), m: 2 } - ] - ); } - // DOM state: - //
 # Id 1 for if statement
-    // 

# Id 2 - // "5" # Id 3 - //

# Id 4 - // "6" # Id 5 - // - // The diffing engine should add two new elements to the end and modify the first two elements in place - { - dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec(); - assert_eq!( - edits.edits, - [ - // load the p tag template - LoadTemplate { index: 0, id: ElementId(5) }, - // Create the third text node - CreateTextNode { value: "3".into(), id: ElementId(6) }, - // Replace the placeholder inside the p tag with the text node - ReplacePlaceholder { path: &[0], m: 1 }, - // load the p tag template - LoadTemplate { index: 0, id: ElementId(7) }, - // Create the fourth text node - CreateTextNode { value: "4".into(), id: ElementId(8) }, - // Replace the placeholder inside the p tag with the text node - ReplacePlaceholder { path: &[0], m: 1 }, - // Insert the text nodes after the second p tag - InsertAfter { id: ElementId(3), m: 2 }, - // Set the first text node to "1" - SetText { value: "1".into(), id: ElementId(2) }, - // Set the second text node to "2" - SetText { value: "2".into(), id: ElementId(4) } - ] - ); + let (mut dom, mut oracle, rebuild) = rebuild(app, paragraphs(&["5", "6"])); + assert_eq!(rebuild.loads, 2); + + let summary = rerender(&mut dom, &mut oracle, paragraphs(&["1", "2", "3", "4"])); + assert_eq!(summary.loads, 2); + assert_eq!(summary.set_texts, 2); +} + +fn rebuild( + app: fn() -> Element, + expected: Vec, +) -> (VirtualDom, RendererOracle, EditSummary) { + let mut dom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + let summary = oracle.rebuild(&mut dom); + assert_eq!(oracle.snapshot(), expected); + (dom, oracle, summary) +} + +fn rerender( + dom: &mut VirtualDom, + oracle: &mut RendererOracle, + expected: Vec, +) -> EditSummary { + dom.mark_dirty(ScopeId::APP); + let summary = oracle.render(dom); + assert_eq!(oracle.snapshot(), expected); + summary +} + +fn numbered_outer_div(count: usize, copies: usize) -> Vec { + vec![div(numbered_children(count, copies))] +} + +fn numbered_children(count: usize, copies: usize) -> Vec { + let mut children = Vec::new(); + for i in 0..count { + for _ in 0..copies { + children.push(div(vec![text(i.to_string())])); + } + } + children +} + +fn repeated_text_divs(value: &str, count: usize) -> Vec { + (0..count).map(|_| div(vec![text(value)])).collect() +} + +fn hello_divs(count: usize) -> Vec { + (0..count) + .map(|i| div(vec![text(format!("hello {i}"))])) + .collect() +} + +fn fizz_items(count: usize) -> Vec { + (0..count).map(|_| li(vec![text("Fizz")])).collect() +} + +fn paragraphs(lines: &[&str]) -> Vec { + lines.iter().map(|line| p(vec![text(*line)])).collect() +} + +fn div(children: Vec) -> SnapshotNode { + element("div", children) +} + +fn ul(children: Vec) -> SnapshotNode { + element("ul", children) +} + +fn li(children: Vec) -> SnapshotNode { + element("li", children) +} + +fn p(children: Vec) -> SnapshotNode { + element("p", children) +} + +fn element(tag: &str, children: Vec) -> SnapshotNode { + SnapshotNode::Element { + tag: tag.to_string(), + namespace: None, + attrs: Vec::new(), + listeners: Vec::new(), + children, } } + +fn text(value: impl Into) -> SnapshotNode { + SnapshotNode::Text(value.into()) +} diff --git a/packages/core/tests/event_propagation.rs b/packages/core/tests/event_propagation.rs index a480d49900..48d1d5aa12 100644 --- a/packages/core/tests/event_propagation.rs +++ b/packages/core/tests/event_propagation.rs @@ -1,79 +1,76 @@ use dioxus::prelude::*; -use dioxus_core::ElementId; +use dioxus_core::ScopeId; +use dioxus_renderer_oracle::RendererOracle; use std::{any::Any, rc::Rc, sync::Mutex}; static CLICKS: Mutex = Mutex::new(0); +fn click_event() -> Event { + Event::new( + Rc::new(PlatformEventData::new(Box::::default())) as Rc, + true, + ) +} + #[test] fn events_propagate() { set_event_converter(Box::new(dioxus::html::SerializedHtmlEventConverter)); + *CLICKS.lock().unwrap() = 0; + + fn app() -> Element { + rsx! { + div { onclick: move |_| { + println!("top clicked"); + *CLICKS.lock().unwrap() += 1; + }, + {vec![ + rsx! { + problematic_child {} + } + ].into_iter()} + } + } + } + + fn problematic_child() -> Element { + rsx! { + button { onclick: move |evt| { + println!("bottom clicked"); + let mut clicks = CLICKS.lock().unwrap(); + if *clicks == 3 { + evt.stop_propagation(); + } else { + *clicks += 1; + } + } } + } + } let mut dom = VirtualDom::new(app); - dom.rebuild(&mut dioxus_core::NoOpMutations); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); - // Top-level click is registered - let event = Event::new( - Rc::new(PlatformEventData::new(Box::::default())) as Rc, - true, - ); - dom.runtime().handle_event("click", event, ElementId(1)); + // 1. A click on the top-level div fires the outer handler, so CLICKS = 1. + let target = oracle.element_id_by_tag("div"); + dom.runtime().handle_event("click", click_event(), target); assert_eq!(*CLICKS.lock().unwrap(), 1); - // break reference.... - for _ in 0..5 { - dom.mark_dirty(ScopeId(0)); - _ = dom.render_immediate_to_vec(); - } + dom.mark_dirty(ScopeId::APP); + oracle.render(&mut dom); - // Lower click is registered - let event = Event::new( - Rc::new(PlatformEventData::new(Box::::default())) as Rc, - true, - ); - dom.runtime().handle_event("click", event, ElementId(2)); + // 2. A click on the inner button propagates to the outer div, so CLICKS = 3. + let target = oracle.element_id_by_tag("button"); + dom.runtime().handle_event("click", click_event(), target); assert_eq!(*CLICKS.lock().unwrap(), 3); - // break reference.... - for _ in 0..5 { - dom.mark_dirty(ScopeId(0)); - _ = dom.render_immediate_to_vec(); - } + dom.mark_dirty(ScopeId::APP); + oracle.render(&mut dom); - // Stop propagation occurs - let event = Event::new( - Rc::new(PlatformEventData::new(Box::::default())) as Rc, - true, - ); - dom.runtime().handle_event("click", event, ElementId(2)); + // 3. Stop-propagation in the button blocks the outer handler, so CLICKS stays at 3. + let target = oracle.element_id_by_tag("button"); + dom.runtime().handle_event("click", click_event(), target); assert_eq!(*CLICKS.lock().unwrap(), 3); -} -fn app() -> Element { - rsx! { - div { onclick: move |_| { - println!("top clicked"); - *CLICKS.lock().unwrap() += 1; - }, - - {vec![ - rsx! { - problematic_child {} - } - ].into_iter()} - } - } -} - -fn problematic_child() -> Element { - rsx! { - button { onclick: move |evt| { - println!("bottom clicked"); - let mut clicks = CLICKS.lock().unwrap(); - if *clicks == 3 { - evt.stop_propagation(); - } else { - *clicks += 1; - } - } } - } + dom.mark_dirty(ScopeId::APP); + oracle.render(&mut dom); } diff --git a/packages/core/tests/fuzzing.rs b/packages/core/tests/fuzzing.rs deleted file mode 100644 index 20af1d93ee..0000000000 --- a/packages/core/tests/fuzzing.rs +++ /dev/null @@ -1,369 +0,0 @@ -#![cfg(not(miri))] - -use dioxus::prelude::*; -use dioxus_core::{AttributeValue, DynamicNode, NoOpMutations, Template, VComponent, VNode, *}; -use std::{any::Any, cell::RefCell, cfg, collections::HashSet, default::Default, rc::Rc}; - -fn random_ns() -> Option<&'static str> { - let namespace = rand::random::() % 2; - match namespace { - 0 => None, - 1 => Some(Box::leak( - format!("ns{}", rand::random::()).into_boxed_str(), - )), - _ => unreachable!(), - } -} - -fn create_random_attribute(attr_idx: &mut usize) -> TemplateAttribute { - match rand::random::() % 2 { - 0 => TemplateAttribute::Static { - name: Box::leak(format!("attr{}", rand::random::()).into_boxed_str()), - value: Box::leak(format!("value{}", rand::random::()).into_boxed_str()), - namespace: random_ns(), - }, - 1 => TemplateAttribute::Dynamic { - id: { - let old_idx = *attr_idx; - *attr_idx += 1; - old_idx - }, - }, - _ => unreachable!(), - } -} - -fn create_random_template_node( - dynamic_node_types: &mut Vec, - template_idx: &mut usize, - attr_idx: &mut usize, - depth: usize, -) -> TemplateNode { - match rand::random::() % 4 { - 0 => { - let attrs = { - let attrs: Vec<_> = (0..(rand::random::() % 10)) - .map(|_| create_random_attribute(attr_idx)) - .collect(); - Box::leak(attrs.into_boxed_slice()) - }; - TemplateNode::Element { - tag: Box::leak(format!("tag{}", rand::random::()).into_boxed_str()), - namespace: random_ns(), - attrs, - children: { - if depth > 4 { - &[] - } else { - let children: Vec<_> = (0..(rand::random::() % 3)) - .map(|_| { - create_random_template_node( - dynamic_node_types, - template_idx, - attr_idx, - depth + 1, - ) - }) - .collect(); - Box::leak(children.into_boxed_slice()) - } - }, - } - } - 1 => TemplateNode::Text { - text: Box::leak(format!("{}", rand::random::()).into_boxed_str()), - }, - 2 => TemplateNode::Dynamic { - id: { - let old_idx = *template_idx; - *template_idx += 1; - dynamic_node_types.push(DynamicNodeType::Text); - old_idx - }, - }, - 3 => TemplateNode::Dynamic { - id: { - let old_idx = *template_idx; - *template_idx += 1; - dynamic_node_types.push(DynamicNodeType::Other); - old_idx - }, - }, - _ => unreachable!(), - } -} - -fn generate_paths( - node: &TemplateNode, - current_path: &[u8], - node_paths: &mut Vec>, - attr_paths: &mut Vec>, -) { - match node { - TemplateNode::Element { children, attrs, .. } => { - for attr in *attrs { - match attr { - TemplateAttribute::Static { .. } => {} - TemplateAttribute::Dynamic { .. } => { - attr_paths.push(current_path.to_vec()); - } - } - } - for (i, child) in children.iter().enumerate() { - let mut current_path = current_path.to_vec(); - current_path.push(i as u8); - generate_paths(child, ¤t_path, node_paths, attr_paths); - } - } - TemplateNode::Text { .. } => {} - TemplateNode::Dynamic { .. } => { - node_paths.push(current_path.to_vec()); - } - } -} - -enum DynamicNodeType { - Text, - Other, -} - -fn create_random_template(depth: u8) -> (Template, Box<[DynamicNode]>) { - let mut dynamic_node_types = Vec::new(); - let mut template_idx = 0; - let mut attr_idx = 0; - let roots = (0..(1 + rand::random::() % 5)) - .map(|_| { - create_random_template_node( - &mut dynamic_node_types, - &mut template_idx, - &mut attr_idx, - 0, - ) - }) - .collect::>(); - assert!(!roots.is_empty()); - let roots = Box::leak(roots.into_boxed_slice()); - let mut node_paths = Vec::new(); - let mut attr_paths = Vec::new(); - for (i, root) in roots.iter().enumerate() { - generate_paths(root, &[i as u8], &mut node_paths, &mut attr_paths); - } - let node_paths = Box::leak( - node_paths - .into_iter() - .map(|v| &*Box::leak(v.into_boxed_slice())) - .collect::>() - .into_boxed_slice(), - ); - let attr_paths = Box::leak( - attr_paths - .into_iter() - .map(|v| &*Box::leak(v.into_boxed_slice())) - .collect::>() - .into_boxed_slice(), - ); - let dynamic_nodes = dynamic_node_types - .iter() - .map(|ty| match ty { - DynamicNodeType::Text => { - DynamicNode::Text(VText::new(format!("{}", rand::random::()))) - } - DynamicNodeType::Other => create_random_dynamic_node(depth + 1), - }) - .collect(); - (Template::new(roots, node_paths, attr_paths), dynamic_nodes) -} - -fn create_random_dynamic_node(depth: u8) -> DynamicNode { - let range = if depth > 5 { 1 } else { 3 }; - match rand::random::() % range { - 0 => DynamicNode::Placeholder(Default::default()), - 1 => (0..(rand::random::() % 5)) - .map(|_| { - VNode::new( - None, - Template::new(&[TemplateNode::Dynamic { id: 0 }], &[&[0]], &[]), - Box::new([DynamicNode::Component(VComponent::new( - create_random_element, - DepthProps { depth, root: false }, - "create_random_element", - ))]), - Box::new([]), - ) - }) - .into_dyn_node(), - 2 => DynamicNode::Component(VComponent::new( - create_random_element, - DepthProps { depth, root: false }, - "create_random_element", - )), - _ => unreachable!(), - } -} - -fn create_random_dynamic_attr() -> Attribute { - let value = match rand::random::() % 7 { - 0 => AttributeValue::Text(format!("{}", rand::random::())), - 1 => AttributeValue::Float(rand::random()), - 2 => AttributeValue::Int(rand::random()), - 3 => AttributeValue::Bool(rand::random()), - 4 => AttributeValue::any_value(rand::random::()), - 5 => AttributeValue::None, - 6 => { - let value = AttributeValue::listener(|e: Event| println!("{:?}", e)); - return Attribute::new("ondata", value, None, false); - } - _ => unreachable!(), - }; - Attribute::new( - Box::leak(format!("attr{}", rand::random::()).into_boxed_str()), - value, - random_ns(), - rand::random(), - ) -} - -#[derive(PartialEq, Props, Clone)] -struct DepthProps { - depth: u8, - root: bool, -} - -fn create_random_element(cx: DepthProps) -> Element { - let last_template = use_hook(|| Rc::new(RefCell::new(None))); - if rand::random::() % 10 == 0 { - needs_update(); - } - let range = if cx.root { 2 } else { 3 }; - let node = match rand::random::() % range { - // Change both the template and the dynamic nodes - 0 => { - let (template, dynamic_nodes) = create_random_template(cx.depth + 1); - last_template.replace(Some(template)); - VNode::new( - None, - template, - dynamic_nodes, - (0..template.attr_paths().len()) - .map(|_| Box::new([create_random_dynamic_attr()]) as Box<[Attribute]>) - .collect(), - ) - } - // Change just the dynamic nodes - 1 => { - let (template, dynamic_nodes) = match *last_template.borrow() { - Some(template) => ( - template, - (0..template.node_paths().len()) - .map(|_| create_random_dynamic_node(cx.depth + 1)) - .collect(), - ), - None => create_random_template(cx.depth + 1), - }; - VNode::new( - None, - template, - dynamic_nodes, - (0..template.attr_paths().len()) - .map(|_| Box::new([create_random_dynamic_attr()]) as Box<[Attribute]>) - .collect(), - ) - } - // Remove the template - _ => VNode::default(), - }; - Element::Ok(node) -} - -// test for panics when creating random nodes and templates -#[test] -fn create() { - let repeat_count = if cfg!(miri) { 100 } else { 1000 }; - for _ in 0..repeat_count { - let mut vdom = - VirtualDom::new_with_props(create_random_element, DepthProps { depth: 0, root: true }); - vdom.rebuild(&mut NoOpMutations); - - vdom.in_scope(ScopeId::APP, || { - assert!(consume_context::().error().is_none()) - }) - } -} - -// test for panics when diffing random nodes -// This test will change the template every render which is not very realistic, but it helps stress the system -#[test] -fn diff() { - let repeat_count = if cfg!(miri) { 100 } else { 1000 }; - for _ in 0..repeat_count { - let mut vdom = - VirtualDom::new_with_props(create_random_element, DepthProps { depth: 0, root: true }); - vdom.rebuild(&mut NoOpMutations); - // A list of all elements that have had event listeners - // This is intentionally never cleared, so that we can test that calling event listeners that are removed doesn't cause a panic - let mut event_listeners = HashSet::new(); - for _ in 0..100 { - for &id in &event_listeners { - println!("firing event on {:?}", id); - let event = Event::new( - std::rc::Rc::new(String::from("hello world")) as Rc, - true, - ); - vdom.runtime().handle_event("data", event, id); - } - { - vdom.render_immediate(&mut InsertEventListenerMutationHandler( - &mut event_listeners, - )); - } - } - - vdom.in_scope(ScopeId::APP, || { - assert!(consume_context::().error().is_none()) - }) - } -} - -struct InsertEventListenerMutationHandler<'a>(&'a mut HashSet); - -impl WriteMutations for InsertEventListenerMutationHandler<'_> { - fn append_children(&mut self, _: ElementId, _: usize) {} - - fn assign_node_id(&mut self, _: &'static [u8], _: ElementId) {} - - fn create_placeholder(&mut self, _: ElementId) {} - - fn create_text_node(&mut self, _: &str, _: ElementId) {} - - fn load_template(&mut self, _: Template, _: usize, _: ElementId) {} - - fn replace_node_with(&mut self, _: ElementId, _: usize) {} - - fn replace_placeholder_with_nodes(&mut self, _: &'static [u8], _: usize) {} - - fn insert_nodes_after(&mut self, _: ElementId, _: usize) {} - - fn insert_nodes_before(&mut self, _: ElementId, _: usize) {} - - fn set_attribute( - &mut self, - _: &'static str, - _: Option<&'static str>, - _: &AttributeValue, - _: ElementId, - ) { - } - - fn set_node_text(&mut self, _: &str, _: ElementId) {} - - fn create_event_listener(&mut self, name: &'static str, id: ElementId) { - println!("new event listener on {:?} for {:?}", id, name); - self.0.insert(id); - } - - fn remove_event_listener(&mut self, _: &'static str, _: ElementId) {} - - fn remove_node(&mut self, _: ElementId) {} - - fn push_root(&mut self, _: ElementId) {} -} diff --git a/packages/core/tests/kitchen_sink.rs b/packages/core/tests/kitchen_sink.rs index 6cd30ec494..d97e168ea7 100644 --- a/packages/core/tests/kitchen_sink.rs +++ b/packages/core/tests/kitchen_sink.rs @@ -1,7 +1,5 @@ -use dioxus::dioxus_core::{ElementId, Mutation}; use dioxus::prelude::*; -use dioxus_core::IntoAttributeValue; -use pretty_assertions::assert_eq; +use dioxus_renderer_oracle::RendererOracle; fn basic_syntax_is_a_template() -> Element { let asd = 123; @@ -35,22 +33,9 @@ fn basic_syntax_is_a_template() -> Element { #[test] fn dual_stream() { let mut dom = VirtualDom::new(basic_syntax_is_a_template); - let edits = dom.rebuild_to_vec(); + let mut oracle = RendererOracle::new(); + let summary = oracle.rebuild(&mut dom); - use Mutation::*; - assert_eq!(edits.edits, { - [ - LoadTemplate { index: 0, id: ElementId(1) }, - SetAttribute { - name: "class", - value: "asd 123 123 ".into_value(), - id: ElementId(1), - ns: None, - }, - NewEventListener { name: "click".to_string(), id: ElementId(1) }, - CreateTextNode { value: "123".to_string(), id: ElementId(2) }, - ReplacePlaceholder { path: &[0, 0], m: 1 }, - AppendChildren { id: ElementId(0), m: 1 }, - ] - }); + oracle.assert_matches(basic_syntax_is_a_template); + assert_eq!(summary.set_attrs, 1); } diff --git a/packages/core/tests/lifecycle.rs b/packages/core/tests/lifecycle.rs index 2b7ead4dd7..9207263895 100644 --- a/packages/core/tests/lifecycle.rs +++ b/packages/core/tests/lifecycle.rs @@ -2,9 +2,9 @@ #![allow(non_snake_case)] //! Tests for the lifecycle of components. -use dioxus::dioxus_core::{ElementId, Mutation::*}; use dioxus::html::SerializedHtmlEventConverter; use dioxus::prelude::*; +use dioxus_renderer_oracle::RendererOracle; use std::any::Any; use std::rc::Rc; use std::sync::{Arc, Mutex}; @@ -24,21 +24,18 @@ fn manual_diffing() { }; let value = Arc::new(Mutex::new("Hello")); - let mut dom = VirtualDom::new_with_props(app, AppProps { value: value.clone() }); + fn expected_goodbye() -> Element { + rsx! { div { "goodbye" } } + } - dom.rebuild(&mut dioxus_core::NoOpMutations); + let mut dom = VirtualDom::new_with_props(app, AppProps { value: value.clone() }); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); *value.lock().unwrap() = "goodbye"; - assert_eq!( - dom.rebuild_to_vec().edits, - [ - LoadTemplate { index: 0, id: ElementId(3) }, - CreateTextNode { value: "goodbye".to_string(), id: ElementId(4) }, - ReplacePlaceholder { path: &[0], m: 1 }, - AppendChildren { m: 1, id: ElementId(0) } - ] - ); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected_goodbye); } #[test] @@ -49,7 +46,7 @@ fn events_generate() { match count() { 0 => rsx! { - div { onclick: move |_| count += 1, + div { id: "click-target", onclick: move |_| count += 1, div { "nested" } "Click me!" } @@ -59,22 +56,17 @@ fn events_generate() { }; let mut dom = VirtualDom::new(app); - dom.rebuild(&mut dioxus_core::NoOpMutations); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); let event = Event::new( Rc::new(PlatformEventData::new(Box::::default())) as Rc, true, ); - dom.runtime().handle_event("click", event, ElementId(1)); + let target = oracle.element_id_by_attr("id", "click-target"); + dom.runtime().handle_event("click", event, target); dom.mark_dirty(ScopeId::APP); - let edits = dom.render_immediate_to_vec(); - - assert_eq!( - edits.edits, - [ - CreatePlaceholder { id: ElementId(2) }, - ReplaceWith { id: ElementId(1), m: 1 } - ] - ) + let summary = oracle.render(&mut dom); + assert_eq!(summary.replaces, 1); } diff --git a/packages/core/tests/many_roots.rs b/packages/core/tests/many_roots.rs index c954df52bb..8da7ffd7bb 100644 --- a/packages/core/tests/many_roots.rs +++ b/packages/core/tests/many_roots.rs @@ -1,9 +1,7 @@ #![allow(non_snake_case)] -use dioxus::dioxus_core::Mutation::*; use dioxus::prelude::*; -use dioxus_core::{AttributeValue, ElementId}; -use pretty_assertions::assert_eq; +use dioxus_renderer_oracle::RendererOracle; /// Should push the text node onto the stack and modify it /// Regression test for https://github.com/DioxusLabs/dioxus/issues/2809 and https://github.com/DioxusLabs/dioxus/issues/3055 @@ -38,32 +36,22 @@ fn many_roots() { ) } + fn expected() -> Element { + rsx! { + div { + div { "trailing nav" } + div { "whhhhh" } + div { "bhhhh" } + div { "homepage 1" } + div { width: "100%" } + } + } + } + let mut dom = VirtualDom::new(app); - let edits = dom.rebuild_to_vec(); + let mut oracle = RendererOracle::new(); + let summary = oracle.rebuild(&mut dom); - assert_eq!( - edits.edits, - [ - // load the div {} container - LoadTemplate { index: 0, id: ElementId(1) }, - // Set the width attribute first - AssignId { path: &[2], id: ElementId(2,) }, - SetAttribute { - name: "width", - ns: Some("style",), - value: AttributeValue::Text("100%".to_string()), - id: ElementId(2,), - }, - // Load MyOutlet next - LoadTemplate { index: 0, id: ElementId(3) }, - ReplacePlaceholder { path: &[1], m: 1 }, - // Then MyNav - LoadTemplate { index: 0, id: ElementId(4) }, - LoadTemplate { index: 1, id: ElementId(5) }, - LoadTemplate { index: 2, id: ElementId(6) }, - ReplacePlaceholder { path: &[0], m: 3 }, - // Then mount the div to the dom - AppendChildren { m: 1, id: ElementId(0) }, - ] - ) + oracle.assert_matches(expected); + assert_eq!(summary.set_attrs, 1); } diff --git a/packages/core/tests/miri_full_app.rs b/packages/core/tests/miri_full_app.rs index 883a7bedde..ebea5c8e09 100644 --- a/packages/core/tests/miri_full_app.rs +++ b/packages/core/tests/miri_full_app.rs @@ -1,6 +1,6 @@ use dioxus::prelude::*; -use dioxus_core::ElementId; use dioxus_elements::SerializedHtmlEventConverter; +use dioxus_renderer_oracle::RendererOracle; use std::{any::Any, rc::Rc}; // This test is intended to be run with Miri, and contains no assertions. If it completes under @@ -9,17 +9,18 @@ use std::{any::Any, rc::Rc}; fn miri_rollover() { set_event_converter(Box::new(SerializedHtmlEventConverter)); let mut dom = VirtualDom::new(app); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); for _ in 0..3 { let event = Event::new( Rc::new(PlatformEventData::new(Box::::default())) as Rc, true, ); - dom.runtime().handle_event("click", event, ElementId(2)); + let target = oracle.element_id_by_attr("id", "increment"); + dom.runtime().handle_event("click", event, target); dom.process_events(); - _ = dom.render_immediate_to_vec(); + oracle.render(&mut dom); } } @@ -30,6 +31,7 @@ fn app() -> Element { rsx! { div { button { + id: "increment", onclick: move |_| { idx += 1; println!("Clicked"); diff --git a/packages/core/tests/miri_simple.rs b/packages/core/tests/miri_simple.rs index aa5c6216bb..fd6bd1a654 100644 --- a/packages/core/tests/miri_simple.rs +++ b/packages/core/tests/miri_simple.rs @@ -1,5 +1,6 @@ use dioxus::prelude::*; use dioxus_core::generation; +use dioxus_renderer_oracle::RendererOracle; // The tests in this file are intended to be run with Miri, and contain no assertions. If they // complete under Miri, they have passed. @@ -11,10 +12,7 @@ fn app_drops() { } let mut dom = VirtualDom::new(app); - - dom.rebuild(&mut dioxus_core::NoOpMutations); - dom.mark_dirty(ScopeId::APP); - _ = dom.render_immediate_to_vec(); + rebuild_and_render(&mut dom); } #[test] @@ -29,10 +27,7 @@ fn hooks_drop() { } let mut dom = VirtualDom::new(app); - - dom.rebuild(&mut dioxus_core::NoOpMutations); - dom.mark_dirty(ScopeId::APP); - _ = dom.render_immediate_to_vec(); + rebuild_and_render(&mut dom); } #[test] @@ -53,10 +48,7 @@ fn contexts_drop() { } let mut dom = VirtualDom::new(app); - - dom.rebuild(&mut dioxus_core::NoOpMutations); - dom.mark_dirty(ScopeId::APP); - _ = dom.render_immediate_to_vec(); + rebuild_and_render(&mut dom); } #[test] @@ -70,10 +62,7 @@ fn tasks_drop() { } let mut dom = VirtualDom::new(app); - - dom.rebuild(&mut dioxus_core::NoOpMutations); - dom.mark_dirty(ScopeId::APP); - _ = dom.render_immediate_to_vec(); + rebuild_and_render(&mut dom); } #[test] @@ -86,9 +75,7 @@ fn root_props_drop() { RootProps("asdasd".to_string()), ); - dom.rebuild(&mut dioxus_core::NoOpMutations); - dom.mark_dirty(ScopeId::APP); - _ = dom.render_immediate_to_vec(); + rebuild_and_render(&mut dom); } #[test] @@ -115,11 +102,26 @@ fn diffing_drops_old() { rsx! {"Goodbye {name}"} } + fn expected_first() -> Element { + rsx! { + div { "Hello asdasd" } + } + } + + fn expected_second() -> Element { + rsx! { + div { "Goodbye asdasd" } + } + } + let mut dom = VirtualDom::new(app); - dom.rebuild(&mut dioxus_core::NoOpMutations); - dom.mark_dirty(ScopeId::APP); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected_first); - _ = dom.render_immediate_to_vec(); + dom.mark_dirty(ScopeId::APP); + oracle.render(&mut dom); + oracle.assert_matches(expected_second); } #[test] @@ -143,8 +145,12 @@ fn hooks_drop_before_contexts() { } let mut dom = VirtualDom::new(app); + rebuild_and_render(&mut dom); +} - dom.rebuild(&mut dioxus_core::NoOpMutations); +fn rebuild_and_render(dom: &mut VirtualDom) { + let mut oracle = RendererOracle::new(); + oracle.rebuild(dom); dom.mark_dirty(ScopeId::APP); - _ = dom.render_immediate_to_vec(); + oracle.render(dom); } diff --git a/packages/core/tests/miri_stress.rs b/packages/core/tests/miri_stress.rs index 7d3969d49b..db64e9a1d3 100644 --- a/packages/core/tests/miri_stress.rs +++ b/packages/core/tests/miri_stress.rs @@ -4,6 +4,7 @@ use std::rc::Rc; use dioxus::prelude::*; use dioxus_core::{NoOpMutations, generation}; +use dioxus_renderer_oracle::RendererOracle; // The tests in this file are intended to be run with Miri, so not all of them contain assertions. // If the tests complete under Miri, they have passed. @@ -62,12 +63,12 @@ fn test_memory_leak() { } let mut dom = VirtualDom::new(app); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); for _ in 0..5 { dom.mark_dirty(ScopeId::APP); - _ = dom.render_immediate_to_vec(); + oracle.render(&mut dom); } } diff --git a/packages/core/tests/suspense.rs b/packages/core/tests/suspense.rs index e80bcc8f86..ef7baee728 100644 --- a/packages/core/tests/suspense.rs +++ b/packages/core/tests/suspense.rs @@ -1,5 +1,6 @@ use dioxus::prelude::*; -use dioxus_core::{AttributeValue, ElementId, Mutation, generation}; +use dioxus_core::{ScopeId, Task, generation}; +use dioxus_renderer_oracle::{EditSummary, RendererOracle, SnapshotNode}; use pretty_assertions::assert_eq; use std::future::poll_fn; use std::task::Poll; @@ -73,6 +74,128 @@ fn suspended_child() -> Element { rsx!("child") } +#[test] +fn suspense_switches_to_fallback_when_child_suspends_during_diff() { + fn app() -> Element { + let should_suspend = generation() > 0; + + rsx! { + SuspenseBoundary { + fallback: |_| rsx! { "fallback" }, + Child { should_suspend } + } + } + } + + #[component] + fn Child(should_suspend: bool) -> Element { + if should_suspend { + let task = spawn(async { std::future::pending::<()>().await }); + suspend(task)?; + } + + rsx! { + div { "resolved" } + } + } + + let mut dom = VirtualDom::new(app); + let mut renderer = RendererOracle::new(); + dom.rebuild(&mut renderer); + + assert_eq!( + renderer.snapshot(), + [SnapshotNode::Element { + tag: "div".to_string(), + namespace: None, + attrs: Vec::new(), + listeners: Vec::new(), + children: vec![SnapshotNode::Text("resolved".to_string())], + }] + ); + + dom.mark_dirty(ScopeId::APP); + dom.render_immediate(&mut renderer); + + assert_eq!( + renderer.snapshot(), + [SnapshotNode::Text("fallback".to_string())] + ); +} + +#[test] +fn suspense_promotes_child_when_suspended_task_is_cancelled_during_diff() { + fn app() -> Element { + let render_generation = generation(); + + rsx! { + SuspenseBoundary { + fallback: |_| rsx! { "fallback" }, + Child { + should_suspend: render_generation == 0, + show_component: render_generation > 0, + } + } + } + } + + #[component] + fn Child(should_suspend: bool, show_component: bool) -> Element { + let mut task = use_signal(|| None::); + + if should_suspend { + let running = task.cloned().unwrap_or_else(|| { + let new_task = spawn(async { std::future::pending::<()>().await }); + task.set(Some(new_task)); + new_task + }); + suspend(running)?; + } else if let Some(task) = task.take() { + task.cancel(); + } + + if show_component { + rsx! { + LoadedChild {} + } + } else { + rsx! { + div {} + } + } + } + + #[component] + fn LoadedChild() -> Element { + rsx! { + div {} + } + } + + let mut dom = VirtualDom::new(app); + let mut renderer = RendererOracle::new(); + dom.rebuild(&mut renderer); + + assert_eq!( + renderer.snapshot(), + [SnapshotNode::Text("fallback".to_string())] + ); + + dom.mark_dirty(ScopeId::APP); + dom.render_immediate(&mut renderer); + + assert_eq!( + renderer.snapshot(), + [SnapshotNode::Element { + tag: "div".to_string(), + namespace: None, + attrs: Vec::new(), + listeners: Vec::new(), + children: Vec::new(), + }] + ); +} + /// When switching from a suspense fallback to the real child, the state of that component must be kept #[test] fn suspense_keeps_state() { @@ -359,8 +482,6 @@ fn suspense_tracks_resolved() { // Regression test for https://github.com/DioxusLabs/dioxus/issues/2783 #[test] fn toggle_suspense() { - use dioxus::prelude::*; - fn app() -> Element { rsx! { SuspenseBoundary { @@ -393,71 +514,51 @@ fn toggle_suspense() { } } + fn expected_page() -> Element { + rsx! { + "goodbye world" + } + } + + fn expected_fallback() -> Element { + rsx! { + "fallback" + } + } + + fn expected_home() -> Element { + rsx! { + "hello world" + } + } + tokio::runtime::Builder::new_current_thread() .enable_time() .build() .unwrap() .block_on(async { let mut dom = VirtualDom::new(app); - let mutations = dom.rebuild_to_vec(); - - // First create goodbye world - println!("{:#?}", mutations); - assert_eq!( - mutations.edits, - [ - Mutation::LoadTemplate { index: 0, id: ElementId(1) }, - Mutation::AppendChildren { id: ElementId(0), m: 1 } - ] - ); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected_page); dom.mark_dirty(ScopeId::APP); - let mutations = dom.render_immediate_to_vec(); - - // Then replace that with nothing - println!("{:#?}", mutations); - assert_eq!( - mutations.edits, - [ - Mutation::CreatePlaceholder { id: ElementId(2) }, - Mutation::ReplaceWith { id: ElementId(1), m: 1 }, - ] - ); + oracle.render(&mut dom); + oracle.assert_matches(expected_fallback); dom.wait_for_work().await; - let mutations = dom.render_immediate_to_vec(); - - // Then replace it with a placeholder - println!("{:#?}", mutations); - assert_eq!( - mutations.edits, - [ - Mutation::LoadTemplate { index: 0, id: ElementId(1) }, - Mutation::ReplaceWith { id: ElementId(2), m: 1 }, - ] - ); + let summary = oracle.render(&mut dom); + oracle.assert_matches(expected_fallback); + assert_eq!(summary, EditSummary::default()); dom.wait_for_work().await; - let mutations = dom.render_immediate_to_vec(); - - // Then replace it with the resolved node - println!("{:#?}", mutations); - assert_eq!( - mutations.edits, - [ - Mutation::CreatePlaceholder { id: ElementId(2,) }, - Mutation::ReplaceWith { id: ElementId(1,), m: 1 }, - Mutation::LoadTemplate { index: 0, id: ElementId(1) }, - Mutation::ReplaceWith { id: ElementId(2), m: 1 }, - ] - ); + oracle.render(&mut dom); + oracle.assert_matches(expected_home); }); } #[test] fn nested_suspense_resolves_client() { - use Mutation::*; - fn app() -> Element { rsx! { SuspenseBoundary { @@ -547,354 +648,145 @@ fn nested_suspense_resolves_client() { content_tree[id].clone() } - // wait just a moment, not enough time for the boundary to resolve + fn expected_loading_root() -> Element { + rsx! { + "Loading 0..." + } + } + + fn expected_root_message_loading_children() -> Element { + rsx! { + h2 { + id: "title-0", + "The robot says hello world" + } + p { + id: "body-0", + "The robot becomes sentient and says hello world" + } + div { + id: "children-0", + padding: "10px", + "Loading 1..." + "Loading 2..." + } + } + } + + fn expected_nested_messages_loading_grandchild() -> Element { + rsx! { + h2 { + id: "title-0", + "The robot says hello world" + } + p { + id: "body-0", + "The robot becomes sentient and says hello world" + } + div { + id: "children-0", + padding: "10px", + h2 { + id: "title-1", + "The world says hello back" + } + p { + id: "body-1", + "In a stunning turn of events, the world collectively unites and says hello back" + } + div { + id: "children-1", + padding: "10px", + } + h2 { + id: "title-2", + "Goodbye Robot" + } + p { + id: "body-2", + "The robot says goodbye" + } + div { + id: "children-2", + padding: "10px", + "Loading 3..." + } + } + } + } + + fn expected_resolved_tree() -> Element { + rsx! { + h2 { + id: "title-0", + "The robot says hello world" + } + p { + id: "body-0", + "The robot becomes sentient and says hello world" + } + div { + id: "children-0", + padding: "10px", + h2 { + id: "title-1", + "The world says hello back" + } + p { + id: "body-1", + "In a stunning turn of events, the world collectively unites and says hello back" + } + div { + id: "children-1", + padding: "10px", + } + h2 { + id: "title-2", + "Goodbye Robot" + } + p { + id: "body-2", + "The robot says goodbye" + } + div { + id: "children-2", + padding: "10px", + h2 { + id: "title-3", + "Goodbye Robot again" + } + p { + id: "body-3", + "The robot says goodbye again" + } + div { + id: "children-3", + padding: "10px", + } + } + } + } + } + tokio::runtime::Builder::new_current_thread() .build() .unwrap() .block_on(async { let mut dom = VirtualDom::new(app); - let mutations = dom.rebuild_to_vec(); - // Initial loading message and loading title - assert_eq!( - mutations.edits, - vec![ - CreatePlaceholder { id: ElementId(1,) }, - CreateTextNode { value: "Loading 0...".to_string(), id: ElementId(2,) }, - AppendChildren { id: ElementId(0,), m: 2 }, - ] - ); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); + oracle.assert_matches(expected_loading_root); dom.wait_for_work().await; - // DOM STATE: - // placeholder // ID: 1 - // "Loading 0..." // ID: 2 - let mutations = dom.render_immediate_to_vec(); - // Fill in the contents of the initial message and start loading the nested suspense - // The title also finishes loading - assert_eq!( - mutations.edits, - vec![ - // Creating and swapping these placeholders doesn't do anything - // It is just extra work that we are forced to do because mutations are not - // reversible. We start rendering the children and then realize it is suspended. - // Then we need to replace what we just rendered with the suspense placeholder - CreatePlaceholder { id: ElementId(3,) }, - ReplaceWith { id: ElementId(1,), m: 1 }, - - // Replace the pending placeholder with the title placeholder - CreatePlaceholder { id: ElementId(1,) }, - ReplaceWith { id: ElementId(3,), m: 1 }, - - // Replace loading... with a placeholder for us to fill in later - CreatePlaceholder { id: ElementId(3,) }, - ReplaceWith { id: ElementId(2,), m: 1 }, - - // Load the title - LoadTemplate { index: 0, id: ElementId(2,) }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("title-0".to_string()), - id: ElementId(2,), - }, - CreateTextNode { value: "The robot says hello world".to_string(), id: ElementId(4,) }, - ReplacePlaceholder { path: &[0,], m: 1 }, - - // Then load the body - LoadTemplate { index: 1, id: ElementId(5,) }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("body-0".to_string()), - id: ElementId(5,), - }, - CreateTextNode { value: "The robot becomes sentient and says hello world".to_string(), id: ElementId(6,) }, - ReplacePlaceholder { path: &[0,], m: 1 }, - - // Then load the suspended children - LoadTemplate { index: 2, id: ElementId(7,) }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("children-0".to_string()), - id: ElementId(7,), - }, - CreateTextNode { value: "Loading 1...".to_string(), id: ElementId(8,) }, - CreateTextNode { value: "Loading 2...".to_string(), id: ElementId(9,) }, - ReplacePlaceholder { path: &[0,], m: 2 }, - - // Finally replace the loading placeholder in the body with the resolved children - ReplaceWith { id: ElementId(3,), m: 3 }, - ] - ); + oracle.render(&mut dom); + oracle.assert_matches(expected_root_message_loading_children); dom.wait_for_work().await; - // DOM STATE: - // placeholder // ID: 1 - // h2 // ID: 2 - // p // ID: 5 - // div // ID: 7 - // "Loading 1..." // ID: 8 - // "Loading 2..." // ID: 9 - let mutations = dom.render_immediate_to_vec(); - assert_eq!( - mutations.edits, - vec![ - // Replace the first loading placeholder with a placeholder for us to fill in later - CreatePlaceholder { - id: ElementId( - 3, - ), - }, - ReplaceWith { - id: ElementId( - 8, - ), - m: 1, - }, - - // Load the nested suspense - LoadTemplate { - - index: 0, - id: ElementId( - 8, - ), - }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("title-1".to_string()), - id: ElementId( - 8, - ), - }, - CreateTextNode { value: "The world says hello back".to_string(), id: ElementId(10,) }, - ReplacePlaceholder { - path: &[ - 0, - ], - m: 1, - }, - LoadTemplate { - index: 1, - id: ElementId( - 11, - ), - }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("body-1".to_string()), - id: ElementId( - 11, - ), - }, - CreateTextNode { value: "In a stunning turn of events, the world collectively unites and says hello back".to_string(), id: ElementId(12,) }, - ReplacePlaceholder { - path: &[ - 0, - ], - m: 1, - }, - LoadTemplate { - index: 2, - id: ElementId( - 13, - ), - }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("children-1".to_string()), - id: ElementId( - 13, - ), - }, - CreatePlaceholder { id: ElementId(14,) }, - ReplacePlaceholder { - path: &[ - 0, - ], - m: 1, - }, - ReplaceWith { - id: ElementId( - 3, - ), - m: 3, - }, - - // Replace the second loading placeholder with a placeholder for us to fill in later - CreatePlaceholder { - id: ElementId( - 3, - ), - }, - ReplaceWith { - id: ElementId( - 9, - ), - m: 1, - }, - LoadTemplate { - index: 0, - id: ElementId( - 9, - ), - }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("title-2".to_string()), - id: ElementId( - 9, - ), - }, - CreateTextNode { value: "Goodbye Robot".to_string(), id: ElementId(15,) }, - ReplacePlaceholder { - path: &[ - 0, - ], - m: 1, - }, - LoadTemplate { - index: 1, - id: ElementId( - 16, - ), - }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("body-2".to_string()), - id: ElementId( - 16, - ), - }, - CreateTextNode { value: "The robot says goodbye".to_string(), id: ElementId(17,) }, - ReplacePlaceholder { - path: &[ - 0, - ], - m: 1, - }, - LoadTemplate { - - index: 2, - id: ElementId( - 18, - ), - }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("children-2".to_string()), - id: ElementId( - 18, - ), - }, - // Create a placeholder for the resolved children - CreateTextNode { value: "Loading 3...".to_string(), id: ElementId(19,) }, - ReplacePlaceholder { path: &[0,], m: 1 }, - - // Replace the loading placeholder with the resolved children - ReplaceWith { - id: ElementId( - 3, - ), - m: 3, - }, - ] - ); + oracle.render(&mut dom); + oracle.assert_matches(expected_nested_messages_loading_grandchild); dom.wait_for_work().await; - let mutations = dom.render_immediate_to_vec(); - assert_eq!( - mutations.edits, - vec![ - CreatePlaceholder { - id: ElementId( - 3, - ), - }, - ReplaceWith { - id: ElementId( - 19, - ), - m: 1, - }, - LoadTemplate { - - index: 0, - id: ElementId( - 19, - ), - }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("title-3".to_string()), - id: ElementId( - 19, - ), - }, - CreateTextNode { value: "Goodbye Robot again".to_string(), id: ElementId(20,) }, - ReplacePlaceholder { - path: &[ - 0, - ], - m: 1, - }, - LoadTemplate { - index: 1, - id: ElementId( - 21, - ), - }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("body-3".to_string()), - id: ElementId( - 21, - ), - }, - CreateTextNode { value: "The robot says goodbye again".to_string(), id: ElementId(22,) }, - ReplacePlaceholder { - path: &[ - 0, - ], - m: 1, - }, - LoadTemplate { - index: 2, - id: ElementId( - 23, - ), - }, - SetAttribute { - name: "id", - ns: None, - value: AttributeValue::Text("children-3".to_string()), - id: ElementId( - 23, - ), - }, - CreatePlaceholder { id: ElementId(24,) }, - ReplacePlaceholder { - path: &[ - 0 - ], - m: 1, - }, - ReplaceWith { - id: ElementId( - 3, - ), - m: 3, - }, - ] - ) + oracle.render(&mut dom); + oracle.assert_matches(expected_resolved_tree); }); } diff --git a/packages/core/tests/tracing.rs b/packages/core/tests/tracing.rs index 7b39ec27f9..e3b5cb202d 100644 --- a/packages/core/tests/tracing.rs +++ b/packages/core/tests/tracing.rs @@ -1,16 +1,17 @@ use dioxus::html::SerializedHtmlEventConverter; use dioxus::prelude::*; -use dioxus_core::{ElementId, Event}; +use dioxus_core::Event; +use dioxus_renderer_oracle::RendererOracle; use std::{any::Any, rc::Rc}; use tracing_fluent_assertions::{AssertionRegistry, AssertionsLayer}; use tracing_subscriber::{Registry, layer::SubscriberExt}; +// This test asserts on tracing events emitted by `VirtualDom::new` and +// `VirtualDom::rebuild`; it requires those calls to happen *exactly once*. #[test] fn basic_tracing() { - // setup tracing let assertion_registry = AssertionRegistry::default(); let base_subscriber = Registry::default(); - // log to standard out for testing let std_out_log = tracing_subscriber::fmt::layer().pretty(); let subscriber = base_subscriber .with(std_out_log) @@ -35,8 +36,8 @@ fn basic_tracing() { set_event_converter(Box::new(SerializedHtmlEventConverter)); let mut dom = VirtualDom::new(app); - - dom.rebuild(&mut dioxus_core::NoOpMutations); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut dom); new_virtual_dom.assert(); edited_virtual_dom.assert(); @@ -46,9 +47,10 @@ fn basic_tracing() { Rc::new(PlatformEventData::new(Box::::default())) as Rc, true, ); - dom.runtime().handle_event("click", event, ElementId(2)); + let target = oracle.element_id_by_attr("id", "increment"); + dom.runtime().handle_event("click", event, target); dom.process_events(); - _ = dom.render_immediate_to_vec(); + oracle.render(&mut dom); } } @@ -59,6 +61,7 @@ fn app() -> Element { rsx! { div { button { + id: "increment", onclick: move |_| { idx += 1; println!("Clicked"); diff --git a/packages/desktop/headless_tests/rendering.rs b/packages/desktop/headless_tests/rendering.rs index d9edddc353..49113b4dd9 100644 --- a/packages/desktop/headless_tests/rendering.rs +++ b/packages/desktop/headless_tests/rendering.rs @@ -33,7 +33,7 @@ fn use_inner_html(id: &'static str) -> Option { value() } -const EXPECTED_HTML: &str = r#"

text

hello world

"#; +const EXPECTED_HTML: &str = r#"

text

hello world

"#; fn check_html_renders() -> Element { let inner_html = use_inner_html("main_div"); diff --git a/packages/dioxus/Cargo.toml b/packages/dioxus/Cargo.toml index 8e824643ab..40aa964977 100644 --- a/packages/dioxus/Cargo.toml +++ b/packages/dioxus/Cargo.toml @@ -121,16 +121,11 @@ third-party-renderer = [] futures-util = { workspace = true } tracing = { workspace = true } rand = { workspace = true, features = ["small_rng"] } -criterion = { workspace = true } thiserror = { workspace = true } env_logger = { workspace = true } tokio = { workspace = true, features = ["full"] } dioxus = { workspace = true } -[[bench]] -name = "jsframework" -harness = false - [package.metadata.docs.rs] cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] features = ["router", "ssr", "web", "fullstack", "signals", "hooks", "html", "liveview", "server", "warnings"] diff --git a/packages/dioxus/benches/jsframework.rs b/packages/dioxus/benches/jsframework.rs deleted file mode 100644 index dbc210af95..0000000000 --- a/packages/dioxus/benches/jsframework.rs +++ /dev/null @@ -1,129 +0,0 @@ -#![allow(non_snake_case, non_upper_case_globals)] -//! This benchmark tests just the overhead of Dioxus itself. -//! -//! For the JS Framework Benchmark, both the framework and the browser is benchmarked together. Dioxus prepares changes -//! to be made, but the change application phase will be just as performant as the vanilla wasm_bindgen code. In essence, -//! we are measuring the overhead of Dioxus, not the performance of the "apply" phase. -//! -//! -//! Pre-templates (Mac M1): -//! - 3ms to create 1_000 rows -//! - 30ms to create 10_000 rows -//! -//! Post-templates -//! - 580us to create 1_000 rows -//! - 6.2ms to create 10_000 rows -//! -//! As pure "overhead", these are amazing good numbers, mostly slowed down by hitting the global allocator. -//! These numbers don't represent Dioxus with the heuristic engine installed, so I assume it'll be even faster. - -use criterion::{Criterion, criterion_group, criterion_main}; -use dioxus::prelude::*; -use dioxus_core::NoOpMutations; -use rand::prelude::*; - -criterion_group!(mbenches, create_rows); -criterion_main!(mbenches); - -fn create_rows(c: &mut Criterion) { - c.bench_function("create rows", |b| { - let mut dom = VirtualDom::new(app); - dom.rebuild(&mut dioxus_core::NoOpMutations); - - b.iter(|| { - dom.rebuild(&mut NoOpMutations); - }) - }); -} - -fn app() -> Element { - let mut rng = SmallRng::from_os_rng(); - - rsx! ( - table { - tbody { - for f in 0..10_000_usize { - table_row { - row_id: f, - label: Label::new(&mut rng) - } - } - } - } - ) -} - -#[derive(PartialEq, Props, Clone, Copy)] -struct RowProps { - row_id: usize, - label: Label, -} -fn table_row(props: RowProps) -> Element { - let [adj, col, noun] = props.label.0; - - rsx! { - tr { - td { class:"col-md-1", "{props.row_id}" } - td { class:"col-md-1", onclick: move |_| { /* run onselect */ }, - a { class: "lbl", "{adj}" "{col}" "{noun}" } - } - td { class: "col-md-1", - a { class: "remove", onclick: move |_| {/* remove */}, - span { class: "glyphicon glyphicon-remove remove", aria_hidden: "true" } - } - } - td { class: "col-md-6" } - } - } -} - -#[derive(PartialEq, Clone, Copy)] -struct Label([&'static str; 3]); - -impl Label { - fn new(rng: &mut SmallRng) -> Self { - Label([ - ADJECTIVES.choose(rng).unwrap(), - COLOURS.choose(rng).unwrap(), - NOUNS.choose(rng).unwrap(), - ]) - } -} - -static ADJECTIVES: &[&str] = &[ - "pretty", - "large", - "big", - "small", - "tall", - "short", - "long", - "handsome", - "plain", - "quaint", - "clean", - "elegant", - "easy", - "angry", - "crazy", - "helpful", - "mushy", - "odd", - "unsightly", - "adorable", - "important", - "inexpensive", - "cheap", - "expensive", - "fancy", -]; - -static COLOURS: &[&str] = &[ - "red", "yellow", "blue", "green", "pink", "brown", "purple", "brown", "white", "black", - "orange", -]; - -static NOUNS: &[&str] = &[ - "table", "chair", "house", "bbq", "desk", "car", "pony", "cookie", "sandwich", "burger", - "pizza", "mouse", "keyboard", -]; diff --git a/packages/fuzz/Cargo.toml b/packages/fuzz/Cargo.toml new file mode 100644 index 0000000000..4225c3a71e --- /dev/null +++ b/packages/fuzz/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dioxus-vdom-fuzz" +version = { workspace = true } +authors = ["Dioxus Labs"] +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false +rust-version = "1.85.0" + +[dependencies] +dioxus = { workspace = true } +dioxus-core = { workspace = true } +dioxus-renderer-oracle = { workspace = true } +dioxus-ssr = { workspace = true } +mutatis = { version = "0.5.2", features = ["alloc", "derive"] } +postcard = { workspace = true, features = ["alloc"] } +serde = { workspace = true, features = ["derive"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(fuzzing)'] } diff --git a/packages/fuzz/README.md b/packages/fuzz/README.md new file mode 100644 index 0000000000..c72f1cab14 --- /dev/null +++ b/packages/fuzz/README.md @@ -0,0 +1,75 @@ +# Dioxus VirtualDom Fuzzer + +This crate provides the structured operation model and renderer oracle used by +the local `cargo-fuzz` target in `fuzz/`. LibFuzzer handles coverage guidance, +corpus scheduling, crash storage, and minimization. Mutatis provides the custom +structure-aware mutator for encoded `FuzzCase` values. + +The fuzzer drives Dioxus `VirtualDom` updates with template, dynamic-node, +dynamic-attribute, fragment, event-listener, portal/multi-renderer, and suspense +operations. Each case is applied to per-target incremental renderers and checked +against stable re-render and fresh rebuild snapshots. + +## Running + +Install `cargo-fuzz` if needed: + +```sh +cargo install cargo-fuzz +``` + +Run a short smoke session from this package directory: + +```sh +cargo +nightly fuzz run vdom_ops -- -runs=256 +``` + +To replay the package-local corpus from this package directory: + +```sh +cargo +nightly fuzz run vdom_ops fuzz/corpus/vdom_ops -- -runs=256 +``` + +From the workspace root, pass the nested fuzz project explicitly: + +```sh +cargo +nightly fuzz run --fuzz-dir packages/fuzz/fuzz vdom_ops packages/fuzz/fuzz/corpus/vdom_ops -- -runs=256 +``` + +Run a longer session: + +```sh +cargo +nightly fuzz run vdom_ops +``` + +Minimize a crashing input. This still uses `cargo fuzz tmin`, but the +`vdom_ops` custom mutator detects libFuzzer minimization mode and first runs the +structured operation reducer before falling back to Mutatis shrink candidates: + +```sh +cargo +nightly fuzz tmin vdom_ops fuzz/artifacts/vdom_ops/ +``` + +Generate coverage using cargo-fuzz's built-in command: + +```sh +cargo +nightly fuzz coverage vdom_ops +``` + +## How It Works + +`fuzz/fuzz_targets/vdom_ops.rs` decodes the raw libFuzzer bytes as a postcard +encoded `FuzzCase`. Invalid raw inputs are ignored by the target. The custom +`fuzz_mutator!` hook decodes the current case, falls back to a valid iterator +branch-sweep seed when decoding fails, calls this crate's structured mutator, +and writes the encoded case back to libFuzzer's input buffer. + +Cases are capped at the crate-internal step limit so mutated corpus inputs +cannot produce unbounded replay work. + +## Failures + +On divergence, the fuzz target prints an SSR replay trace for the failing +operation sequence and then panics. LibFuzzer stores the crashing input under +`fuzz/artifacts/vdom_ops/`; use `cargo fuzz tmin` to minimize it and rerun the +target on the minimized artifact to reproduce the trace. diff --git a/packages/fuzz/fuzz/.gitignore b/packages/fuzz/fuzz/.gitignore new file mode 100644 index 0000000000..565b96be62 --- /dev/null +++ b/packages/fuzz/fuzz/.gitignore @@ -0,0 +1,4 @@ +artifacts/ +coverage/ +corpus/ +target/ diff --git a/packages/fuzz/fuzz/Cargo.lock b/packages/fuzz/fuzz/Cargo.lock new file mode 100644 index 0000000000..bdb224dbbd --- /dev/null +++ b/packages/fuzz/fuzz/Cargo.lock @@ -0,0 +1,2037 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "askama_escape" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df27b8d5ddb458c5fb1bbc1ce172d4a38c614a97d550b0ac89003897fb01de4" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-serialize" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7154afa56de2f290e3c82c2c6dc4f5b282b6870903f56ef3509aba95866edc" +dependencies = [ + "const-serialize-macro 0.7.2", +] + +[[package]] +name = "const-serialize" +version = "0.8.0-alpha.0" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize-macro 0.8.0-alpha.0", + "serde", +] + +[[package]] +name = "const-serialize-macro" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "const-serialize-macro" +version = "0.8.0-alpha.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dioxus" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus-asset-resolver", + "dioxus-cli-config", + "dioxus-config-macro", + "dioxus-config-macros", + "dioxus-core", + "dioxus-core-macro", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-logger", + "dioxus-signals", + "dioxus-stores", + "dioxus-web", + "manganis", + "subsecond", + "warnings", +] + +[[package]] +name = "dioxus-asset-resolver" +version = "0.8.0-alpha.0" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "ndk-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "dioxus-cli-config" +version = "0.8.0-alpha.0" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "dioxus-config-macro" +version = "0.8.0-alpha.0" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "dioxus-config-macros" +version = "0.8.0-alpha.0" + +[[package]] +name = "dioxus-core" +version = "0.8.0-alpha.0" +dependencies = [ + "anyhow", + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash 2.1.2", + "rustversion", + "serde", + "slab", + "slotmap", + "subsecond", + "tracing", + "xxhash-rust", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.8.0-alpha.0" +dependencies = [ + "convert_case", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-core-types" +version = "0.8.0-alpha.0" + +[[package]] +name = "dioxus-devtools" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools-types", + "dioxus-signals", + "serde", + "serde_json", + "subsecond", + "thiserror 2.0.18", + "tracing", + "tungstenite", +] + +[[package]] +name = "dioxus-devtools-types" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus-core", + "serde", + "subsecond-types", +] + +[[package]] +name = "dioxus-document" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-html", + "futures-channel", + "futures-util", + "generational-box", + "lazy-js-bundle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "dioxus-fuzz" +version = "0.0.0" +dependencies = [ + "dioxus-vdom-fuzz", + "libfuzzer-sys", +] + +[[package]] +name = "dioxus-history" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus-core", + "tracing", +] + +[[package]] +name = "dioxus-hooks" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", +] + +[[package]] +name = "dioxus-html" +version = "0.8.0-alpha.0" +dependencies = [ + "async-trait", + "bytes", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "futures-util", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "tracing", +] + +[[package]] +name = "dioxus-html-internal-macro" +version = "0.8.0-alpha.0" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-interpreter-js" +version = "0.8.0-alpha.0" +dependencies = [ + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.2", + "sledgehammer_bindgen", + "sledgehammer_utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-logger" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus-cli-config", + "tracing", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "dioxus-renderer-oracle" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus-core", + "pretty_assertions", +] + +[[package]] +name = "dioxus-rsx" +version = "0.8.0-alpha.0" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "dioxus-signals" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "parking_lot", + "rustc-hash 2.1.2", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-ssr" +version = "0.8.0-alpha.0" +dependencies = [ + "askama_escape", + "dioxus-core", + "dioxus-core-types", + "rustc-hash 2.1.2", +] + +[[package]] +name = "dioxus-stores" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "dioxus-stores-macro", + "generational-box", +] + +[[package]] +name = "dioxus-stores-macro" +version = "0.8.0-alpha.0" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-vdom-fuzz" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus", + "dioxus-core", + "dioxus-renderer-oracle", + "dioxus-ssr", + "mutatis", + "postcard", + "serde", +] + +[[package]] +name = "dioxus-web" +version = "0.8.0-alpha.0" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-types", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "gloo-timers", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.2", + "send_wrapper", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generational-box" +version = "0.8.0-alpha.0" +dependencies = [ + "parking_lot", + "tracing", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "lazy-js-bundle" +version = "0.8.0-alpha.0" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manganis" +version = "0.8.0-alpha.0" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "jni", + "manganis-core", + "manganis-macro", + "ndk-context", + "objc2", + "thiserror 2.0.18", +] + +[[package]] +name = "manganis-core" +version = "0.8.0-alpha.0" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "dioxus-cli-config", + "dioxus-core-types", + "serde", + "winnow 0.7.15", +] + +[[package]] +name = "manganis-macro" +version = "0.8.0-alpha.0" +dependencies = [ + "dunce", + "macro-string", + "manganis-core", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "mutatis" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda9aa1c47053dd102896e1f3e69d0cec502e3467af8c3ab3b58702cc62197ef" +dependencies = [ + "mutatis-derive", + "rand 0.8.6", +] + +[[package]] +name = "mutatis-derive" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3c893cbc8cc5b87607ed340786512781aff5d8d7ede9f43a82464f5c7c2390" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", + "wasm-bindgen", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "subsecond" +version = "0.8.0-alpha.0" +dependencies = [ + "js-sys", + "libc", + "libloading", + "memfd", + "memmap2", + "serde", + "subsecond-types", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "subsecond-types" +version = "0.8.0-alpha.0" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/fuzz/fuzz/Cargo.toml b/packages/fuzz/fuzz/Cargo.toml new file mode 100644 index 0000000000..2c5a3d12c0 --- /dev/null +++ b/packages/fuzz/fuzz/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "dioxus-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +# Standalone workspace so `cargo test --workspace` in the parent doesn't pick +# up this libFuzzer binary as a unit test (which would loop until OOM). +[workspace] + +[package.metadata] +cargo-fuzz = true + +[profile.dev] +opt-level = 3 + +[dependencies] +dioxus-vdom-fuzz = { path = ".." } +libfuzzer-sys = "0.4.7" + +[[bin]] +name = "vdom_ops" +path = "fuzz_targets/vdom_ops.rs" +test = false +doc = false +bench = false diff --git a/packages/fuzz/fuzz/fuzz_parallel_cmin.sh b/packages/fuzz/fuzz/fuzz_parallel_cmin.sh new file mode 100755 index 0000000000..3b576c4459 --- /dev/null +++ b/packages/fuzz/fuzz/fuzz_parallel_cmin.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Minimize the corpus in parallel, then run cargo-fuzz in parallel once. +# +# Environment overrides: +# TARGET=vdom_ops +# WORKERS=8 +# JOBS=8 +# FUZZ_SECONDS=1800 +# FUZZ_CHUNK_SECONDS=120 +# CORPUS=corpus/vdom_ops +# TOOLCHAIN=nightly +# LIBFUZZER_ARGS="-rss_limit_mb=8192" +# ARTIFACTS=artifacts/vdom_ops + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$script_dir" + +target="${TARGET:-vdom_ops}" +corpus="${CORPUS:-corpus/$target}" +artifacts="${ARTIFACTS:-artifacts/$target}" +toolchain="${TOOLCHAIN:-nightly}" +fuzz_seconds="${FUZZ_SECONDS:-1800}" +fuzz_chunk_seconds="${FUZZ_CHUNK_SECONDS:-120}" + +is_failure_artifact() { + local name="${1##*/}" + case "$name" in + crash-* | timeout-* | oom-* | leak-*) return 0 ;; + *) return 1 ;; + esac +} + +first_failure_from_log() { + local log="$1" + local line path + + while IFS= read -r line; do + case "$line" in + *"Test unit written to "*) + path="${line#*Test unit written to }" + path="${path%$'\r'}" + path="${path%%[[:space:]]*}" + + if is_failure_artifact "$path" && [[ -f "$path" ]]; then + printf '%s\n' "$path" + return 0 + fi + + if is_failure_artifact "$path" && [[ -f "../$path" ]]; then + printf '%s\n' "../$path" + return 0 + fi + ;; + esac + done <"$log" +} + +file_mtime() { + if stat -f '%m' "$1" >/dev/null 2>&1; then + stat -f '%m' "$1" + else + stat -c '%Y' "$1" + fi +} + +first_new_failure_artifact() { + local marker="$1" + local dir="$2" + local path mtime + local first_path="" + local first_mtime="" + + [[ -d "$dir" ]] || return 0 + + while IFS= read -r -d '' path; do + is_failure_artifact "$path" || continue + mtime="$(file_mtime "$path")" + + if [[ -z "$first_path" ]] || + ((mtime < first_mtime)) || + (((mtime == first_mtime)) && [[ "$path" < "$first_path" ]]); then + first_path="$path" + first_mtime="$mtime" + fi + done < <(find "$dir" -type f -newer "$marker" -print0) + + if [[ -n "$first_path" ]]; then + printf '%s\n' "$first_path" + fi +} + +default_workers="4" +if command -v sysctl >/dev/null 2>&1; then + default_workers="$(sysctl -n hw.ncpu 2>/dev/null || printf '4')" +elif command -v nproc >/dev/null 2>&1; then + default_workers="$(nproc 2>/dev/null || printf '4')" +fi + +workers="${WORKERS:-$default_workers}" +jobs="${JOBS:-$workers}" + +mkdir -p "$corpus" "$artifacts" + +echo "target: $target" +echo "corpus: $corpus" +echo "artifacts: $artifacts" +echo "workers/jobs: $workers/$jobs" +echo "epoch: ${fuzz_seconds}s" +echo "chunk: ${fuzz_chunk_seconds}s" +echo + +echo "==> minimizing corpus in place" +cargo "+$toolchain" fuzz cmin -s none "$target" "$corpus" -- \ + -jobs="$jobs" \ + -workers="$workers" \ + ${LIBFUZZER_ARGS:-} + +fuzz_log="$(mktemp "${TMPDIR:-/tmp}/fuzz_parallel_cmin.log.XXXXXX")" +artifact_marker="$(mktemp "${TMPDIR:-/tmp}/fuzz_parallel_cmin.marker.XXXXXX")" +trap 'rm -f "$fuzz_log" "$artifact_marker"' EXIT + +echo "==> fuzzing for ${fuzz_seconds}s" +fuzz_status=0 +remaining_seconds="$fuzz_seconds" +chunk_index=1 + +while ((remaining_seconds > 0)); do + chunk_seconds="$remaining_seconds" + if ((fuzz_chunk_seconds > 0 && chunk_seconds > fuzz_chunk_seconds)); then + chunk_seconds="$fuzz_chunk_seconds" + fi + + if ((fuzz_seconds != chunk_seconds)); then + echo "==> fuzz chunk $chunk_index (${chunk_seconds}s, ${remaining_seconds}s remaining)" + fi + + set +e + cargo "+$toolchain" fuzz run -s none "$target" "$corpus" -- \ + -jobs="$jobs" \ + -workers="$workers" \ + -max_total_time="$chunk_seconds" \ + ${LIBFUZZER_ARGS:-} 2>&1 | tee -a "$fuzz_log" + fuzz_status="${PIPESTATUS[0]}" + set -e + + if ((fuzz_status != 0)); then + break + fi + + remaining_seconds=$((remaining_seconds - chunk_seconds)) + chunk_index=$((chunk_index + 1)) +done + +if ((fuzz_status == 0)); then + exit 0 +fi + +failure_artifact="$(first_failure_from_log "$fuzz_log" || true)" +if [[ -z "$failure_artifact" ]]; then + failure_artifact="$(first_new_failure_artifact "$artifact_marker" "$artifacts" || true)" +fi + +if [[ -z "$failure_artifact" ]]; then + echo "==> fuzzing failed with status $fuzz_status, but no new failure artifact was found" >&2 + exit "$fuzz_status" +fi + +echo +echo "==> minimizing first failure: $failure_artifact" +set +e +cargo "+$toolchain" fuzz tmin -s none "$target" "$failure_artifact" +tmin_status="$?" +set -e + +if ((tmin_status != 0)); then + echo "==> minimization failed with status $tmin_status" >&2 + exit "$tmin_status" +fi + +exit "$fuzz_status" diff --git a/packages/fuzz/fuzz/fuzz_targets/vdom_ops.rs b/packages/fuzz/fuzz/fuzz_targets/vdom_ops.rs new file mode 100644 index 0000000000..780633aece --- /dev/null +++ b/packages/fuzz/fuzz/fuzz_targets/vdom_ops.rs @@ -0,0 +1,165 @@ +#![no_main] + +use dioxus_vdom_fuzz::{ + FuzzCase, ReductionOptions, decode_case, encode_case, format_failure_report, mutate_case, + print_case_trace, reduce_case_to_encoded_vec, run_case, +}; +use libfuzzer_sys::{fuzz_mutator, fuzz_target, fuzzer_mutate}; +use std::{ + collections::{HashMap, hash_map::DefaultHasher}, + hash::{Hash, Hasher}, + sync::{ + Mutex, OnceLock, + atomic::{AtomicBool, Ordering}, + }, +}; + +const INTERNAL_MINIMIZE_RANDOM_ATTEMPTS: usize = 64; +const INTERNAL_MINIMIZE_ATTEMPT_LIMIT: usize = 64; + +fuzz_target!(|data: &[u8]| { + let Some(case) = decode_case(data) else { + return; + }; + + if let Err(failure) = run_case(&case) { + if coverage_ignore_failures() { + return; + } + print_case_trace(&case, &failure); + panic!("{}", format_failure_report(&case, &failure)); + } +}); + +fuzz_mutator!(|data: &mut [u8], size: usize, max_size: usize, seed: u32| { + let mut case = decode_case(&data[..size]).unwrap_or_default(); + let minimizing = cargo_fuzz_minimizing(); + + if let Some(options) = cargo_fuzz_semantic_reduction_options() { + if claim_semantic_reduction_attempt() { + if let Some(reduced) = + cached_semantic_reduction(&case, &data[..size], max_size, options) + { + data[..reduced.len()].copy_from_slice(&reduced); + return reduced.len(); + } + } + } + + let additional_mutations = if minimizing { + extra_minimization_mutations(seed) + } else { + 0 + }; + if !mutate_case( + &mut case, + seed, + minimizing || max_size <= size, + additional_mutations, + ) { + return fuzzer_mutate(data, size, max_size); + } + + encode_case(&case, data, max_size).unwrap_or_else(|| fuzzer_mutate(data, size, max_size)) +}); + +fn extra_minimization_mutations(seed: u32) -> usize { + let mut state = seed as u64 ^ 0x9E37_79B9_7F4A_7C15; + state ^= state >> 12; + state ^= state << 25; + state ^= state >> 27; + state = state.wrapping_mul(0x2545_F491_4F6C_DD1D); + + if state & 0b11 == 0 { + 1 + ((state >> 8) as usize % 7) + } else { + 0 + } +} + +fn cargo_fuzz_minimizing() -> bool { + static MINIMIZING: OnceLock = OnceLock::new(); + *MINIMIZING.get_or_init(|| std::env::args().any(|arg| is_minimize_crash_arg(&arg))) +} + +fn coverage_ignore_failures() -> bool { + static IGNORE_FAILURES: OnceLock = OnceLock::new(); + *IGNORE_FAILURES + .get_or_init(|| std::env::var_os("DIOXUS_VDOM_FUZZ_COVERAGE_IGNORE_FAILURES").is_some()) +} + +fn claim_semantic_reduction_attempt() -> bool { + static ATTEMPTED: AtomicBool = AtomicBool::new(false); + !ATTEMPTED.swap(true, Ordering::Relaxed) +} + +fn cargo_fuzz_semantic_reduction_options() -> Option { + static OPTIONS: OnceLock> = OnceLock::new(); + OPTIONS + .get_or_init(|| { + let mut minimizing = false; + let mut internal_step = false; + for arg in std::env::args() { + if is_minimize_crash_internal_step_arg(&arg) { + internal_step = true; + } + minimizing |= is_minimize_crash_arg(&arg); + } + + if !minimizing { + return None; + } + + let options = if internal_step { + ReductionOptions::default() + .random_multi_attempts(INTERNAL_MINIMIZE_RANDOM_ATTEMPTS) + .max_attempts(INTERNAL_MINIMIZE_ATTEMPT_LIMIT) + } else { + ReductionOptions::default() + }; + + Some(options) + }) + .clone() +} + +fn is_minimize_crash_arg(arg: &str) -> bool { + matches!( + arg, + "-minimize_crash=1" + | "-minimize_crash" + | "--minimize_crash=1" + | "-minimize_crash_internal_step=1" + | "--minimize_crash_internal_step=1" + ) +} + +fn is_minimize_crash_internal_step_arg(arg: &str) -> bool { + matches!( + arg, + "-minimize_crash_internal_step=1" | "--minimize_crash_internal_step=1" + ) +} + +fn cached_semantic_reduction( + case: &FuzzCase, + encoded_case: &[u8], + max_size: usize, + options: ReductionOptions, +) -> Option> { + static CACHE: OnceLock>>>> = OnceLock::new(); + + let mut hasher = DefaultHasher::new(); + encoded_case.hash(&mut hasher); + let key = hasher.finish(); + + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(cached) = cache.lock().unwrap().get(&key).cloned() { + return cached; + } + + let reduction = reduce_case_to_encoded_vec(case, encoded_case.len(), max_size, options); + + cache.lock().unwrap().insert(key, reduction.clone()); + reduction +} diff --git a/packages/fuzz/fuzz/lsan.supp b/packages/fuzz/fuzz/lsan.supp new file mode 100644 index 0000000000..14c7d36fb6 --- /dev/null +++ b/packages/fuzz/fuzz/lsan.supp @@ -0,0 +1,13 @@ +# generational_box intentionally leaks each `UnsyncStorage` slot via +# `Box::leak` and recycles them through a thread-local free list, so any +# slots still in the pool when the libFuzzer worker thread exits look like +# leaks to LSan. The coverage build demangles inherent impls as +# `::method`, so we need both patterns. +leak:generational_box::unsync::UnsyncStorage::create_new +leak:generational_box::unsync::UnsyncStorage>::create_new + +# The fuzz harness interns generated `Template` content into `&'static` +# caches with `Box::leak` so it satisfies `Template::new`'s static-lifetime +# requirement. Every unique template the fuzzer produces stays alive for +# the rest of the process by design. +leak:dioxus_vdom_fuzz::vdom::intern_ diff --git a/packages/fuzz/src/cache.rs b/packages/fuzz/src/cache.rs new file mode 100644 index 0000000000..cdcfab09c7 --- /dev/null +++ b/packages/fuzz/src/cache.rs @@ -0,0 +1,40 @@ +use std::{ + borrow::Borrow, + collections::HashSet, + hash::Hash, + sync::{Mutex, OnceLock}, +}; + +pub(crate) struct InternSet { + inner: OnceLock>>, +} + +impl InternSet +where + T: Clone + Eq + Hash, +{ + pub(crate) const fn new() -> Self { + Self { + inner: OnceLock::new(), + } + } + + pub(crate) fn get_or_insert_with(&self, key: &Q, create: impl FnOnce() -> T) -> T + where + T: Borrow, + Q: Eq + Hash + ?Sized, + { + let values = self.inner.get_or_init(|| Mutex::new(HashSet::new())); + if let Some(value) = values.lock().unwrap().get(key) { + return value.clone(); + } + + let value = create(); + let mut values = values.lock().unwrap(); + if let Some(value) = values.get(key) { + return value.clone(); + } + values.insert(value.clone()); + value + } +} diff --git a/packages/fuzz/src/context.rs b/packages/fuzz/src/context.rs new file mode 100644 index 0000000000..178e130fea --- /dev/null +++ b/packages/fuzz/src/context.rs @@ -0,0 +1,40 @@ +use crate::{ + event::EventState, lifecycle::LifecycleState, model::Model, ops::SuspenseReadyRegistry, +}; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, +}; + +#[derive(Clone)] +pub(crate) struct HarnessContext { + pub(crate) model: Rc>, + pub(crate) suspense_ready: Rc>, + pub(crate) register_suspense_ready_wakers: Rc>, + pub(crate) events: EventState, + pub(crate) lifecycle: LifecycleState, +} + +impl Default for HarnessContext { + fn default() -> Self { + Self { + model: Rc::new(RefCell::new(Model::initial())), + suspense_ready: Rc::new(RefCell::new(SuspenseReadyRegistry::default())), + register_suspense_ready_wakers: Rc::new(Cell::new(true)), + events: EventState::default(), + lifecycle: LifecycleState::default(), + } + } +} + +impl PartialEq for HarnessContext { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.model, &other.model) + } +} + +impl HarnessContext { + pub(crate) fn new() -> Self { + Self::default() + } +} diff --git a/packages/fuzz/src/diagnostics.rs b/packages/fuzz/src/diagnostics.rs new file mode 100644 index 0000000000..db86ea9172 --- /dev/null +++ b/packages/fuzz/src/diagnostics.rs @@ -0,0 +1,12 @@ +use std::any::Any; + +/// Convert a panic payload into a readable string for fuzzer/test diagnostics. +pub(crate) fn panic_message(payload: &Box) -> String { + if let Some(s) = payload.downcast_ref::<&'static str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "".to_string() + } +} diff --git a/packages/fuzz/src/event.rs b/packages/fuzz/src/event.rs new file mode 100644 index 0000000000..61a030f681 --- /dev/null +++ b/packages/fuzz/src/event.rs @@ -0,0 +1,65 @@ +use crate::ops::EventBehaviorSpec; +use std::{cell::RefCell, rc::Rc}; + +pub(crate) type ListenerDriver = Rc; + +#[derive(Clone)] +struct ListenerDriverState { + behavior: EventBehaviorSpec, + driver: Option, +} + +impl Default for ListenerDriverState { + fn default() -> Self { + Self { + behavior: EventBehaviorSpec::Noop, + driver: None, + } + } +} + +#[derive(Clone, Default)] +pub(crate) struct EventState { + current: Rc>, +} + +impl EventState { + pub(crate) fn with_listener_driver( + &self, + behavior: EventBehaviorSpec, + driver: ListenerDriver, + f: impl FnOnce() -> R, + ) -> R { + let previous = self.current.replace(ListenerDriverState { + behavior, + driver: Some(driver), + }); + let _guard = ListenerDriverGuard { + state: self.clone(), + previous, + }; + f() + } + + pub(crate) fn handle_listener_event(&self) { + let state = self.current.borrow().clone(); + if state.behavior == EventBehaviorSpec::Noop { + return; + } + + if let Some(driver) = state.driver { + driver(state.behavior); + } + } +} + +struct ListenerDriverGuard { + state: EventState, + previous: ListenerDriverState, +} + +impl Drop for ListenerDriverGuard { + fn drop(&mut self) { + self.state.current.replace(self.previous.clone()); + } +} diff --git a/packages/fuzz/src/harness.rs b/packages/fuzz/src/harness.rs new file mode 100644 index 0000000000..146f5ac118 --- /dev/null +++ b/packages/fuzz/src/harness.rs @@ -0,0 +1,3386 @@ +use crate::diagnostics::panic_message; +use crate::{ + context::HarnessContext, + lifecycle::{LifecycleKey, LifecycleRole, LifecycleRun, LifecycleSnapshot}, + model::*, + ops::{EventBehaviorSpec, Op}, + vdom::App, +}; +use dioxus_core::{ + AttributeValue, ElementId, Event, ScopeId, Template, VirtualDom, WriteMutations, +}; +use dioxus_renderer_oracle::{RendererOracle, SnapshotNode}; +use std::{any::Any, cell::RefCell, collections::BTreeSet, fmt, panic, rc::Rc}; + +type TargetSnapshots = Vec; + +pub(crate) struct Harness { + vdom: Rc>, + incremental: Rc>, + context: HarnessContext, + strict_renderer_errors: bool, + strict_lifecycle_errors: bool, +} + +impl Harness { + pub(crate) fn fresh() -> Self { + Self::fresh_with_strict_options(cfg!(fuzzing), cfg!(fuzzing)) + } + + #[cfg(test)] + fn fresh_strict() -> Self { + Self::fresh_with_strict_options(true, false) + } + + #[cfg(test)] + fn fresh_strict_lifecycle() -> Self { + Self::fresh_with_strict_options(true, true) + } + + fn fresh_with_strict_options( + strict_renderer_errors: bool, + strict_lifecycle_errors: bool, + ) -> Self { + let context = HarnessContext::new(); + context.clear_suspense_ready_tasks(); + context.lifecycle.reset_all(); + context.with_model(|model| *model = Model::initial()); + let vdom = Rc::new(RefCell::new(VirtualDom::new_with_props( + App, + context.clone(), + ))); + let incremental = Rc::new(RefCell::new(TargetedRendererOracle::new())); + context.lifecycle.with_run(LifecycleRun::Incremental, || { + vdom.borrow_mut().rebuild(&mut *incremental.borrow_mut()) + }); + incremental.borrow().assert_stack_clean(); + let state = Self { + vdom, + incremental, + context, + strict_renderer_errors, + strict_lifecycle_errors, + }; + if strict_lifecycle_errors { + let (_, fresh_lifecycle) = build_fresh_check(&state.context).unwrap(); + check_lifecycle_matches_fresh_snapshot(&state.context, &fresh_lifecycle).unwrap(); + } + state + } +} + +struct TargetedRendererOracle { + renderer: RendererOracle, + historical_event_listener_targets: BTreeSet, + last_mutation: Option, + recent_mutations: [Option; RECENT_MUTATION_LIMIT], + recent_mutation_start: usize, + recent_mutation_len: usize, +} + +const RECENT_MUTATION_LIMIT: usize = 16; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +struct EventListenerTarget { + name: &'static str, + id: ElementId, +} + +impl PartialOrd for EventListenerTarget { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for EventListenerTarget { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.name + .cmp(other.name) + .then_with(|| self.id.0.cmp(&other.id.0)) + } +} + +#[derive(Copy, Clone, Debug)] +enum MutationTrace { + AppendChildren { id: ElementId, m: usize }, + AssignNodeId { path: &'static [u8], id: ElementId }, + CreatePlaceholder { id: ElementId }, + CreateTextNode { len: usize, id: ElementId }, + LoadTemplate { index: usize, id: ElementId }, + ReplaceNodeWith { id: ElementId, m: usize }, + ReplacePlaceholderWithNodes { path: &'static [u8], m: usize }, + InsertNodesAfter { id: ElementId, m: usize }, + InsertNodesBefore { id: ElementId, m: usize }, + SetAttribute { name: &'static str, id: ElementId }, + SetNodeText { len: usize, id: ElementId }, + CreateEventListener { name: &'static str, id: ElementId }, + RemoveEventListener { name: &'static str, id: ElementId }, + RemoveNode { id: ElementId }, + PushRoot { id: ElementId }, +} + +impl fmt::Display for MutationTrace { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AppendChildren { id, m } => { + write!(f, "append_children(id: {id:?}, m: {m})") + } + Self::AssignNodeId { path, id } => { + write!(f, "assign_node_id(path: {path:?}, id: {id:?})") + } + Self::CreatePlaceholder { id } => write!(f, "create_placeholder(id: {id:?})"), + Self::CreateTextNode { len, id } => { + write!(f, "create_text_node(len: {len}, id: {id:?})") + } + Self::LoadTemplate { index, id } => { + write!(f, "load_template(index: {index}, id: {id:?})") + } + Self::ReplaceNodeWith { id, m } => { + write!(f, "replace_node_with(id: {id:?}, m: {m})") + } + Self::ReplacePlaceholderWithNodes { path, m } => { + write!(f, "replace_placeholder_with_nodes(path: {path:?}, m: {m})") + } + Self::InsertNodesAfter { id, m } => { + write!(f, "insert_nodes_after(id: {id:?}, m: {m})") + } + Self::InsertNodesBefore { id, m } => { + write!(f, "insert_nodes_before(id: {id:?}, m: {m})") + } + Self::SetAttribute { name, id } => { + write!(f, "set_attribute(name: {name:?}, id: {id:?})") + } + Self::SetNodeText { len, id } => { + write!(f, "set_node_text(len: {len}, id: {id:?})") + } + Self::CreateEventListener { name, id } => { + write!(f, "create_event_listener(name: {name:?}, id: {id:?})") + } + Self::RemoveEventListener { name, id } => { + write!(f, "remove_event_listener(name: {name:?}, id: {id:?})") + } + Self::RemoveNode { id } => write!(f, "remove_node(id: {id:?})"), + Self::PushRoot { id } => write!(f, "push_root(id: {id:?})"), + } + } +} + +impl TargetedRendererOracle { + fn new() -> Self { + Self { + renderer: RendererOracle::new(), + historical_event_listener_targets: BTreeSet::new(), + last_mutation: None, + recent_mutations: [None; RECENT_MUTATION_LIMIT], + recent_mutation_start: 0, + recent_mutation_len: 0, + } + } + + fn current_renderer(&mut self) -> &mut RendererOracle { + &mut self.renderer + } + + fn record_mutation(&mut self, mutation: MutationTrace) { + self.last_mutation = Some(mutation); + if self.recent_mutation_len < RECENT_MUTATION_LIMIT { + let index = + (self.recent_mutation_start + self.recent_mutation_len) % RECENT_MUTATION_LIMIT; + self.recent_mutations[index] = Some(mutation); + self.recent_mutation_len += 1; + } else { + self.recent_mutations[self.recent_mutation_start] = Some(mutation); + self.recent_mutation_start = (self.recent_mutation_start + 1) % RECENT_MUTATION_LIMIT; + } + } + + fn recent_mutations_text(&self) -> String { + let mut out = String::new(); + for offset in 0..self.recent_mutation_len { + let index = (self.recent_mutation_start + offset) % RECENT_MUTATION_LIMIT; + if let Some(mutation) = self.recent_mutations[index] { + if !out.is_empty() { + out.push_str("\n "); + } + out.push_str(&mutation.to_string()); + } + } + out + } + + fn assert_stack_clean(&self) { + if let Err(error) = self.check_stack_clean() { + panic!("{error}"); + } + } + + fn check_stack_clean(&self) -> Result<(), String> { + self.renderer.check_stack_clean() + } + + fn check_matches_fresh(&self, fresh: &RendererOracle) -> Result<(), String> { + if self.renderer.snapshot_eq(fresh) { + return Ok(()); + } + + let fresh_snapshot = fresh.snapshot(); + let incremental_snapshot = self.snapshot(); + Err(format!( + "incremental renderer snapshot does not match fresh render\nincremental:\n{incremental_snapshot:#?}\nfresh:\n{fresh_snapshot:#?}" + )) + } + + fn snapshot(&self) -> TargetSnapshots { + self.renderer.snapshot() + } + + fn historical_event_listener_targets(&self) -> Vec { + self.historical_event_listener_targets + .iter() + .copied() + .collect() + } +} + +impl WriteMutations for TargetedRendererOracle { + fn append_children(&mut self, id: ElementId, m: usize) { + self.record_mutation(MutationTrace::AppendChildren { id, m }); + self.current_renderer().append_children(id, m) + } + + fn assign_node_id(&mut self, path: &'static [u8], id: ElementId) { + self.record_mutation(MutationTrace::AssignNodeId { path, id }); + self.current_renderer().assign_node_id(path, id) + } + + fn create_placeholder(&mut self, id: ElementId) { + self.record_mutation(MutationTrace::CreatePlaceholder { id }); + self.current_renderer().create_placeholder(id) + } + + fn create_text_node(&mut self, value: &str, id: ElementId) { + self.record_mutation(MutationTrace::CreateTextNode { + len: value.len(), + id, + }); + self.current_renderer().create_text_node(value, id) + } + + fn load_template(&mut self, template: Template, index: usize, id: ElementId) { + self.record_mutation(MutationTrace::LoadTemplate { index, id }); + self.current_renderer().load_template(template, index, id) + } + + fn replace_node_with(&mut self, id: ElementId, m: usize) { + self.record_mutation(MutationTrace::ReplaceNodeWith { id, m }); + self.current_renderer().replace_node_with(id, m) + } + + fn replace_placeholder_with_nodes(&mut self, path: &'static [u8], m: usize) { + self.record_mutation(MutationTrace::ReplacePlaceholderWithNodes { path, m }); + self.current_renderer() + .replace_placeholder_with_nodes(path, m) + } + + fn insert_nodes_after(&mut self, id: ElementId, m: usize) { + self.record_mutation(MutationTrace::InsertNodesAfter { id, m }); + self.current_renderer().insert_nodes_after(id, m) + } + + fn insert_nodes_before(&mut self, id: ElementId, m: usize) { + self.record_mutation(MutationTrace::InsertNodesBefore { id, m }); + self.current_renderer().insert_nodes_before(id, m) + } + + fn set_attribute( + &mut self, + name: &'static str, + ns: Option<&'static str>, + value: &AttributeValue, + id: ElementId, + ) { + self.record_mutation(MutationTrace::SetAttribute { name, id }); + self.current_renderer().set_attribute(name, ns, value, id) + } + + fn set_node_text(&mut self, value: &str, id: ElementId) { + self.record_mutation(MutationTrace::SetNodeText { + len: value.len(), + id, + }); + self.current_renderer().set_node_text(value, id) + } + + fn create_event_listener(&mut self, name: &'static str, id: ElementId) { + self.record_mutation(MutationTrace::CreateEventListener { name, id }); + self.current_renderer().create_event_listener(name, id); + self.historical_event_listener_targets + .insert(EventListenerTarget { name, id }); + } + + fn remove_event_listener(&mut self, name: &'static str, id: ElementId) { + self.record_mutation(MutationTrace::RemoveEventListener { name, id }); + self.current_renderer().remove_event_listener(name, id) + } + + fn remove_node(&mut self, id: ElementId) { + self.record_mutation(MutationTrace::RemoveNode { id }); + self.current_renderer().remove_node(id) + } + + fn push_root(&mut self, id: ElementId) { + self.record_mutation(MutationTrace::PushRoot { id }); + self.current_renderer().push_root(id) + } +} + +const TRACE_CONTEXT: usize = 6; +const MAX_HTML_CHARS: usize = 240; + +fn catch_unwind_result(f: F) -> std::thread::Result +where + F: FnOnce() -> R, +{ + panic::catch_unwind(panic::AssertUnwindSafe(f)) +} + +fn render_model_with_ssr(context: &HarnessContext, model: &Model) -> Result { + catch_unwind_result(|| { + context.without_suspense_ready_registration(|| { + context.with_model(|global| *global = model.clone()); + let mut vdom = VirtualDom::new_with_props(App, context.clone()); + vdom.rebuild_in_place(); + dioxus_ssr::render(&vdom) + }) + }) + .map_err(|payload| format!("panic in SSR render: {}", panic_message(&payload))) +} + +fn print_html_line(label: &str, rendered: &Result) { + match rendered { + Ok(html) => println!(" {label:<7} {}", truncate_html(html)), + Err(err) => println!(" {label:<7} <{err}>"), + } +} + +fn truncate_html(html: &str) -> String { + if html.chars().count() <= MAX_HTML_CHARS { + return html.to_string(); + } + + let mut truncated = html.chars().take(MAX_HTML_CHARS).collect::(); + truncated.push_str("..."); + truncated +} + +fn first_line(text: &str) -> &str { + text.lines().next().unwrap_or(text) +} + +fn print_indented(text: &str, indent: &str) { + for line in text.lines() { + println!("{indent}{line}"); + } +} + +fn print_op_list(ops: &[Op], failing_step: usize) { + println!("operations:"); + for (index, op) in ops.iter().enumerate() { + let marker = if index == failing_step { ">>" } else { " " }; + println!("{marker} {index:03}: {op:?}"); + } +} + +fn trace_bounds(ops_len: usize, failing_step: usize) -> (usize, usize) { + if ops_len <= TRACE_CONTEXT * 4 { + return (0, ops_len); + } + + ( + failing_step.saturating_sub(TRACE_CONTEXT), + (failing_step + TRACE_CONTEXT + 1).min(ops_len), + ) +} + +pub(crate) fn print_ssr_diff_trace(ops: &[Op], failing_step: usize, minimized_error: &str) { + let panic_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + + println!(); + println!("fuzz failure"); + println!("decoded operations: {}", ops.len()); + println!("reported failing step: {failing_step}"); + println!("summary: {}", first_line(minimized_error)); + println!(); + print_op_list(ops, failing_step); + println!(); + println!("ssr replay around failing step:"); + + let mut state = Harness::fresh(); + let mut current_model = Model::initial(); + let mut current_html = render_model_with_ssr(&state.context, ¤t_model); + let (trace_start, trace_end) = trace_bounds(ops.len(), failing_step); + + if trace_start == 0 { + println!(" initial"); + print_html_line("html:", ¤t_html); + } else { + println!(" replaying first {trace_start} steps without logging"); + } + + let mut reproduced_error = None; + for (index, op) in ops.iter().enumerate() { + state + .context + .with_model(|global| *global = current_model.clone()); + let should_log = index >= trace_start && index < trace_end; + + if should_log { + println!(); + println!(" step {index}"); + println!(" op: {op:?}"); + print_html_line("before:", ¤t_html); + } + + let applied = catch_unwind_result(|| apply_op(&mut state, op)).unwrap_or_else(|payload| { + Err(format!( + "panic while replaying operation: {}", + panic_message(&payload) + )) + }); + + match applied { + Ok(()) => { + let next_model = state.context.read_model(); + let next_html = render_model_with_ssr(&state.context, &next_model); + if should_log { + print_html_line("after:", &next_html); + println!(" status: ok"); + } + current_model = next_model; + current_html = next_html; + } + Err(err) => { + let next_model = state.context.read_model(); + let next_html = render_model_with_ssr(&state.context, &next_model); + print_html_line("after:", &next_html); + println!(" error: {}", first_line(&err)); + println!(); + println!("full oracle error:"); + print_indented(&err, " "); + reproduced_error = Some(err); + break; + } + } + } + + if reproduced_error.is_none() { + println!(); + println!(" replay completed without reproducing the minimized error:"); + println!(" {minimized_error}"); + } + std::panic::set_hook(panic_hook); +} + +pub(crate) fn apply_step(state: &mut Harness, op: &Op) -> Result<(), String> { + apply_op(state, op) +} + +fn apply_op(state: &mut Harness, op: &Op) -> Result<(), String> { + match op { + Op::Rerender => render_app_and_assert(state), + Op::WakeSuspense { suspense } => { + let Some(key) = state + .context + .selected_registered_ready_suspense_key(*suspense) + else { + return Ok(()); + }; + state.context.release_suspense_ready_task(key); + state + .context + .with_model(|model| model.wake_ready_suspense(key)); + state.vdom.borrow_mut().mark_dirty(ScopeId::APP); + render_dirty_and_assert(state) + } + Op::FireEvent { target, behavior } => { + fire_selected_event_listener(state, *target, *behavior) + } + Op::Mutate(_) => { + state.context.apply_to_model(op); + state.vdom.borrow_mut().mark_dirty(ScopeId::APP); + Ok(()) + } + } +} + +fn fire_historical_event_listeners(state: &Harness) -> Result<(), String> { + let targets = state + .incremental + .borrow() + .historical_event_listener_targets(); + if targets.is_empty() { + return Ok(()); + } + + let runtime = state.vdom.borrow().runtime(); + for target in targets { + let event = Event::new( + Rc::new(String::from("fuzzer stale event")) as Rc, + true, + ); + runtime.handle_event(target.name, event, target.id); + } + Ok(()) +} + +fn fire_selected_event_listener( + state: &mut Harness, + target_selector: u8, + behavior: EventBehaviorSpec, +) -> Result<(), String> { + let targets = state + .incremental + .borrow() + .historical_event_listener_targets(); + if targets.is_empty() { + return Ok(()); + } + + let target = targets[target_selector as usize % targets.len()]; + let runtime = state.vdom.borrow().runtime(); + let nested_runtime = runtime.clone(); + let nested_targets = targets.clone(); + let events = state.context.events.clone(); + let nested_events = events.clone(); + let listener_driver = Rc::new(move |behavior| match behavior { + EventBehaviorSpec::Noop => {} + EventBehaviorSpec::DispatchNestedEvent { target } => { + let Some(target) = nested_targets.get(target as usize % nested_targets.len()) else { + return; + }; + let event = Event::new( + Rc::new(String::from("fuzzer nested event")) as Rc, + true, + ); + nested_events.with_listener_driver(EventBehaviorSpec::Noop, Rc::new(|_| {}), || { + nested_runtime.handle_event(target.name, event, target.id) + }); + } + }); + + events.with_listener_driver(behavior, listener_driver, || { + let event = Event::new( + Rc::new(String::from("fuzzer explicit event")) as Rc, + true, + ); + runtime.handle_event(target.name, event, target.id); + }); + + Ok(()) +} + +fn render_once(state: &mut Harness, assert_lifecycle_matches_fresh: bool) -> Result<(), String> { + fire_historical_event_listeners(state)?; + state + .context + .lifecycle + .with_run(LifecycleRun::Incremental, || { + state + .vdom + .borrow_mut() + .render_immediate(&mut *state.incremental.borrow_mut()) + }); + check_incremental_state(state, assert_lifecycle_matches_fresh) +} + +fn check_incremental_state( + state: &Harness, + assert_lifecycle_matches_fresh: bool, +) -> Result<(), String> { + let incremental = state.incremental.borrow(); + incremental.check_stack_clean().map_err(|err| { + let last_mutation = incremental + .last_mutation + .map_or_else(|| "".to_string(), |mutation| mutation.to_string()); + let recent_mutations = incremental.recent_mutations_text(); + format!("{err} after {last_mutation}\nrecent mutations:\n {recent_mutations}") + })?; + let (fresh_renderer, fresh_lifecycle) = build_fresh_check(&state.context)?; + incremental.check_matches_fresh(&fresh_renderer)?; + if assert_lifecycle_matches_fresh { + check_lifecycle_matches_fresh_snapshot(&state.context, &fresh_lifecycle).map_err( + |err| { + let last_mutation = incremental + .last_mutation + .map_or_else(|| "".to_string(), |mutation| mutation.to_string()); + let recent_mutations = incremental.recent_mutations_text(); + format!("{err} after {last_mutation}\nrecent mutations:\n {recent_mutations}") + }, + )?; + } + Ok(()) +} + +fn render_app_and_assert(state: &mut Harness) -> Result<(), String> { + state.vdom.borrow_mut().mark_dirty(ScopeId::APP); + let compare_lifecycle = state.strict_lifecycle_errors; + let result = render_once(state, compare_lifecycle); + render_result_to_fuzz_failure(state, result) +} + +fn render_dirty_and_assert(state: &mut Harness) -> Result<(), String> { + let compare_lifecycle = state.strict_lifecycle_errors; + let result = render_once(state, compare_lifecycle); + render_result_to_fuzz_failure(state, result) +} + +fn build_fresh_check( + context: &HarnessContext, +) -> Result<(RendererOracle, LifecycleSnapshot), String> { + context.lifecycle.reset_run(LifecycleRun::Fresh); + let mut fresh_vdom = VirtualDom::new_with_props(App, context.clone()); + let mut renderer = RendererOracle::new(); + context.without_suspense_ready_registration(|| { + context + .lifecycle + .with_run(LifecycleRun::Fresh, || fresh_vdom.rebuild(&mut renderer)); + }); + renderer.check_stack_clean()?; + + Ok((renderer, context.lifecycle.snapshot(LifecycleRun::Fresh))) +} + +fn check_lifecycle_matches_fresh_snapshot( + context: &HarnessContext, + fresh: &LifecycleSnapshot, +) -> Result<(), String> { + let incremental = context.lifecycle.snapshot(LifecycleRun::Incremental); + let model = expected_model_lifecycle_snapshot(context); + if lifecycle_is_within_expected_bounds(context, &incremental, fresh, &model) { + return Ok(()); + } + + let retaining_suspense_ids = retaining_suspense_ids(context, &incremental, fresh, &model); + let retained_suspended = context + .lifecycle + .snapshot_with_suspense_ancestor(LifecycleRun::Incremental, &retaining_suspense_ids); + let model_suspended = + model_lifecycle_with_suspense_ancestor_snapshot(context, &retaining_suspense_ids); + Err(lifecycle_mismatch_error( + &incremental, + fresh, + &model, + &retained_suspended, + &model_suspended, + )) +} + +fn lifecycle_is_within_expected_bounds( + context: &HarnessContext, + incremental: &LifecycleSnapshot, + fresh: &LifecycleSnapshot, + model: &LifecycleSnapshot, +) -> bool { + let retaining_suspense_ids = retaining_suspense_ids(context, incremental, fresh, model); + let retained_suspended_subtree_lifecycle = context + .lifecycle + .snapshot_with_suspense_ancestor(LifecycleRun::Incremental, &retaining_suspense_ids); + let model_suspended_subtree_lifecycle = + model_lifecycle_with_suspense_ancestor_snapshot(context, &retaining_suspense_ids); + let has_all_visible_fresh_components = fresh + .iter() + .filter(|(key, _)| lifecycle_role_is_strict(**key)) + .all(|(key, count)| incremental.get(key).copied().unwrap_or(0) >= *count); + let has_no_components_outside_the_model = incremental + .iter() + .filter(|(key, _)| lifecycle_role_is_strict(**key)) + .all(|(key, count)| { + let model_count = model.get(key).copied().unwrap_or(0); + let retained_suspended_count = retained_suspended_subtree_lifecycle + .get(key) + .copied() + .unwrap_or(0); + let model_suspended_count = model_suspended_subtree_lifecycle + .get(key) + .copied() + .unwrap_or(0); + let retained_extra_count = + retained_suspended_count.saturating_sub(model_suspended_count); + *count <= model_count + retained_extra_count + }); + has_all_visible_fresh_components && has_no_components_outside_the_model +} + +fn lifecycle_role_is_strict(key: LifecycleKey) -> bool { + // Suspense helper components can overlap while core moves work between + // visible and suspended trees. The strict oracle targets generated app + // components, where a live key outside the model means stale state. + matches!( + key.role, + LifecycleRole::ComponentA | LifecycleRole::ComponentB + ) +} + +fn expected_model_lifecycle_snapshot(context: &HarnessContext) -> LifecycleSnapshot { + let model = context.read_model(); + let mut out = LifecycleSnapshot::new(); + collect_vnode_lifecycle(&model.root, &mut out); + out +} + +fn retaining_suspense_ids( + context: &HarnessContext, + incremental: &LifecycleSnapshot, + fresh: &LifecycleSnapshot, + model: &LifecycleSnapshot, +) -> BTreeSet { + let current_model = context.read_model(); + let mut out = BTreeSet::new(); + // Core suspense can retain previous child state while a reused boundary + // moves between fallback and resolved output, even if the model suspense is + // currently resolved. Bound retained extras by current boundary ancestry. + collect_current_suspense_ids(¤t_model.root, &mut out); + + for (key, count) in incremental { + if key.role != LifecycleRole::SuspenseChild { + continue; + } + + let fresh_count = fresh.get(key).copied().unwrap_or(0); + let model_count = model.get(key).copied().unwrap_or(0); + if (fresh_count > 0 || model_count > 0) && *count > fresh_count.max(model_count) { + out.insert(key.id); + } + } + + out +} + +fn model_lifecycle_with_suspense_ancestor_snapshot( + context: &HarnessContext, + suspense_ids: &BTreeSet, +) -> LifecycleSnapshot { + let model = context.read_model(); + let mut out = LifecycleSnapshot::new(); + collect_model_lifecycle_with_suspense_ancestor(&model.root, false, suspense_ids, &mut out); + out +} + +fn collect_current_suspense_ids(vnode: &VNodeSpec, out: &mut BTreeSet) { + collect_template_current_suspense_ids(&vnode.template.roots, out); +} + +fn collect_template_current_suspense_ids(nodes: &[TemplateNodeSpec], out: &mut BTreeSet) { + for node in nodes { + match node { + TemplateNodeSpec::Element { children, .. } => { + collect_template_current_suspense_ids(children, out); + } + TemplateNodeSpec::Text(_) => {} + TemplateNodeSpec::Dynamic(dynamic) => { + collect_dynamic_current_suspense_ids(dynamic, out) + } + } + } +} + +fn collect_dynamic_current_suspense_ids(dynamic: &DynamicSpec, out: &mut BTreeSet) { + match dynamic { + DynamicSpec::Fragment(nodes) => { + for node in nodes { + collect_current_suspense_ids(node, out); + } + } + DynamicSpec::ComponentA(component) | DynamicSpec::ComponentB(component) => { + collect_current_suspense_ids(&component.child, out); + } + DynamicSpec::Suspense(spec) => { + out.insert(spec.id); + collect_current_suspense_ids(&spec.child, out); + } + DynamicSpec::Empty | DynamicSpec::Text(_) | DynamicSpec::Placeholder => {} + } +} + +fn collect_model_lifecycle_with_suspense_ancestor( + vnode: &VNodeSpec, + within_retaining_suspense: bool, + suspense_ids: &BTreeSet, + out: &mut LifecycleSnapshot, +) { + collect_model_template_lifecycle_with_suspense_ancestor( + &vnode.template.roots, + within_retaining_suspense, + suspense_ids, + out, + ); +} + +fn collect_model_template_lifecycle_with_suspense_ancestor( + nodes: &[TemplateNodeSpec], + within_retaining_suspense: bool, + suspense_ids: &BTreeSet, + out: &mut LifecycleSnapshot, +) { + for node in nodes { + match node { + TemplateNodeSpec::Element { children, .. } => { + collect_model_template_lifecycle_with_suspense_ancestor( + children, + within_retaining_suspense, + suspense_ids, + out, + ); + } + TemplateNodeSpec::Text(_) => {} + TemplateNodeSpec::Dynamic(dynamic) => { + collect_model_dynamic_lifecycle_with_suspense_ancestor( + dynamic, + within_retaining_suspense, + suspense_ids, + out, + ); + } + } + } +} + +fn collect_model_dynamic_lifecycle_with_suspense_ancestor( + dynamic: &DynamicSpec, + within_retaining_suspense: bool, + suspense_ids: &BTreeSet, + out: &mut LifecycleSnapshot, +) { + match dynamic { + DynamicSpec::Fragment(nodes) => { + for node in nodes { + collect_model_lifecycle_with_suspense_ancestor( + node, + within_retaining_suspense, + suspense_ids, + out, + ); + } + } + DynamicSpec::ComponentA(component) => { + if within_retaining_suspense { + add_lifecycle_key(out, LifecycleRole::ComponentA, component.id); + } + collect_model_lifecycle_with_suspense_ancestor( + &component.child, + within_retaining_suspense, + suspense_ids, + out, + ); + } + DynamicSpec::ComponentB(component) => { + if within_retaining_suspense { + add_lifecycle_key(out, LifecycleRole::ComponentB, component.id); + } + collect_model_lifecycle_with_suspense_ancestor( + &component.child, + within_retaining_suspense, + suspense_ids, + out, + ); + } + DynamicSpec::Suspense(spec) => { + collect_model_lifecycle_with_suspense_ancestor( + &spec.child, + within_retaining_suspense || suspense_ids.contains(&spec.id), + suspense_ids, + out, + ); + } + DynamicSpec::Empty | DynamicSpec::Text(_) | DynamicSpec::Placeholder => {} + } +} + +fn collect_vnode_lifecycle(vnode: &VNodeSpec, out: &mut LifecycleSnapshot) { + collect_template_lifecycle(&vnode.template.roots, out); +} + +fn collect_template_lifecycle(nodes: &[TemplateNodeSpec], out: &mut LifecycleSnapshot) { + for node in nodes { + match node { + TemplateNodeSpec::Element { children, .. } => { + collect_template_lifecycle(children, out); + } + TemplateNodeSpec::Text(_) => {} + TemplateNodeSpec::Dynamic(dynamic) => collect_dynamic_lifecycle(dynamic, out), + } + } +} + +fn collect_dynamic_lifecycle(dynamic: &DynamicSpec, out: &mut LifecycleSnapshot) { + match dynamic { + DynamicSpec::Fragment(nodes) => { + for node in nodes { + collect_vnode_lifecycle(node, out); + } + } + DynamicSpec::ComponentA(component) => { + add_lifecycle_key(out, LifecycleRole::ComponentA, component.id); + collect_vnode_lifecycle(&component.child, out); + } + DynamicSpec::ComponentB(component) => { + add_lifecycle_key(out, LifecycleRole::ComponentB, component.id); + collect_vnode_lifecycle(&component.child, out); + } + DynamicSpec::Suspense(spec) => { + add_lifecycle_key(out, LifecycleRole::SuspenseBoundary, spec.id); + add_lifecycle_key(out, LifecycleRole::SuspenseChild, spec.id); + collect_vnode_lifecycle(&spec.child, out); + } + DynamicSpec::Empty | DynamicSpec::Text(_) | DynamicSpec::Placeholder => {} + } +} + +fn add_lifecycle_key(out: &mut LifecycleSnapshot, role: LifecycleRole, id: u64) { + *out.entry(LifecycleKey { role, id }).or_insert(0) += 1; +} + +fn lifecycle_mismatch_error( + incremental: &LifecycleSnapshot, + fresh: &LifecycleSnapshot, + model: &LifecycleSnapshot, + retained_suspended: &LifecycleSnapshot, + model_suspended: &LifecycleSnapshot, +) -> String { + format!( + "incremental component lifecycle set is outside fresh/model bounds\nincremental:\n{incremental:#?}\nvisible fresh:\n{fresh:#?}\nmodel upper bound:\n{model:#?}\nretained suspended incremental:\n{retained_suspended:#?}\nmodel suspended subtree:\n{model_suspended:#?}" + ) +} + +fn render_result_to_fuzz_failure( + state: &Harness, + result: Result<(), String>, +) -> Result<(), String> { + if state.strict_renderer_errors { + result.map(|_| ()) + } else { + let _ = result; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + model::{ + AttrSpec, AttrValueSpec, DynamicKind, FragmentKeyMode, SuspenseMode, TemplateAttrSpec, + TemplateNodeKind, TemplateNodeSpec, WakeMutationSpec, + }, + ops::{EventBehaviorSpec, FragmentEdit, ListEdit, TemplateEdit}, + }; + + fn replay_ops(ops: impl IntoIterator) { + let mut harness = Harness::fresh_strict(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + fn replay_ops_with_lifecycle(ops: impl IntoIterator) { + let mut harness = Harness::fresh_strict_lifecycle(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + fn first_suspense_mode_and_wake_count(context: &HarnessContext) -> Option<(SuspenseMode, u8)> { + let model = context.read_model(); + let DynamicSpec::Suspense(spec) = first_dynamic(&model.root.template.roots)? else { + return None; + }; + Some((spec.mode, spec.ready_wake_count)) + } + + fn first_dynamic(nodes: &[TemplateNodeSpec]) -> Option<&DynamicSpec> { + for node in nodes { + match node { + TemplateNodeSpec::Element { children, .. } => { + if let Some(dynamic) = first_dynamic(children) { + return Some(dynamic); + } + } + TemplateNodeSpec::Text(_) => {} + TemplateNodeSpec::Dynamic(dynamic) => return Some(dynamic), + } + } + None + } + + fn set_pending_suspense_model(context: &HarnessContext) { + context.with_model(|model| *model = Model::initial()); + context.apply_to_model(&Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + )); + context.apply_to_model(&Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Pending, + }, + )); + } + + fn mount_listener_ops() -> Vec { + vec![ + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 1, + namespace: None, + value: AttrValueSpec::Listener, + volatile: false, + }, + }, + ), + Op::Rerender, + ] + } + + #[test] + fn vnode_mutation_still_compares_fresh_render() { + let mut harness = Harness::fresh_strict(); + + apply_op( + &mut harness, + &Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + ) + .unwrap(); + + apply_op(&mut harness, &Op::Rerender).unwrap(); + } + + #[test] + fn single_op_creates_dynamic_text_at_root() { + replay_ops([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Text(7)), + }, + ), + Op::Rerender, + ]); + } + + #[test] + fn single_op_creates_dynamic_component() { + replay_ops([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::ComponentA), + }, + ), + Op::Rerender, + ]); + } + + #[test] + fn single_op_creates_dynamic_fragment_with_children() { + replay_ops([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Fragment { + children: 2, + key_base: Some(10), + }), + }, + ), + Op::Rerender, + ]); + } + + #[test] + fn single_op_creates_dynamic_suspense_boundary() { + replay_ops([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }), + }, + ), + Op::Rerender, + ]); + } + + #[test] + fn single_op_creates_dynamic_listener_attr() { + let mut harness = Harness::fresh_strict(); + apply_op( + &mut harness, + &Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(vec![AttrSpec { + name: 1, + namespace: None, + value: AttrValueSpec::Listener, + volatile: false, + }]), + }, + }, + ), + ) + .unwrap(); + apply_op(&mut harness, &Op::Rerender).unwrap(); + assert_eq!( + harness + .incremental + .borrow() + .historical_event_listener_targets() + .len(), + 1 + ); + } + + #[test] + fn explicit_noop_event_fires_listener_without_rendering() { + let mut harness = Harness::fresh_strict(); + for op in mount_listener_ops() { + apply_op(&mut harness, &op).unwrap(); + } + + assert_eq!( + harness + .incremental + .borrow() + .historical_event_listener_targets() + .len(), + 1 + ); + apply_op(&mut harness, &Op::fire_event(0, EventBehaviorSpec::Noop)).unwrap(); + } + + #[test] + fn explicit_nested_event_ignores_reentrant_dispatch() { + let mut harness = Harness::fresh_strict(); + for op in mount_listener_ops() { + apply_op(&mut harness, &op).unwrap(); + } + + assert_eq!( + harness + .incremental + .borrow() + .historical_event_listener_targets() + .len(), + 1 + ); + apply_op( + &mut harness, + &Op::fire_event(0, EventBehaviorSpec::DispatchNestedEvent { target: 0 }), + ) + .unwrap(); + } + + #[test] + fn suspense_slot_mutation_still_compares_fresh_render() { + let mut harness = Harness::fresh_strict(); + + apply_op( + &mut harness, + &Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + ) + .unwrap(); + apply_op( + &mut harness, + &Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + ) + .unwrap(); + + apply_op(&mut harness, &Op::Rerender).unwrap(); + } + + #[test] + fn ready_suspense_resolves_after_configured_real_wakes() { + let mut harness = Harness::fresh_strict(); + + apply_op( + &mut harness, + &Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + ) + .unwrap(); + apply_op( + &mut harness, + &Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 1 }, + }, + ), + ) + .unwrap(); + apply_op(&mut harness, &Op::Rerender).unwrap(); + + apply_op(&mut harness, &Op::wake_suspense(0)).unwrap(); + assert!( + harness + .context + .read_model() + .selected_ready_suspense_key(0) + .is_some() + ); + assert_eq!( + first_suspense_mode_and_wake_count(&harness.context), + Some((SuspenseMode::Ready { wake_after: 1 }, 1)) + ); + + apply_op(&mut harness, &Op::wake_suspense(0)).unwrap(); + assert!( + harness + .context + .read_model() + .selected_ready_suspense_key(0) + .is_none() + ); + assert_eq!( + first_suspense_mode_and_wake_count(&harness.context), + Some((SuspenseMode::Resolved, 2)) + ); + } + + #[test] + fn waking_hidden_nested_suspense_keeps_renderer_stack_balanced() { + replay_ops([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }), + }, + ), + Op::template( + 1, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }), + }, + }, + ), + Op::Rerender, + Op::suspense(2, SuspenseMode::Pending), + Op::wake_suspense(4), + ]); + } + + #[test] + fn resolved_suspense_with_edited_child_matches_fresh_render() { + replay_ops([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::Rerender, + Op::suspense(240, SuspenseMode::Resolved), + Op::dynamic(1, 51, DynamicKind::ComponentA), + Op::Rerender, + ]); + } + + #[test] + fn removing_root_after_resolving_nested_suspense_drops_stale_component_state() { + replay_ops_with_lifecycle([ + Op::template( + 0, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 1, + item: TemplateNodeKind::Dynamic(DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 90 }, + }), + }, + }, + ), + Op::template( + 123, + TemplateEdit::SetNode { + node: 183, + kind: TemplateNodeKind::Dynamic(DynamicKind::Fragment { + children: 48, + key_base: None, + }), + }, + ), + Op::Rerender, + Op::template( + 133, + TemplateEdit::SetNode { + node: 202, + kind: TemplateNodeKind::Dynamic(DynamicKind::Suspense { + mode: SuspenseMode::Pending, + }), + }, + ), + Op::template( + 4, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 1, + item: TemplateNodeKind::Dynamic(DynamicKind::ComponentA), + }, + }, + ), + Op::wake_suspense(97), + Op::template( + 12, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::ComponentA), + }, + ), + Op::template( + 100, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 16, + item: TemplateNodeKind::Dynamic(DynamicKind::Suspense { + mode: SuspenseMode::Pending, + }), + }, + }, + ), + Op::wake_suspense(50), + Op::template( + 11, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::ComponentB), + }, + ), + Op::wake_suspense(117), + Op::template( + 45, + TemplateEdit::SetNode { + node: 9, + kind: TemplateNodeKind::Dynamic(DynamicKind::Suspense { + mode: SuspenseMode::Pending, + }), + }, + ), + Op::Rerender, + Op::template( + 0, + TemplateEdit::Roots { + edit: ListEdit::Remove { index: 95 }, + }, + ), + Op::Rerender, + ]); + } + + #[test] + fn lifecycle_oracle_rejects_stale_component_outside_unresolved_suspense() { + let context = HarnessContext::new(); + context.lifecycle.reset_all(); + set_pending_suspense_model(&context); + + let stale_key = LifecycleKey { + role: LifecycleRole::ComponentA, + id: 99, + }; + let incremental = LifecycleSnapshot::from([(stale_key, 1)]); + let fresh = LifecycleSnapshot::new(); + let model = expected_model_lifecycle_snapshot(&context); + + assert!(!lifecycle_is_within_expected_bounds( + &context, + &incremental, + &fresh, + &model + )); + } + + #[test] + fn lifecycle_oracle_allows_stale_component_inside_unresolved_suspense() { + let context = HarnessContext::new(); + context.lifecycle.reset_all(); + set_pending_suspense_model(&context); + + let _guard = context.lifecycle.with_run(LifecycleRun::Incremental, || { + context.lifecycle.track(LifecycleRole::ComponentA, 99, &[0]) + }); + let incremental = context.lifecycle.snapshot(LifecycleRun::Incremental); + let fresh = LifecycleSnapshot::new(); + let model = expected_model_lifecycle_snapshot(&context); + + assert!(lifecycle_is_within_expected_bounds( + &context, + &incremental, + &fresh, + &model + )); + } + + // Regression test for a panic in `SuspenseContext::remove_suspended_task` when + // a nested suspense boundary was unmounted while a child task was still suspended. + // The boundary scope was dropped before the task cleanup ran, so `needs_update` + // unwrapped a `None` scope state. + #[test] + fn unmounting_nested_pending_suspense_does_not_panic_on_drop() { + replay_ops([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }, + ), + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 1, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::Rerender, + Op::suspense(0, SuspenseMode::Pending), + Op::dynamic(1, 0, DynamicKind::Placeholder), + Op::Rerender, + Op::suspense(0, SuspenseMode::Resolved), + Op::Rerender, + ]); + } + + #[test] + fn replacing_root_component_with_fragment_removes_old_subtree() { + replay_ops([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic(0, 0, DynamicKind::ComponentA), + Op::Rerender, + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::Rerender, + ]); + } + + #[test] + fn keyed_fragment_move_with_component_child_skips_placeholder_root() { + replay_ops([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic(1, 0, DynamicKind::ComponentA), + Op::fragment( + 0, + 0, + FragmentEdit::KeyMode(FragmentKeyMode::Keyed { base: 0 }), + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::Rerender, + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Move { from: 1, to: 0 }), + ), + Op::Rerender, + ]); + } + + #[test] + fn hidden_suspense_diff_drops_removed_generated_component() { + replay_ops_with_lifecycle([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }, + ), + Op::template( + 1, + TemplateEdit::Children { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::template( + 1, + TemplateEdit::Children { + element: 0, + edit: ListEdit::Insert { + index: 1, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::dynamic( + 1, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Pending, + }, + ), + Op::dynamic(1, 1, DynamicKind::ComponentA), + Op::Rerender, + Op::template( + 1, + TemplateEdit::Children { + element: 0, + edit: ListEdit::Remove { index: 1 }, + }, + ), + Op::Rerender, + ]); + } + + #[test] + fn reused_component_scope_updates_lifecycle_identity() { + replay_ops_with_lifecycle([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 51, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic(0, 0, DynamicKind::ComponentA), + Op::Rerender, + Op::Rerender, + Op::Rerender, + Op::dynamic(98, 73, DynamicKind::Empty), + Op::dynamic(0, 0, DynamicKind::ComponentA), + Op::Rerender, + ]); + } + + #[test] + fn pending_parent_may_retain_rendered_nested_suspense_lifecycle() { + replay_ops_with_lifecycle([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 195, + 186, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::Rerender, + Op::Rerender, + Op::Rerender, + Op::Rerender, + Op::template( + 1, + TemplateEdit::SetNode { + node: 207, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::Rerender, + Op::dynamic( + 39, + 114, + DynamicKind::Suspense { + mode: SuspenseMode::Pending, + }, + ), + Op::Rerender, + Op::wake_suspense(4), + Op::Rerender, + Op::wake_suspense(210), + Op::Rerender, + Op::suspense(0, SuspenseMode::Pending), + Op::Rerender, + ]); + } + + #[test] + fn suspense_child_helper_overlap_does_not_fail_lifecycle_oracle() { + replay_ops_with_lifecycle([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::Rerender, + Op::dynamic( + 195, + 186, + DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }, + ), + Op::Rerender, + Op::Rerender, + Op::Rerender, + Op::Rerender, + Op::template( + 1, + TemplateEdit::SetNode { + node: 207, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::Rerender, + Op::Rerender, + Op::dynamic( + 1, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Pending, + }, + ), + Op::wake_suspense(130), + Op::wake_suspense(167), + Op::Rerender, + Op::suspense(245, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + Op::suspense(0, SuspenseMode::Pending), + Op::Rerender, + ]); + } + + #[test] + fn resolving_parent_reuses_mounted_nested_suspense_children() { + replay_ops_with_lifecycle([ + Op::template( + 50, + TemplateEdit::SetNode { + node: 196, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic(109, 211, DynamicKind::ComponentB), + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 15, + 170, + DynamicKind::Suspense { + mode: SuspenseMode::Pending, + }, + ), + Op::template( + 2, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 2, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }, + ), + Op::template( + 47, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 20, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::Rerender, + Op::dynamic(3, 0, DynamicKind::ComponentB), + Op::suspense(124, SuspenseMode::Resolved), + Op::Rerender, + Op::suspense(23, SuspenseMode::Ready { wake_after: 0 }), + Op::wake_suspense(50), + ]); + } + + #[test] + fn hidden_template_replace_drops_unmounted_component_state() { + replay_ops_with_lifecycle([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Pending, + }, + ), + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::template( + 1, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 16, + item: TemplateNodeKind::Text(88), + }, + }, + ), + Op::dynamic(1, 0, DynamicKind::ComponentB), + Op::Rerender, + Op::suspense(0, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + Op::suspense_wake_mutation(0, WakeMutationSpec::PrependStaticRoot { tag: 127 }), + Op::Rerender, + Op::wake_suspense(0), + Op::suspense_wake_mutation(0, WakeMutationSpec::None), + Op::Rerender, + ]); + } + + #[test] + fn suspended_component_may_retain_previous_generated_child() { + replay_ops_with_lifecycle([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic(1, 0, DynamicKind::ComponentA), + Op::Rerender, + Op::wake_suspense(0), + Op::dynamic( + 1, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::suspense(0, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + ]); + } + + #[test] + fn nested_ready_rewake_may_retain_current_generated_child() { + replay_ops_with_lifecycle([ + Op::template( + 50, + TemplateEdit::SetNode { + node: 189, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 15, + 170, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::template( + 2, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic(2, 0, DynamicKind::ComponentA), + Op::suspense(83, SuspenseMode::Pending), + Op::wake_suspense(0), + Op::Rerender, + Op::suspense(204, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + Op::wake_suspense(2), + Op::suspense(31, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + Op::Rerender, + Op::suspense(2, SuspenseMode::Ready { wake_after: 0 }), + Op::wake_suspense(0), + Op::Rerender, + Op::wake_suspense(50), + ]); + } + + #[test] + fn suspending_updated_child_drops_previous_generated_output() { + replay_ops_with_lifecycle([ + Op::template( + 50, + TemplateEdit::SetNode { + node: 84, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::Rerender, + Op::dynamic(1, 0, DynamicKind::ComponentB), + Op::Rerender, + Op::wake_suspense(164), + Op::dynamic(0, 0, DynamicKind::ComponentB), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::Rerender, + ]); + } + + #[test] + fn stale_suspended_output_reclaim_is_idempotent() { + replay_ops_with_lifecycle([ + Op::template( + 50, + TemplateEdit::SetNode { + node: 2, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::Rerender, + Op::Rerender, + Op::wake_suspense(104), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::wake_suspense(94), + Op::Rerender, + Op::suspense(50, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + Op::wake_suspense(120), + Op::template( + 3, + TemplateEdit::Roots { + edit: ListEdit::Remove { index: 3 }, + }, + ), + Op::dynamic(2, 0, DynamicKind::Text(7)), + Op::Rerender, + ]); + } + + #[test] + fn anchor_only_root_fragment_child_materializes_before_sibling() { + replay_ops([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::Rerender, + Op::dynamic(1, 0, DynamicKind::Text(0)), + Op::Rerender, + ]); + } + + #[test] + fn replacing_root_component_with_static_text_uses_root_anchor() { + replay_ops([ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic(0, 0, DynamicKind::ComponentA), + Op::Rerender, + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Text(0), + }, + ), + Op::Rerender, + ]); + } + + #[test] + fn stale_event_after_listener_removal_is_noop() { + let ops = [ + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 0, + namespace: None, + value: AttrValueSpec::Listener, + volatile: false, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs(0, 0, ListEdit::Remove { index: 0 }), + Op::Rerender, + Op::Rerender, + ]; + + let mut harness = Harness::fresh_strict(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + assert_eq!( + harness + .incremental + .borrow() + .historical_event_listener_targets() + .len(), + 1 + ); + fire_historical_event_listeners(&harness).unwrap(); + } + + #[test] + fn stale_event_after_listener_element_removal_is_noop() { + let ops = [ + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 0, + namespace: None, + value: AttrValueSpec::Listener, + volatile: false, + }, + }, + ), + Op::Rerender, + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Text(0), + }, + ), + Op::Rerender, + Op::Rerender, + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + assert_eq!( + harness + .incremental + .borrow() + .historical_event_listener_targets() + .len(), + 1 + ); + fire_historical_event_listeners(&harness).unwrap(); + } + + #[test] + fn suspense_replay_does_not_duplicate_promoted_children() { + let ops = [ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }, + ), + Op::template( + 3, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 7, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::Rerender, + Op::suspense(0, SuspenseMode::Pending), + Op::template( + 7, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::Rerender, + Op::suspense(0, SuspenseMode::Resolved), + Op::wake_suspense(0), + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn suspense_wake_after_parent_root_insert_does_not_duplicate_promoted_children() { + let ops = [ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }, + ), + Op::template( + 3, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 7, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::Rerender, + Op::suspense(0, SuspenseMode::Pending), + Op::template( + 7, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::Rerender, + Op::suspense(0, SuspenseMode::Resolved), + Op::Rerender, + Op::template( + 0, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::wake_suspense(0), + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn nested_suspense_wake_after_parent_attr_and_child_edit_does_not_duplicate_children() { + let ops = [ + Op::template( + 0, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }, + ), + Op::template( + 3, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 7, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::Rerender, + Op::suspense(0, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::wake_suspense(0), + Op::template( + 0, + TemplateEdit::Children { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::Rerender, + Op::wake_suspense(0), + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn waker_wake_unmounted_ready_suspense_is_noop() { + let ops = [ + Op::template( + 3, + TemplateEdit::Children { + element: 0, + edit: ListEdit::Insert { + index: 5, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::dynamic( + 5, + 2, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::wake_suspense(3), + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn waker_wake_after_unrendered_parent_edit_matches_fresh_model() { + let ops = [ + Op::template( + 2, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 4, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::dynamic( + 6, + 4, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::Rerender, + Op::template( + 2, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 5, + item: TemplateNodeKind::Text(110), + }, + }, + ), + Op::wake_suspense(0), + Op::Rerender, + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn waker_wake_nested_suspense_applies_hidden_wake_mutation() { + let ops = [ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }, + ), + Op::template( + 3, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 7, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::suspense_wake_mutation(1, WakeMutationSpec::PrependStaticRoot { tag: 42 }), + Op::Rerender, + Op::suspense(0, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + Op::wake_suspense(1), + Op::wake_suspense(0), + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn nested_suspense_wake_with_prepended_root_does_not_use_cleared_mount_id() { + let ops = [ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::Rerender, + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 1, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::wake_suspense(0), + Op::suspense_wake_mutation(1, WakeMutationSpec::PrependStaticRoot { tag: 0 }), + Op::Rerender, + Op::wake_suspense(0), + ]; + + let mut harness = Harness::fresh_strict(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn removing_suspended_empty_fragment_does_not_reclaim_live_fallback_id() { + let ops = [ + Op::template( + 223, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::Rerender, + Op::dynamic( + 109, + 103, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::Rerender, + Op::Rerender, + Op::wake_suspense(34), + Op::suspense(22, SuspenseMode::Pending), + Op::Rerender, + Op::Rerender, + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 1, + item: None, + }), + ), + Op::Rerender, + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 2, + item: None, + }), + ), + Op::Rerender, + Op::dynamic(0, 0, DynamicKind::Empty), + Op::Rerender, + ]; + + let mut harness = Harness::fresh_strict(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn template_hash_distinguishes_root_sibling_from_nested_child() { + let ops = [ + Op::template( + 0, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::template( + 0, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::template( + 0, + TemplateEdit::Roots { + edit: ListEdit::Remove { index: 0 }, + }, + ), + Op::template( + 0, + TemplateEdit::SetNode { + node: 5, + kind: TemplateNodeKind::Text(36), + }, + ), + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Element { + tag: 0, + namespace: None, + }, + }, + ), + Op::Rerender, + Op::template( + 0, + TemplateEdit::Roots { + edit: ListEdit::Remove { index: 1 }, + }, + ), + Op::template( + 0, + TemplateEdit::Children { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Text(36), + }, + }, + ), + Op::Rerender, + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn dynamic_attribute_shadowing_survives_no_change_rerender() { + let ops = [ + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::dynamic_attrs( + 0, + 7, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 0, + namespace: None, + value: AttrValueSpec::Int(0), + volatile: false, + }, + }, + ), + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 0, + namespace: None, + value: AttrValueSpec::None, + volatile: true, + }, + }, + ), + Op::Rerender, + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn removing_none_dynamic_attr_restores_static_template_attr() { + replay_ops([ + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Static { + name: 209, + value: 0, + namespace: None, + }, + }, + }, + ), + Op::template( + 195, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::dynamic_attrs( + 108, + 137, + ListEdit::Insert { + index: 142, + item: AttrSpec { + name: 209, + namespace: None, + value: AttrValueSpec::None, + volatile: true, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs(0, 185, ListEdit::Remove { index: 2 }), + Op::Rerender, + ]); + } + + #[test] + fn dynamic_attr_namespace_change_removes_old_namespace() { + replay_ops([ + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 49, + namespace: None, + value: AttrValueSpec::Float(0), + volatile: false, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 49, + namespace: Some(122), + value: AttrValueSpec::Text(48), + volatile: false, + }, + }, + ), + Op::Rerender, + ]); + } + + #[test] + fn later_dynamic_attr_slot_shadows_earlier_slot() { + replay_ops([ + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 0, + namespace: None, + value: AttrValueSpec::Text(50), + volatile: false, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs(0, 0, ListEdit::Remove { index: 0 }), + Op::Rerender, + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 1, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs( + 0, + 1, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 0, + namespace: None, + value: AttrValueSpec::Text(195), + volatile: false, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 0, + namespace: None, + value: AttrValueSpec::Any(229), + volatile: true, + }, + }, + ), + Op::Rerender, + ]); + } + + #[test] + fn later_none_dynamic_attr_slot_shadows_earlier_slot() { + replay_ops([ + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::dynamic_attrs( + 0, + 67, + ListEdit::Insert { + index: 5, + item: AttrSpec { + name: 0, + namespace: None, + value: AttrValueSpec::None, + volatile: false, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 0, + namespace: None, + value: AttrValueSpec::Int(114), + volatile: false, + }, + }, + ), + Op::Rerender, + ]); + } + + #[test] + fn root_dynamic_suspense_then_static_text_survives_no_change_rerender() { + let ops = [ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 206, + 3, + DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }, + ), + Op::template( + 5, + TemplateEdit::SetNode { + node: 2, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::Rerender, + Op::template( + 0, + TemplateEdit::SetNode { + node: 3, + kind: TemplateNodeKind::Text(0), + }, + ), + Op::Rerender, + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn nested_suspense_slot_static_child_survives_no_change_rerender() { + let ops = [ + Op::template( + 0, + TemplateEdit::Children { + element: 7, + edit: ListEdit::Insert { + index: 16, + item: TemplateNodeKind::Text(68), + }, + }, + ), + Op::template( + 5, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 1, + item: TemplateNodeKind::Text(24), + }, + }, + ), + Op::template( + 1, + TemplateEdit::SetNode { + node: 143, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::template( + 3, + TemplateEdit::Children { + element: 3, + edit: ListEdit::Insert { + index: 6, + item: TemplateNodeKind::Element { + tag: 66, + namespace: None, + }, + }, + }, + ), + Op::dynamic( + 4, + 4, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::template( + 7, + TemplateEdit::SetNode { + node: 7, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::template( + 88, + TemplateEdit::SetNode { + node: 6, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::template( + 0, + TemplateEdit::Children { + element: 1, + edit: ListEdit::Insert { + index: 5, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::dynamic(4, 2, DynamicKind::ComponentB), + Op::wake_suspense(120), + Op::dynamic( + 1, + 5, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::template( + 6, + TemplateEdit::SetNode { + node: 7, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::wake_suspense(4), + Op::template( + 5, + TemplateEdit::SetNode { + node: 7, + kind: TemplateNodeKind::Element { + tag: 0, + namespace: Some(0), + }, + }, + ), + Op::Rerender, + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn nested_suspense_wake_replaces_inner_fallback_root() { + let ops = [ + Op::template( + 183, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::dynamic( + 0, + 1, + DynamicKind::Suspense { + mode: SuspenseMode::Pending, + }, + ), + Op::template( + 7, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 1, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::suspense(4, SuspenseMode::Resolved), + Op::dynamic( + 3, + 2, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::Rerender, + Op::suspense(0, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + Op::suspense(1, SuspenseMode::Resolved), + Op::wake_suspense(2), + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn nested_ready_wake_while_parent_enters_suspense_keeps_renderer_stack_balanced() { + let ops = [ + Op::template( + 0, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 68, + item: TemplateNodeKind::Text(94), + }, + }, + ), + Op::template( + 50, + TemplateEdit::SetNode { + node: 189, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 0, + 0, + DynamicKind::Suspense { + mode: SuspenseMode::Ready { wake_after: 0 }, + }, + ), + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::dynamic( + 15, + 170, + DynamicKind::Suspense { + mode: SuspenseMode::Resolved, + }, + ), + Op::template( + 2, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::wake_suspense(6), + Op::dynamic(2, 0, DynamicKind::ComponentB), + Op::Rerender, + Op::template( + 2, + TemplateEdit::Roots { + edit: ListEdit::Remove { index: 97 }, + }, + ), + Op::suspense(31, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + Op::suspense(240, SuspenseMode::Ready { wake_after: 0 }), + Op::wake_suspense(197), + ]; + + let mut harness = Harness::fresh_strict(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn keyed_fragment_moves_nested_child_after_component_insert() { + let ops = [ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::fragment( + 0, + 0, + FragmentEdit::KeyMode(FragmentKeyMode::Keyed { base: 0 }), + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::template( + 6, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::template( + 7, + TemplateEdit::Children { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::fragment( + 177, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::Rerender, + Op::dynamic(2, 0, DynamicKind::ComponentA), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Move { from: 3, to: 2 }), + ), + Op::Rerender, + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } + + #[test] + fn keyed_fragment_remove_after_anchor_only_child_move_keeps_parent_links() { + let ops = [ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::fragment( + 0, + 0, + FragmentEdit::KeyMode(FragmentKeyMode::Keyed { base: 0 }), + ), + Op::template( + 6, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::Rerender, + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Move { from: 3, to: 2 }), + ), + Op::fragment(0, 0, FragmentEdit::Children(ListEdit::Remove { index: 0 })), + Op::Rerender, + ]; + + let mut harness = Harness::fresh(); + for op in ops { + apply_op(&mut harness, &op).unwrap(); + } + } +} diff --git a/packages/fuzz/src/lib.rs b/packages/fuzz/src/lib.rs new file mode 100644 index 0000000000..8eed08c0e6 --- /dev/null +++ b/packages/fuzz/src/lib.rs @@ -0,0 +1,1793 @@ +//! Reusable Dioxus VirtualDom fuzzing harness. +//! +//! The `cargo-fuzz` target feeds encoded [`FuzzCase`] values into this crate. +//! LibFuzzer owns coverage guidance and corpus management; this crate owns the +//! structured operation stream and renderer oracle. +#![deny(unsafe_code)] + +mod cache; +mod context; +mod diagnostics; +mod event; +mod harness; +mod lifecycle; +mod model; +mod ops; +mod reducer; +mod vdom; + +use diagnostics::panic_message; +use harness::{Harness, apply_step, print_ssr_diff_trace}; +use model::{ + AttrSpec, AttrValueSpec, DynamicKind, DynamicSpec, FragmentKeyMode, Model, SuspenseMode, + TemplateAttrSpec, TemplateNodeKind, TemplateNodeSpec, VNodeSpec, WakeMutationSpec, +}; +use mutatis::{Candidates, Generate, Mutate, Result as MutatisResult, Session}; +use ops::{EventBehaviorSpec, FragmentEdit, ListEdit, Op, TemplateEdit}; +pub use reducer::ReductionOptions; +use reducer::{random_multistep_shrink_case, simplified_ops}; +use serde::{Deserialize, Serialize}; +use std::{ + fmt, + panic::{self, AssertUnwindSafe}, +}; + +const MAX_STEPS: usize = 512; +const PRIMITIVE_MUTATION_COUNT: u32 = 20; + +/// Fold every attribute name into a 16-slot pool so static and dynamic +/// attributes on the same element collide on the same `(name, namespace)` +/// key often enough for `remove_attribute_or_write_fallback` to fire. +pub(crate) const ATTR_NAME_POOL_MASK: u8 = 0x0F; + +pub struct FuzzCase { + ops: Vec, +} + +impl FuzzCase { + pub(crate) fn new(mut ops: Vec) -> Self { + ops.truncate(MAX_STEPS); + Self { ops } + } + + fn normalize(&mut self) { + self.ops.truncate(MAX_STEPS); + } + + fn clone_case(&self) -> Self { + Self { + ops: self.ops.clone(), + } + } +} + +impl Default for FuzzCase { + fn default() -> Self { + Self::new(Vec::new()) + } +} + +#[derive(Clone, Debug, Default)] +struct FuzzCaseMutator; + +impl Mutate for FuzzCaseMutator { + fn mutate( + &mut self, + candidates: &mut Candidates<'_>, + case: &mut FuzzCase, + ) -> MutatisResult<()> { + if candidates.shrink() { + return shrink_case(candidates, case); + } + + if case.ops.len() < MAX_STEPS { + candidates.mutation(|context| { + let index = context.rng().gen_index(case.ops.len() + 1).unwrap(); + let mut op_mutator = mutatis::mutators::default::(); + let op = op_mutator.generate(context)?; + case.ops.insert(index, op); + Ok(()) + })?; + } + + candidates.mutation_group(PRIMITIVE_MUTATION_COUNT, |context, which| { + splice_primitive_op(context, case, which); + Ok(()) + })?; + + if !case.ops.is_empty() { + candidates.mutation(|context| { + let index = context.rng().gen_index(case.ops.len()).unwrap(); + case.ops.remove(index); + Ok(()) + })?; + } + + if case.ops.len() >= 2 { + candidates.mutation(|context| { + let left = context.rng().gen_index(case.ops.len()).unwrap(); + let right = context.rng().gen_index(case.ops.len()).unwrap(); + case.ops.swap(left, right); + Ok(()) + })?; + } + + let mut op_mutator = mutatis::mutators::default::(); + for op in &mut case.ops { + op_mutator.mutate(candidates, op)?; + } + + case.normalize(); + + Ok(()) + } +} + +pub fn mutate_case( + case: &mut FuzzCase, + seed: u32, + shrink: bool, + additional_mutations: usize, +) -> bool { + let mut session = Session::new().seed(seed.into()).shrink(shrink); + let mut mutator = FuzzCaseMutator; + + if session.mutate_with(&mut mutator, case).is_err() { + return false; + } + + for _ in 0..additional_mutations { + if session.mutate_with(&mut mutator, case).is_err() { + break; + } + } + + case.normalize(); + true +} + +fn replay_model_prefix(ops: &[Op], len: usize) -> Model { + let mut model = Model::initial(); + for op in ops.iter().take(len) { + ops::apply_strategy_op_to_model(&mut model, op); + } + model +} + +fn splice_primitive_op(context: &mut mutatis::Context, case: &mut FuzzCase, which: u32) { + let index = context.rng().gen_index(case.ops.len() + 1).unwrap(); + let model = replay_model_prefix(&case.ops, index); + let selector = context.rng().gen_u8(); + let value = context.rng().gen_u8(); + let ops = biased_primitive_op_sequence(&model, which, selector, value); + for (offset, op) in ops.into_iter().enumerate() { + if case.ops.len() < MAX_STEPS { + case.ops.insert(index + offset, op); + } else { + let replace = (index + offset).min(case.ops.len() - 1); + case.ops[replace] = op; + } + } +} + +fn biased_primitive_op_sequence(model: &Model, which: u32, selector: u8, value: u8) -> Vec { + if which == 19 { + if let Some(ops) = collision_aliasing_sequence(model, selector, value) { + return ops; + } + } + vec![biased_primitive_op(model, which, selector, value)] +} + +fn fragment_insert_key(fragment: FragmentShape, value: u8) -> Option { + fragment + .keyed + .then_some(value.wrapping_add(fragment.len.min(u8::MAX as usize) as u8)) +} + +#[cfg(test)] +fn dynamic_attribute_static_fallback_recipe() -> Vec { + vec![ + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Static { + name: 33, + value: 129, + namespace: None, + }, + }, + }, + ), + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 1, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 33, + namespace: None, + value: AttrValueSpec::Text(2), + volatile: false, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs(0, 0, ListEdit::Remove { index: 0 }), + Op::Rerender, + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 33, + namespace: None, + value: AttrValueSpec::Text(1), + volatile: false, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs(0, 0, ListEdit::Remove { index: 0 }), + Op::Rerender, + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 33, + namespace: None, + value: AttrValueSpec::Int(3), + volatile: false, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs(0, 0, ListEdit::Remove { index: 0 }), + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 33, + namespace: None, + value: AttrValueSpec::Bool(true), + volatile: false, + }, + }, + ), + Op::Rerender, + ] +} + +fn biased_primitive_op(model: &Model, which: u32, selector: u8, value: u8) -> Op { + let facts = ModelFacts::new(model); + let vnode = facts.select_focus_vnode(selector, value); + let node = facts.select_node(vnode, value); + let element = facts.select_element(vnode, value); + match which { + 0 if model.can_grow() => Op::template( + vnode, + TemplateEdit::SetNode { + node, + kind: biased_template_node_kind(value), + }, + ), + 1 if model.can_grow() => Op::template( + vnode, + TemplateEdit::Roots { + edit: ListEdit::Insert { + index: biased_index(value, facts.root_count(vnode)), + item: biased_template_node_kind(value), + }, + }, + ), + 2 => Op::template( + vnode, + TemplateEdit::Roots { + edit: remove_or_move_list_edit(facts.root_count(vnode), selector, value), + }, + ), + 3 if model.can_grow() => Op::template( + vnode, + TemplateEdit::Children { + element, + edit: ListEdit::Insert { + index: biased_index(value, facts.child_count(vnode, element)), + item: biased_template_node_kind(value), + }, + }, + ), + 4 => Op::template( + vnode, + TemplateEdit::Children { + element, + edit: remove_or_move_list_edit(facts.child_count(vnode, element), selector, value), + }, + ), + 5 if model.can_grow() => Op::template( + vnode, + TemplateEdit::Attrs { + element, + edit: ListEdit::Insert { + index: biased_index(value, facts.template_attr_count(vnode, element)), + item: biased_template_attr(value), + }, + }, + ), + 6 => Op::template( + vnode, + TemplateEdit::Attrs { + element, + edit: remove_or_move_list_edit( + facts.template_attr_count(vnode, element), + selector, + value, + ), + }, + ), + 7 => dynamic_node_op(&facts, vnode, selector, biased_fragment_dynamic_kind(value)), + 8 => dynamic_node_op(&facts, vnode, selector, biased_leaf_dynamic_kind(value)), + 9 => dynamic_node_op( + &facts, + vnode, + selector, + if value & 1 == 0 { + DynamicKind::ComponentA + } else { + DynamicKind::ComponentB + }, + ), + 10 if facts.has_dynamic_nodes() => { + let fragment = facts + .select_fragment(selector) + .unwrap_or_else(|| facts.fragment_prerequisite(selector)); + Op::fragment( + fragment.vnode, + fragment.node, + FragmentEdit::KeyMode(biased_fragment_key_mode(value)), + ) + } + 10 => dynamic_node_op(&facts, vnode, selector, biased_fragment_dynamic_kind(value)), + 11 if facts.has_dynamic_nodes() => { + edit_fragment_children_op(&facts, model.can_grow(), selector, value) + } + 11 => dynamic_node_op(&facts, vnode, selector, biased_fragment_dynamic_kind(value)), + 12 => edit_dynamic_attrs_op(&facts, model.can_grow(), vnode, element, selector, value), + 13 if facts.has_suspense() => { + Op::suspense(facts.select_suspense(selector), biased_suspense_mode(value)) + } + 13 => dynamic_node_op( + &facts, + vnode, + selector, + suspense_kind(biased_suspense_mode(value)), + ), + 14 if facts.has_suspense() => { + Op::suspense_wake_mutation(facts.select_suspense(selector), biased_wake_mutation(value)) + } + 14 => ready_suspense_node_op(&facts, vnode, selector), + 15 if facts.has_suspense() => Op::wake_suspense(facts.select_suspense(selector)), + 15 => ready_suspense_node_op(&facts, vnode, selector), + 16 => Op::fire_event( + selector, + if value & 1 == 0 { + EventBehaviorSpec::Noop + } else { + EventBehaviorSpec::DispatchNestedEvent { target: selector } + }, + ), + 17 if model.can_grow() => Op::template( + vnode, + TemplateEdit::SetNode { + node, + kind: TemplateNodeKind::Element { + tag: value, + namespace: (selector & 1 == 0).then_some(selector), + }, + }, + ), + 18 => Op::Rerender, + // Note: `which == 19` is handled specially by + // `biased_primitive_op_sequence` (it can emit a paired alias-then- + // remove sequence). If the splice path falls through to this arm + // because the model has no dynamic attribute to alias, fall back to + // a SetNode op so we still produce something useful. + 19 => Op::template( + vnode, + TemplateEdit::SetNode { + node, + kind: TemplateNodeKind::Dynamic(biased_leaf_dynamic_kind(value)), + }, + ), + _ => Op::template( + vnode, + TemplateEdit::SetNode { + node, + kind: TemplateNodeKind::Dynamic(biased_leaf_dynamic_kind(value)), + }, + ), + } +} + +/// Build the alias-then-remove sequence that drives +/// `diff_attributes::remove_attribute_or_write_fallback`. +/// +/// Step 1 inserts a *static* template attribute on the element with the same +/// resolved name as one of its existing dynamic attributes. Step 2 removes +/// the dynamic side via a `Rerender` so the diff can compare the two +/// renders, then a `DynamicAttrs::Remove` op that disposes of the colliding +/// dynamic attribute. After the next `Rerender`, the diff sees: +/// old: dynamic at K / new: dynamic gone, static at K still on template +/// → `remove_attribute_or_write_fallback` falls back to the static value. +fn collision_aliasing_sequence(model: &Model, selector: u8, value: u8) -> Option> { + let mut candidates: Vec = Vec::new(); + collect_collision_candidates(&model.root, 0, &mut 0u8, &mut candidates); + let pick = *candidates.get(selector as usize % candidates.len().max(1))?; + let alias = Op::template( + pick.vnode, + TemplateEdit::Attrs { + element: pick.element, + edit: ListEdit::Insert { + index: biased_index(value, pick.element_attr_count), + item: TemplateAttrSpec::Static { + // Copy the dynamic attribute's name byte verbatim. The + // candidate collector filters to non-listener bytes + // with high bit clear, so this resolves to + // `attr_name(name)` == the dynamic side's + // `attr_name(name)` — a real key collision. + name: pick.dynamic_name, + value: value.wrapping_add(1), + namespace: None, + }, + }, + }, + ); + // Schedule the dynamic drop right after the alias. The fuzz target + // already injects `Rerender` ops on its own; chaining alias+drop without + // explicit rerenders keeps the case short so other diff paths still get + // op budget. + let drop_dynamic = + Op::dynamic_attrs(pick.vnode, pick.dynamic_slot, ListEdit::Remove { index: 0 }); + Some(vec![alias, drop_dynamic]) +} + +#[derive(Clone, Copy)] +struct CollisionCandidate { + vnode: u8, + element: u8, + element_attr_count: usize, + dynamic_slot: u8, + dynamic_name: u8, +} + +fn collect_collision_candidates( + vnode: &VNodeSpec, + vnode_index_hint: u8, + next_vnode_index: &mut u8, + out: &mut Vec, +) { + let vnode_index = vnode_index_hint; + // Track the depth-first element index within this vnode and the global + // dynamic-attr slot counter to match `ModelFacts::select_element` and the + // `attr` numbering consumed by `selected_dynamic_attr_mut`. + let mut element_index: u8 = 0; + let mut dynamic_slot: u8 = 0; + walk_template_for_collisions( + vnode_index, + &vnode.template.roots, + &mut element_index, + &mut dynamic_slot, + out, + ); + + // Recurse into nested vnodes produced by fragments / suspense children so + // we can also target attributes inside those subtrees. The numbering + // matches `ModelFacts::collect_vnode`'s pre-order traversal. + walk_dynamic_for_nested_vnodes(&vnode.template.roots, next_vnode_index, out); +} + +fn walk_template_for_collisions( + vnode: u8, + nodes: &[TemplateNodeSpec], + element_index: &mut u8, + dynamic_slot: &mut u8, + out: &mut Vec, +) { + for node in nodes { + if let TemplateNodeSpec::Element { + attrs, children, .. + } = node + { + let element = *element_index; + *element_index = element_index.saturating_add(1); + + for attr in attrs { + if let TemplateAttrSpec::Dynamic(dynamic_attrs) = attr { + let slot = *dynamic_slot; + *dynamic_slot = dynamic_slot.saturating_add(1); + for dyn_attr in dynamic_attrs { + // Skip listeners (their name space is disjoint from + // static attribute names) and skip any byte whose + // high bit is set, since `dynamic_attr_name` routes + // those through the listener naming path regardless + // of the AttrValueSpec variant. + if matches!(dyn_attr.value, AttrValueSpec::Listener) + || dyn_attr.name & 0x80 != 0 + { + continue; + } + out.push(CollisionCandidate { + vnode, + element, + element_attr_count: attrs.len(), + dynamic_slot: slot, + dynamic_name: dyn_attr.name, + }); + } + } + } + + walk_template_for_collisions(vnode, children, element_index, dynamic_slot, out); + } + } +} + +fn walk_dynamic_for_nested_vnodes( + nodes: &[TemplateNodeSpec], + next_vnode_index: &mut u8, + out: &mut Vec, +) { + for node in nodes { + match node { + TemplateNodeSpec::Element { children, .. } => { + walk_dynamic_for_nested_vnodes(children, next_vnode_index, out); + } + TemplateNodeSpec::Dynamic(DynamicSpec::Fragment(children)) => { + for child in children { + *next_vnode_index = next_vnode_index.saturating_add(1); + let child_index = *next_vnode_index; + collect_collision_candidates(child, child_index, next_vnode_index, out); + } + } + _ => {} + } + } +} + +fn dynamic_node_op(facts: &ModelFacts, vnode: u8, selector: u8, kind: DynamicKind) -> Op { + Op::dynamic(vnode, facts.select_dynamic_node(vnode, selector), kind) +} + +fn ready_suspense_node_op(facts: &ModelFacts, vnode: u8, selector: u8) -> Op { + dynamic_node_op( + facts, + vnode, + selector, + suspense_kind(SuspenseMode::Ready { wake_after: 0 }), + ) +} + +fn edit_fragment_children_op(facts: &ModelFacts, can_grow: bool, selector: u8, value: u8) -> Op { + let fragment = facts + .select_fragment(selector) + .unwrap_or_else(|| facts.fragment_prerequisite(selector)); + let edit = match value % 3 { + 0 if can_grow => ListEdit::Insert { + index: biased_index(value, fragment.len), + item: fragment_insert_key(fragment, value), + }, + 1 if fragment.len > 0 => ListEdit::Remove { + index: biased_existing_index(value, fragment.len), + }, + 2 if fragment.len >= 2 => ListEdit::Move { + from: biased_existing_index(selector, fragment.len), + to: biased_index(value, fragment.len), + }, + _ if can_grow => ListEdit::Insert { + index: 0, + item: fragment_insert_key(fragment, value), + }, + _ => ListEdit::Remove { index: 0 }, + }; + + Op::fragment(fragment.vnode, fragment.node, FragmentEdit::Children(edit)) +} + +fn edit_dynamic_attrs_op( + facts: &ModelFacts, + can_grow: bool, + vnode: u8, + element: u8, + selector: u8, + value: u8, +) -> Op { + let Some(attr) = facts.select_attr_slot(selector) else { + return prerequisite_dynamic_attr_op(facts, vnode, element, value); + }; + + let edit = match value % 3 { + 0 => ListEdit::Insert { + index: biased_index(value, attr.len), + item: biased_attr(value), + }, + 1 if attr.len > 0 => ListEdit::Remove { + index: biased_existing_index(value, attr.len), + }, + 2 if attr.len >= 2 => ListEdit::Move { + from: biased_existing_index(selector, attr.len), + to: biased_index(value, attr.len), + }, + _ if can_grow => ListEdit::Insert { + index: biased_index(value, attr.len), + item: biased_attr(value), + }, + _ => ListEdit::Remove { index: 0 }, + }; + + Op::dynamic_attrs(attr.vnode, attr.slot, edit) +} + +fn prerequisite_dynamic_attr_op(facts: &ModelFacts, vnode: u8, element: u8, value: u8) -> Op { + Op::template( + vnode, + TemplateEdit::Attrs { + element, + edit: ListEdit::Insert { + index: biased_index(value, facts.template_attr_count(vnode, element)), + item: TemplateAttrSpec::Dynamic(vec![biased_attr(value)]), + }, + }, + ) +} + +#[derive(Clone, Copy)] +struct FragmentShape { + vnode: u8, + node: u8, + len: usize, + keyed: bool, +} + +#[derive(Clone, Copy)] +struct AttrShape { + vnode: u8, + slot: u8, + len: usize, +} + +#[derive(Default)] +struct VNodeShape { + roots: usize, + nodes: usize, + elements: Vec, + dynamic_nodes: Vec, +} + +#[derive(Clone)] +struct ElementShape { + children: usize, + attrs: usize, +} + +#[derive(Default)] +struct ModelFacts { + vnodes: Vec, + fragments: Vec, + attrs: Vec, + suspense_child_vnodes: Vec, + suspense_count: usize, +} + +impl ModelFacts { + fn new(model: &Model) -> Self { + let mut facts = Self::default(); + facts.collect_vnode(&model.root, None); + facts + } + + fn collect_vnode(&mut self, vnode: &VNodeSpec, suspense: Option) -> u8 { + let vnode_index = self.vnodes.len() as u8; + let mut elements = Vec::new(); + let mut attr_slot = 0; + self.collect_template_elements_and_attrs( + vnode_index, + &vnode.template.roots, + &mut attr_slot, + &mut elements, + ); + + self.vnodes.push(VNodeShape { + roots: vnode.template.roots.len(), + nodes: vnode.template.node_paths().len(), + elements, + dynamic_nodes: vnode + .template + .node_paths() + .into_iter() + .enumerate() + .filter_map(|(index, path)| { + matches!( + template_node_at(&vnode.template.roots, &path), + Some(TemplateNodeSpec::Dynamic(_)) + ) + .then_some(index.min(u8::MAX as usize) as u8) + }) + .collect(), + }); + + let mut dynamic_slot = 0; + self.collect_dynamic_nodes( + vnode_index, + &vnode.template.roots, + suspense, + &mut dynamic_slot, + ); + + vnode_index + } + + fn collect_template_elements_and_attrs( + &mut self, + vnode: u8, + nodes: &[TemplateNodeSpec], + slot: &mut usize, + elements: &mut Vec, + ) { + for node in nodes { + let TemplateNodeSpec::Element { + attrs, children, .. + } = node + else { + continue; + }; + + for attr in attrs { + if let TemplateAttrSpec::Dynamic(attrs) = attr { + let dynamic_slot = (*slot).min(u8::MAX as usize) as u8; + self.attrs.push(AttrShape { + vnode, + slot: dynamic_slot, + len: attrs.len(), + }); + *slot += 1; + } + } + + elements.push(ElementShape { + children: children.len(), + attrs: attrs.len(), + }); + + self.collect_template_elements_and_attrs(vnode, children, slot, elements); + } + } + + fn collect_dynamic_nodes( + &mut self, + vnode: u8, + nodes: &[TemplateNodeSpec], + suspense: Option, + slot: &mut usize, + ) { + for node in nodes { + match node { + TemplateNodeSpec::Element { children, .. } => { + self.collect_dynamic_nodes(vnode, children, suspense, slot); + } + TemplateNodeSpec::Text(_) => {} + TemplateNodeSpec::Dynamic(dynamic) => { + let current_slot = (*slot).min(u8::MAX as usize) as u8; + *slot += 1; + match dynamic { + DynamicSpec::Fragment(children) => { + for child in children { + self.collect_vnode(child, suspense); + } + self.fragments.push(FragmentShape { + vnode, + node: current_slot, + len: children.len(), + keyed: children.first().and_then(|child| child.key).is_some(), + }); + } + DynamicSpec::ComponentA(component) | DynamicSpec::ComponentB(component) => { + self.collect_vnode(&component.child, suspense); + } + DynamicSpec::Suspense(suspense) => { + let suspense_index = self.suspense_count.min(u8::MAX as usize) as u8; + self.suspense_count += 1; + let child = self.collect_vnode(&suspense.child, Some(suspense_index)); + self.suspense_child_vnodes.push(child); + } + DynamicSpec::Empty | DynamicSpec::Text(_) | DynamicSpec::Placeholder => {} + } + } + } + } + } + + fn select_vnode(&self, selector: u8) -> u8 { + select_bounded(selector, self.vnodes.len()) + } + + fn select_focus_vnode(&self, selector: u8, value: u8) -> u8 { + match value % 4 { + 0 if !self.suspense_child_vnodes.is_empty() => { + self.suspense_child_vnodes[selector as usize % self.suspense_child_vnodes.len()] + } + 1 if self.vnodes.len() > 1 => (1 + select_bounded(selector, self.vnodes.len() - 1) + as usize) + .min(u8::MAX as usize) as u8, + _ => self.select_vnode(selector), + } + } + + fn select_node(&self, vnode: u8, selector: u8) -> u8 { + select_bounded(selector, self.vnodes[vnode as usize].nodes) + } + + fn root_count(&self, vnode: u8) -> usize { + self.vnodes[vnode as usize].roots + } + + fn select_element(&self, vnode: u8, selector: u8) -> u8 { + select_bounded(selector, self.vnodes[vnode as usize].elements.len()) + } + + fn child_count(&self, vnode: u8, element: u8) -> usize { + self.vnodes[vnode as usize] + .elements + .get(element as usize) + .map(|element| element.children) + .unwrap_or(0) + } + + fn template_attr_count(&self, vnode: u8, element: u8) -> usize { + self.vnodes[vnode as usize] + .elements + .get(element as usize) + .map(|element| element.attrs) + .unwrap_or(0) + } + + fn select_dynamic_node(&self, vnode: u8, selector: u8) -> u8 { + let vnode_shape = &self.vnodes[vnode as usize]; + vnode_shape + .dynamic_nodes + .get(selector as usize % vnode_shape.dynamic_nodes.len().max(1)) + .copied() + .unwrap_or_else(|| self.select_node(vnode, selector)) + } + + fn has_dynamic_nodes(&self) -> bool { + self.vnodes + .iter() + .any(|vnode| !vnode.dynamic_nodes.is_empty()) + } + + fn select_fragment(&self, selector: u8) -> Option { + self.fragments + .get(selector as usize % self.fragments.len().max(1)) + .copied() + } + + fn fragment_prerequisite(&self, selector: u8) -> FragmentShape { + let vnode = self.select_vnode(selector); + let vnode_shape = &self.vnodes[vnode as usize]; + FragmentShape { + vnode, + node: select_bounded(selector, vnode_shape.dynamic_nodes.len()), + len: 0, + keyed: false, + } + } + + fn select_attr_slot(&self, selector: u8) -> Option { + self.attrs + .get(selector as usize % self.attrs.len().max(1)) + .copied() + } + + fn select_suspense(&self, selector: u8) -> u8 { + select_bounded(selector, self.suspense_count) + } + + fn has_suspense(&self) -> bool { + self.suspense_count > 0 + } +} + +fn template_node_at<'a>( + roots: &'a [TemplateNodeSpec], + path: &[usize], +) -> Option<&'a TemplateNodeSpec> { + let (&root, rest) = path.split_first()?; + let mut node = roots.get(root)?; + for index in rest { + let TemplateNodeSpec::Element { children, .. } = node else { + return None; + }; + node = children.get(*index)?; + } + Some(node) +} + +fn select_bounded(selector: u8, len: usize) -> u8 { + if len == 0 { + 0 + } else { + (selector as usize % len).min(u8::MAX as usize) as u8 + } +} + +fn biased_index(selector: u8, len: usize) -> u8 { + match selector % 5 { + 0 => 0, + 1 => (len / 2).min(u8::MAX as usize) as u8, + 2 => len.min(u8::MAX as usize) as u8, + 3 => len.saturating_sub(1).min(u8::MAX as usize) as u8, + _ => selector, + } +} + +fn biased_existing_index(selector: u8, len: usize) -> u8 { + biased_index(selector, len.saturating_sub(1)) +} + +fn remove_or_move_list_edit(len: usize, selector: u8, value: u8) -> ListEdit { + if selector & 1 == 0 { + ListEdit::Remove { + index: biased_existing_index(value, len), + } + } else { + ListEdit::Move { + from: biased_existing_index(selector, len), + to: biased_index(value, len), + } + } +} + +fn biased_template_node_kind(value: u8) -> TemplateNodeKind { + match value % 3 { + 0 => TemplateNodeKind::Dynamic(biased_dynamic_kind(value)), + 1 => TemplateNodeKind::Text(value), + _ => TemplateNodeKind::Element { + tag: value, + namespace: (value & 1 == 0).then_some(value.wrapping_add(1)), + }, + } +} + +fn biased_template_attr(value: u8) -> TemplateAttrSpec { + if value & 1 == 0 { + TemplateAttrSpec::Dynamic(vec![biased_attr(value)]) + } else { + TemplateAttrSpec::Static { + // Mask the name into the shared pool so this static attribute + // can collide with a dynamic attribute on the same element and + // exercise `remove_attribute_or_write_fallback`. + name: value & ATTR_NAME_POOL_MASK, + value: value.wrapping_add(1), + namespace: (value & 2 == 0).then_some(value.wrapping_add(2)), + } + } +} + +fn biased_dynamic_kind(value: u8) -> DynamicKind { + match value % 6 { + 0 => biased_leaf_dynamic_kind(value), + 1 => biased_fragment_dynamic_kind(value), + 2 => DynamicKind::ComponentA, + 3 => DynamicKind::ComponentB, + 4 => suspense_kind(biased_suspense_mode(value)), + _ => DynamicKind::Placeholder, + } +} + +fn biased_leaf_dynamic_kind(value: u8) -> DynamicKind { + match value % 3 { + 0 => DynamicKind::Text(value), + 1 => DynamicKind::Placeholder, + _ => DynamicKind::Empty, + } +} + +fn suspense_kind(mode: SuspenseMode) -> DynamicKind { + DynamicKind::Suspense { mode } +} + +fn biased_fragment_dynamic_kind(value: u8) -> DynamicKind { + DynamicKind::Fragment { + children: (value % 3).saturating_add(1), + key_base: (value & 4 != 0).then_some(value), + } +} + +fn biased_suspense_mode(value: u8) -> SuspenseMode { + match value % 3 { + 0 => SuspenseMode::Resolved, + 1 => SuspenseMode::Pending, + _ => SuspenseMode::Ready { + wake_after: value / 3, + }, + } +} + +fn biased_wake_mutation(value: u8) -> WakeMutationSpec { + if value & 1 == 0 { + WakeMutationSpec::None + } else { + WakeMutationSpec::PrependStaticRoot { tag: value } + } +} + +fn biased_fragment_key_mode(value: u8) -> FragmentKeyMode { + if value & 1 == 0 { + FragmentKeyMode::Unkeyed + } else { + FragmentKeyMode::Keyed { base: value } + } +} + +fn biased_attr(value: u8) -> AttrSpec { + let attr_value = match value % 7 { + 0 => AttrValueSpec::Text(value), + 1 => AttrValueSpec::Float(value), + 2 => AttrValueSpec::Int(value), + 3 => AttrValueSpec::Bool(value % 2 == 0), + 4 => AttrValueSpec::Any(value), + 5 => AttrValueSpec::None, + _ => AttrValueSpec::Listener, + }; + AttrSpec { + name: biased_dynamic_attr_name(&attr_value, value), + namespace: None, + value: attr_value, + volatile: false, + } +} + +fn biased_dynamic_attr_name(value: &AttrValueSpec, seed: u8) -> u8 { + // Listeners use a name format that's keyed by slot, not by this byte's + // value — leave the existing `seed & 0x7f` selection alone. + if matches!(value, AttrValueSpec::Listener) { + return seed & 0x7f; + } + + let raw = match value { + AttrValueSpec::Text(value) + | AttrValueSpec::Float(value) + | AttrValueSpec::Int(value) + | AttrValueSpec::Any(value) => *value, + AttrValueSpec::Bool(value) => u8::from(*value), + AttrValueSpec::None => 0, + AttrValueSpec::Listener => unreachable!("handled by the early return above"), + }; + + // Allow a small fraction of out-of-pool names through so the + // "no static at this key" diff path keeps getting exercised. + if seed & 0xF0 == 0xF0 { + seed + } else { + raw & ATTR_NAME_POOL_MASK + } +} + +fn shrink_case(candidates: &mut Candidates<'_>, case: &mut FuzzCase) -> MutatisResult<()> { + let len = case.ops.len(); + + if len > 1 { + candidates.mutation(|context| { + random_multistep_shrink_case(case, context.rng()); + Ok(()) + })?; + + candidates.mutation_group((len - 1) as u32, |_context, which| { + case.ops.truncate(which as usize + 1); + Ok(()) + })?; + + let chunk_sizes = chunk_delete_sizes(len); + let delete_count = chunk_sizes + .iter() + .map(|size| len.saturating_sub(*size) + 1) + .sum::(); + candidates.mutation_group(delete_count as u32, |_context, mut which| { + for size in chunk_sizes { + let starts = len - size + 1; + if which < starts as u32 { + let start = which as usize; + case.ops.drain(start..start + size); + return Ok(()); + } + which -= starts as u32; + } + Ok(()) + })?; + } + + for index in 0..len { + let replacements = simplified_ops(&case.ops[index]); + if replacements.is_empty() { + continue; + } + + candidates.mutation_group(replacements.len() as u32, |_context, which| { + case.ops[index] = replacements[which as usize].clone(); + Ok(()) + })?; + } + + let mut op_mutator = mutatis::mutators::default::(); + for op in &mut case.ops { + op_mutator.mutate(candidates, op)?; + } + + Ok(()) +} + +fn chunk_delete_sizes(len: usize) -> Vec { + let mut sizes = Vec::new(); + let mut size = len / 2; + while size > 1 { + if !sizes.contains(&size) { + sizes.push(size); + } + size /= 2; + } + sizes.push(1); + sizes +} + +#[derive(Debug)] +pub struct FuzzFailure { + step: usize, + op: String, + message: String, +} + +impl fmt::Display for FuzzFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let summary = self.message.lines().next().unwrap_or(&self.message); + write!( + f, + "fuzz case failed at step {} while applying {}: {}", + self.step, self.op, summary + ) + } +} + +pub fn format_failure_report(case: &FuzzCase, failure: &FuzzFailure) -> String { + let mut report = String::new(); + let summary = failure.message.lines().next().unwrap_or(&failure.message); + + use fmt::Write; + writeln!(&mut report, "fuzz failure").unwrap(); + writeln!(&mut report, "decoded operations: {}", case.ops.len()).unwrap(); + writeln!(&mut report, "failed at step: {}", failure.step).unwrap(); + writeln!(&mut report, "failing op: {}", failure.op).unwrap(); + writeln!(&mut report, "summary: {summary}").unwrap(); + writeln!(&mut report).unwrap(); + writeln!(&mut report, "operations:").unwrap(); + for (index, op) in case.ops.iter().enumerate() { + let marker = if index == failure.step { ">>" } else { " " }; + writeln!(&mut report, "{marker} {index:03}: {op:?}").unwrap(); + } + writeln!(&mut report).unwrap(); + writeln!(&mut report, "full error:").unwrap(); + for line in failure.message.lines() { + writeln!(&mut report, " {line}").unwrap(); + } + + report +} + +#[derive(Serialize)] +struct EncodedFuzzCase<'a> { + ops: &'a [Op], +} + +#[derive(Deserialize)] +struct DecodedFuzzCase { + ops: Vec, +} + +pub fn decode_case(data: &[u8]) -> Option { + let decoded = postcard::from_bytes::(data).ok()?; + let mut case = FuzzCase::new(decoded.ops); + case.normalize(); + Some(case) +} + +pub fn encode_case(case: &FuzzCase, data: &mut [u8], max_size: usize) -> Option { + let size = max_size.min(data.len()); + let encoded = + postcard::to_slice(&EncodedFuzzCase { ops: &case.ops }, &mut data[..size]).ok()?; + Some(encoded.len()) +} + +fn encode_case_vec(case: &FuzzCase) -> Option> { + postcard::to_allocvec(&EncodedFuzzCase { ops: &case.ops }).ok() +} + +pub fn reduce_case_to_encoded_vec( + case: &FuzzCase, + encoded_len: usize, + max_size: usize, + options: ReductionOptions, +) -> Option> { + reducer::reduce_case_to_encoded_vec(case, encoded_len, max_size, options) +} + +pub fn run_case(case: &FuzzCase) -> Result<(), FuzzFailure> { + let mut state = + panic::catch_unwind(AssertUnwindSafe(Harness::fresh)).map_err(|payload| FuzzFailure { + step: 0, + op: "".to_string(), + message: format!( + "panic before applying operation: {}", + panic_message(&payload) + ), + })?; + + for (step, op) in case.ops.iter().enumerate() { + let applied = panic::catch_unwind(AssertUnwindSafe(|| apply_step(&mut state, op))) + .map_err(|payload| FuzzFailure { + step, + op: format!("{op:?}"), + message: format!( + "panic while applying operation: {}", + panic_message(&payload) + ), + })?; + + applied.map_err(|message| FuzzFailure { + step, + op: format!("{op:?}"), + message, + })?; + } + Ok(()) +} + +pub fn print_case_trace(case: &FuzzCase, failure: &FuzzFailure) { + print_ssr_diff_trace(&case.ops, failure.step, &failure.message); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_case_roundtrips_and_replays() { + let case = FuzzCase::default(); + let mut bytes = [0; 4096]; + let size = encode_case(&case, &mut bytes, 4096).unwrap(); + let decoded = decode_case(&bytes[..size]).unwrap(); + assert_eq!(encode_case_vec(&case), encode_case_vec(&decoded)); + run_case(&decoded).unwrap(); + } + + #[test] + fn biased_primitive_op_replays() { + for which in 0..PRIMITIVE_MUTATION_COUNT { + let model = Model::initial(); + let op = biased_primitive_op(&model, which, which as u8, 128 + which as u8); + run_case(&FuzzCase::new(vec![op])).unwrap(); + } + } + + #[test] + fn primitive_dynamic_ops_from_initial_model_are_meaningful() { + let dynamic_cases = [ + (7, "fragment", 1), + (8, "leaf", 3), + (9, "component", 4), + (13, "suspense_mode", 5), + (14, "suspense_wake_mutation", 6), + (15, "wake_suspense", 7), + ]; + + for (which, name, value) in dynamic_cases { + let mut model = Model::initial(); + let op = biased_primitive_op(&model, which, 0, value); + ops::apply_strategy_op_to_model(&mut model, &op); + let dynamic = first_dynamic(&model.root.template.roots) + .unwrap_or_else(|| panic!("expected dynamic for {name}: {op:?}")); + assert!( + !matches!(dynamic, DynamicSpec::Empty), + "expected non-empty dynamic for {name}: {op:?}" + ); + } + + let mut model = Model::initial(); + let op = biased_primitive_op(&model, 12, 0, 9); + ops::apply_strategy_op_to_model(&mut model, &op); + let attrs = first_dynamic_attrs(&model.root.template.roots) + .unwrap_or_else(|| panic!("expected dynamic attrs: {op:?}")); + assert!( + !attrs.is_empty(), + "expected non-empty dynamic attrs: {op:?}" + ); + } + + fn first_dynamic(nodes: &[TemplateNodeSpec]) -> Option<&DynamicSpec> { + for node in nodes { + match node { + TemplateNodeSpec::Element { children, .. } => { + if let Some(dynamic) = first_dynamic(children) { + return Some(dynamic); + } + } + TemplateNodeSpec::Text(_) => {} + TemplateNodeSpec::Dynamic(dynamic) => return Some(dynamic), + } + } + None + } + + fn first_dynamic_attrs(nodes: &[TemplateNodeSpec]) -> Option<&[AttrSpec]> { + for node in nodes { + let TemplateNodeSpec::Element { + attrs, children, .. + } = node + else { + continue; + }; + + for attr in attrs { + if let TemplateAttrSpec::Dynamic(attrs) = attr { + return Some(attrs); + } + } + + if let Some(attrs) = first_dynamic_attrs(children) { + return Some(attrs); + } + } + None + } + + #[test] + fn biased_primitive_op_replays_after_prefix() { + let prefix = vec![ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Fragment { + children: 1, + key_base: Some(7), + }), + }, + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: Some(7), + }), + ), + Op::template( + 1, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::dynamic(1, 0, suspense_kind(SuspenseMode::Ready { wake_after: 0 })), + ]; + let model = replay_model_prefix(&prefix, prefix.len()); + for which in 0..PRIMITIVE_MUTATION_COUNT { + let mut ops = prefix.clone(); + ops.push(biased_primitive_op( + &model, + which, + 64 + which as u8, + 192 + which as u8, + )); + run_case(&FuzzCase::new(ops)).unwrap(); + } + } + + #[test] + fn targeted_diff_coverage_cases_replay() { + for (name, case) in targeted_diff_coverage_cases() { + run_case(&case).unwrap_or_else(|failure| { + panic!("targeted diff coverage case {name:?} failed: {failure}") + }); + } + } + + fn targeted_diff_coverage_cases() -> Vec<(&'static str, FuzzCase)> { + vec![ + case( + "non_keyed_append_remove_equal", + non_keyed_append_remove_equal(), + ), + case("keyed_append", keyed_append()), + case("keyed_prepend", keyed_prepend()), + case("keyed_remove_and_add_middle", keyed_remove_and_add_middle()), + case("keyed_replace_all_keys", keyed_replace_all_keys()), + case("keyed_reorder_insert_remove", keyed_reorder_insert_remove()), + case("move_static_root", move_root_node_with_kind(None)), + case( + "move_dynamic_text_root", + move_root_node_with_kind(Some(DynamicKind::Text(7))), + ), + case( + "move_dynamic_placeholder_root", + move_root_node_with_kind(Some(DynamicKind::Placeholder)), + ), + case( + "move_dynamic_fragment_root", + move_root_node_with_kind(Some(DynamicKind::Fragment { + children: 1, + key_base: None, + })), + ), + case( + "move_dynamic_component_root", + move_root_node_with_kind(Some(DynamicKind::ComponentA)), + ), + case("replace_component_render_fn", replace_component_render_fn()), + case( + "hidden_suspense_component_removal", + hidden_suspense_component_removal(), + ), + case("suspense_clear_and_reclaim", suspense_clear_and_reclaim()), + case( + "dynamic_attribute_transitions", + dynamic_attribute_transitions(), + ), + case("hidden_suspense_text_diff", { + vec![ + Op::dynamic(0, 0, suspense_kind(SuspenseMode::Resolved)), + set_vnode_root_dynamic(1, DynamicKind::Text(0)), + Op::Rerender, + Op::suspense(0, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + set_vnode_root_dynamic(1, DynamicKind::Text(1)), + Op::Rerender, + Op::wake_suspense(0), + ] + }), + case("hidden_suspense_keyed_fragment_diff", { + vec![ + Op::dynamic(0, 0, suspense_kind(SuspenseMode::Resolved)), + set_vnode_root_dynamic( + 1, + DynamicKind::Fragment { + children: 3, + key_base: Some(33), + }, + ), + Op::Rerender, + Op::suspense(0, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + insert_fragment_child(2, Some(36)), + Op::Rerender, + Op::wake_suspense(0), + ] + }), + case( + "dynamic_attribute_static_fallback", + dynamic_attribute_static_fallback_recipe(), + ), + ] + } + + fn case(name: &'static str, ops: Vec) -> (&'static str, FuzzCase) { + (name, FuzzCase::new(ops)) + } + + fn set_root_dynamic() -> Op { + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + ) + } + + fn insert_fragment_child(index: u8, key: Option) -> Op { + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { index, item: key }), + ) + } + + fn remove_fragment_child(index: u8) -> Op { + Op::fragment(0, 0, FragmentEdit::Children(ListEdit::Remove { index })) + } + + fn move_fragment_child(from: u8, to: u8) -> Op { + Op::fragment(0, 0, FragmentEdit::Children(ListEdit::Move { from, to })) + } + + fn key_fragment(base: u8) -> Op { + Op::fragment(0, 0, FragmentEdit::KeyMode(FragmentKeyMode::Keyed { base })) + } + + fn fragment_with_children(count: u8, key_base: Option) -> Op { + Op::dynamic( + 0, + 0, + DynamicKind::Fragment { + children: count, + key_base, + }, + ) + } + + fn set_vnode_root_dynamic(vnode: u8, kind: DynamicKind) -> Op { + Op::template( + vnode, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(kind), + }, + ) + } + + fn non_keyed_append_remove_equal() -> Vec { + vec![ + set_root_dynamic(), + insert_fragment_child(0, None), + insert_fragment_child(1, None), + Op::Rerender, + insert_fragment_child(2, None), + Op::Rerender, + remove_fragment_child(1), + Op::Rerender, + Op::template( + 1, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Text(11), + }, + ), + Op::Rerender, + ] + } + + fn keyed_append() -> Vec { + vec![ + set_root_dynamic(), + fragment_with_children(2, Some(0)), + Op::Rerender, + insert_fragment_child(2, Some(2)), + Op::Rerender, + ] + } + + fn keyed_prepend() -> Vec { + vec![ + set_root_dynamic(), + fragment_with_children(2, Some(1)), + Op::Rerender, + insert_fragment_child(0, Some(0)), + Op::Rerender, + ] + } + + fn keyed_remove_and_add_middle() -> Vec { + vec![ + set_root_dynamic(), + fragment_with_children(3, Some(0)), + Op::Rerender, + remove_fragment_child(1), + Op::Rerender, + insert_fragment_child(1, Some(1)), + Op::Rerender, + ] + } + + fn keyed_replace_all_keys() -> Vec { + vec![ + set_root_dynamic(), + fragment_with_children(2, Some(0)), + Op::Rerender, + key_fragment(2), + Op::Rerender, + ] + } + + fn keyed_reorder_insert_remove() -> Vec { + vec![ + set_root_dynamic(), + fragment_with_children(5, Some(0)), + Op::Rerender, + move_fragment_child(3, 1), + insert_fragment_child(2, Some(5)), + remove_fragment_child(4), + Op::Rerender, + ] + } + + fn move_root_node_with_kind(kind: Option) -> Vec { + let mut ops = vec![set_root_dynamic(), fragment_with_children(4, Some(0))]; + + if let Some(kind) = kind { + ops.push(set_vnode_root_dynamic(3, kind)); + if matches!(ops.last(), Some(Op::Mutate(_))) { + // The child vnode selected above must materialize its nested fragment + // before the keyed move so push_all_root_nodes has live roots to collect. + } + } + + ops.extend([Op::Rerender, move_fragment_child(2, 0), Op::Rerender]); + ops + } + + fn replace_component_render_fn() -> Vec { + vec![ + set_root_dynamic(), + Op::dynamic(0, 0, DynamicKind::ComponentA), + Op::Rerender, + Op::dynamic(0, 0, DynamicKind::ComponentB), + Op::Rerender, + ] + } + + fn hidden_suspense_component_removal() -> Vec { + vec![ + set_root_dynamic(), + Op::dynamic(0, 0, suspense_kind(SuspenseMode::Resolved)), + Op::template( + 1, + TemplateEdit::Children { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::template( + 1, + TemplateEdit::Children { + element: 0, + edit: ListEdit::Insert { + index: 1, + item: TemplateNodeKind::Dynamic(DynamicKind::Empty), + }, + }, + ), + Op::dynamic(1, 0, suspense_kind(SuspenseMode::Pending)), + Op::dynamic(1, 1, DynamicKind::ComponentA), + Op::Rerender, + Op::template( + 1, + TemplateEdit::Children { + element: 0, + edit: ListEdit::Remove { index: 1 }, + }, + ), + Op::Rerender, + ] + } + + fn suspense_clear_and_reclaim() -> Vec { + vec![ + set_root_dynamic(), + Op::dynamic(0, 0, suspense_kind(SuspenseMode::Ready { wake_after: 0 })), + set_vnode_root_dynamic(1, DynamicKind::Empty), + Op::Rerender, + Op::wake_suspense(0), + Op::dynamic(1, 0, DynamicKind::ComponentA), + Op::Rerender, + Op::suspense(0, SuspenseMode::Ready { wake_after: 0 }), + Op::Rerender, + Op::wake_suspense(0), + ] + } + + fn dynamic_attribute_transitions() -> Vec { + vec![ + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 0, + item: TemplateAttrSpec::Static { + name: 9, + value: 1, + namespace: None, + }, + }, + }, + ), + Op::template( + 0, + TemplateEdit::Attrs { + element: 0, + edit: ListEdit::Insert { + index: 1, + item: TemplateAttrSpec::Dynamic(Vec::new()), + }, + }, + ), + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 9, + namespace: None, + value: AttrValueSpec::Text(1), + volatile: false, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 9, + namespace: None, + value: AttrValueSpec::None, + volatile: true, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 1, + namespace: None, + value: AttrValueSpec::Listener, + volatile: false, + }, + }, + ), + Op::Rerender, + Op::dynamic_attrs( + 0, + 0, + ListEdit::Insert { + index: 0, + item: AttrSpec { + name: 1, + namespace: None, + value: AttrValueSpec::Text(2), + volatile: false, + }, + }, + ), + Op::Rerender, + ] + } +} diff --git a/packages/fuzz/src/lifecycle.rs b/packages/fuzz/src/lifecycle.rs new file mode 100644 index 0000000000..98d15f485a --- /dev/null +++ b/packages/fuzz/src/lifecycle.rs @@ -0,0 +1,289 @@ +use std::{ + cell::{Cell, RefCell}, + collections::{BTreeMap, BTreeSet}, + rc::{Rc, Weak}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum LifecycleRole { + ComponentA, + ComponentB, + SuspenseBoundary, + SuspenseChild, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct LifecycleKey { + pub(crate) role: LifecycleRole, + pub(crate) id: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum LifecycleRun { + Incremental, + Fresh, +} + +pub(crate) type LifecycleSnapshot = BTreeMap; + +#[derive(Clone, Default)] +pub(crate) struct LifecycleState { + inner: Rc, +} + +#[derive(Default)] +struct LifecycleStateInner { + current_run: Cell>, + live_components: RefCell>, + live_guards: RefCell>>, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct LifecycleContext { + suspense_ancestors: Vec, +} + +impl LifecycleContext { + fn new(suspense_ancestors: &[u64]) -> Self { + Self { + suspense_ancestors: suspense_ancestors.to_vec(), + } + } + + fn intersects_suspense_ids(&self, suspense_ids: &BTreeSet) -> bool { + self.suspense_ancestors + .iter() + .any(|id| suspense_ids.contains(id)) + } + + fn retargeted_suspense_ancestor( + &self, + old_parent: &Self, + old_id: u64, + new_parent: &Self, + new_id: u64, + ) -> Option { + let old_prefix = &old_parent.suspense_ancestors; + if !self.suspense_ancestors.starts_with(old_prefix) { + return None; + } + + let after_parent = &self.suspense_ancestors[old_prefix.len()..]; + let [first, suffix @ ..] = after_parent else { + return None; + }; + if *first != old_id { + return None; + } + + let mut suspense_ancestors = new_parent.suspense_ancestors.clone(); + suspense_ancestors.push(new_id); + suspense_ancestors.extend_from_slice(suffix); + Some(Self { suspense_ancestors }) + } +} + +impl LifecycleState { + pub(crate) fn reset_all(&self) { + self.inner.current_run.set(None); + self.inner.live_components.borrow_mut().clear(); + self.inner.live_guards.borrow_mut().clear(); + } + + pub(crate) fn reset_run(&self, run: LifecycleRun) { + self.inner + .live_components + .borrow_mut() + .retain(|(live_run, _, _), _| *live_run != run); + } + + pub(crate) fn with_run(&self, run: LifecycleRun, f: impl FnOnce() -> R) -> R { + struct RunGuard { + state: LifecycleState, + previous: Option, + } + + impl Drop for RunGuard { + fn drop(&mut self) { + self.state.inner.current_run.set(self.previous); + } + } + + let previous = self.inner.current_run.replace(Some(run)); + let _guard = RunGuard { + state: self.clone(), + previous, + }; + f() + } + + pub(crate) fn track( + &self, + role: LifecycleRole, + id: u64, + suspense_ancestors: &[u64], + ) -> Rc { + let run = self.inner.current_run.get(); + let key = LifecycleKey { role, id }; + let context = LifecycleContext::new(suspense_ancestors); + self.increment(run, key, &context); + let guard = Rc::new(LifecycleGuard { + state: self.clone(), + run: Cell::new(run), + key: Cell::new(key), + context: RefCell::new(context), + }); + self.inner + .live_guards + .borrow_mut() + .push(Rc::downgrade(&guard)); + guard + } + + pub(crate) fn snapshot(&self, run: LifecycleRun) -> LifecycleSnapshot { + let mut out = LifecycleSnapshot::new(); + for ((live_run, key, _), count) in self.inner.live_components.borrow().iter() { + if *live_run == run { + *out.entry(*key).or_insert(0) += *count; + } + } + out + } + + pub(crate) fn snapshot_with_suspense_ancestor( + &self, + run: LifecycleRun, + suspense_ids: &BTreeSet, + ) -> LifecycleSnapshot { + let mut out = LifecycleSnapshot::new(); + for ((live_run, key, context), count) in self.inner.live_components.borrow().iter() { + if *live_run == run && context.intersects_suspense_ids(suspense_ids) { + *out.entry(*key).or_insert(0) += *count; + } + } + out + } + + fn increment(&self, run: Option, key: LifecycleKey, context: &LifecycleContext) { + if let Some(run) = run { + *self + .inner + .live_components + .borrow_mut() + .entry((run, key, context.clone())) + .or_insert(0) += 1; + } + } + + fn decrement(&self, run: Option, key: LifecycleKey, context: &LifecycleContext) { + let Some(run) = run else { + return; + }; + let mut live = self.inner.live_components.borrow_mut(); + let live_key = (run, key, context.clone()); + let Some(count) = live.get_mut(&live_key) else { + return; + }; + if *count <= 1 { + live.remove(&live_key); + } else { + *count -= 1; + } + } + + fn retarget_suspense_descendant_contexts( + &self, + run: Option, + old_id: u64, + new_id: u64, + old_parent: &LifecycleContext, + new_parent: &LifecycleContext, + ) { + let Some(run) = run else { + return; + }; + + let retargeted = { + let mut retargeted = Vec::new(); + self.inner.live_guards.borrow_mut().retain(|guard| { + let Some(guard) = guard.upgrade() else { + return false; + }; + + if guard.run.get() == Some(run) { + let current_context = guard.context.borrow().clone(); + if let Some(next_context) = current_context + .retargeted_suspense_ancestor(old_parent, old_id, new_parent, new_id) + { + if next_context != current_context { + let key = guard.key.get(); + guard.context.replace(next_context.clone()); + retargeted.push((key, current_context, next_context)); + } + } + } + + true + }); + retargeted + }; + + for (key, current_context, next_context) in retargeted { + self.decrement(Some(run), key, ¤t_context); + self.increment(Some(run), key, &next_context); + } + } +} + +pub(crate) struct LifecycleGuard { + state: LifecycleState, + run: Cell>, + key: Cell, + context: RefCell, +} + +impl LifecycleGuard { + pub(crate) fn update(&self, role: LifecycleRole, id: u64, suspense_ancestors: &[u64]) { + let next_run = self.state.inner.current_run.get(); + let next_key = LifecycleKey { role, id }; + let next_context = LifecycleContext::new(suspense_ancestors); + let current_run = self.run.get(); + let current_key = self.key.get(); + let current_context = self.context.borrow().clone(); + + if current_run == next_run && current_key == next_key && current_context == next_context { + return; + } + + if current_key.role == LifecycleRole::SuspenseBoundary + && next_key.role == LifecycleRole::SuspenseBoundary + && current_key.id != next_key.id + { + // A reused suspense boundary can keep descendants alive without + // rerendering them, so retarget their recorded ancestry to the + // boundary identity observed by the current render. + self.state.retarget_suspense_descendant_contexts( + current_run, + current_key.id, + next_key.id, + ¤t_context, + &next_context, + ); + } + + self.state + .decrement(current_run, current_key, ¤t_context); + self.state.increment(next_run, next_key, &next_context); + self.run.set(next_run); + self.key.set(next_key); + self.context.replace(next_context); + } +} + +impl Drop for LifecycleGuard { + fn drop(&mut self) { + let context = self.context.get_mut(); + self.state + .decrement(self.run.get(), self.key.get(), context); + } +} diff --git a/packages/fuzz/src/model.rs b/packages/fuzz/src/model.rs new file mode 100644 index 0000000000..347b20ed41 --- /dev/null +++ b/packages/fuzz/src/model.rs @@ -0,0 +1,1273 @@ +// See note in `ops.rs`: `Mutate` derive emits a wide `new` ctor. +#![allow(clippy::too_many_arguments)] + +use mutatis::{Candidates, DefaultMutate, Generate, Mutate, Result as MutatisResult}; +use serde::{Deserialize, Serialize}; + +use crate::ATTR_NAME_POOL_MASK; + +pub(crate) const MAX_ROOTS: usize = 8; +pub(crate) const MAX_CHILDREN: usize = 8; +pub(crate) const MAX_TEMPLATE_ATTRS: usize = 12; +pub(crate) const MAX_DYNAMIC_ATTRS: usize = 8; +pub(crate) const MAX_FRAGMENT_CHILDREN: usize = 8; +pub(crate) const MAX_MODEL_COST: u64 = 256; +pub(crate) const MAX_READY_WAKE_COUNT: u8 = 4; +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Model { + pub(crate) root: VNodeSpec, + pub(crate) next_suspense_id: u64, + pub(crate) next_component_id: u64, +} + +impl Model { + pub(crate) fn initial() -> Self { + Self { + root: VNodeSpec::minimal(), + next_suspense_id: 0, + next_component_id: 0, + } + } + + pub(crate) fn selected_vnode_mut(&mut self, selector: u8) -> &mut VNodeSpec { + let count = self.root.vnode_count(); + let mut index = selector as usize % count; + self.root + .nth_vnode_mut(&mut index) + .expect("vnode selector should resolve into the root tree") + } + + pub(crate) fn can_grow(&self) -> bool { + self.root.node_count() < MAX_MODEL_COST + } + + pub(crate) fn selected_ready_suspense_key(&self, selector: u8) -> Option { + let mut keys = Vec::new(); + self.root.collect_ready_suspense_keys(&mut keys); + select(keys, selector) + } + + pub(crate) fn set_selected_suspense_mode(&mut self, selector: u8, mode: SuspenseMode) { + let count = self.root.suspense_count(); + if count == 0 { + return; + } + let mut index = selector as usize % count; + if let Some(suspense) = self.root.nth_suspense_mut(&mut index) { + suspense.set_mode(mode); + } + } + + pub(crate) fn set_selected_suspense_wake_mutation( + &mut self, + selector: u8, + mutation: WakeMutationSpec, + ) { + let count = self.root.suspense_count(); + if count == 0 { + return; + } + let mut index = selector as usize % count; + if let Some(suspense) = self.root.nth_suspense_mut(&mut index) { + suspense.set_wake_mutation(mutation); + } + } + + pub(crate) fn wake_ready_suspense(&mut self, key: SuspenseReadyKey) { + self.root.wake_ready_suspense(key); + } + + pub(crate) fn wake_mutation_for_ready_key(&self, key: SuspenseReadyKey) -> WakeMutationSpec { + self.root + .wake_mutation_for_ready_key(key) + .unwrap_or(WakeMutationSpec::None) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct VNodeSpec { + pub(crate) key: Option, + pub(crate) template: TemplateSpec, +} + +impl VNodeSpec { + pub(crate) fn minimal() -> Self { + Self { + key: None, + template: TemplateSpec { + cache_key: None, + roots: vec![TemplateNodeSpec::Element { + tag: 0, + namespace: None, + attrs: Vec::new(), + children: Vec::new(), + }], + }, + } + } + + pub(crate) fn normalize(mut self) -> Self { + self.normalize_in_place(); + self + } + + pub(crate) fn normalize_in_place(&mut self) { + self.template.normalize_in_place(); + } + + pub(crate) fn vnode_count(&self) -> usize { + 1 + self.template.vnode_count() + } + + pub(crate) fn nth_vnode_mut(&mut self, index: &mut usize) -> Option<&mut VNodeSpec> { + if *index == 0 { + return Some(self); + } + *index -= 1; + self.template.nth_vnode_mut(index) + } + + pub(crate) fn node_count(&self) -> u64 { + 1 + self.template.node_count() + } + + pub(crate) fn suspense_count(&self) -> usize { + self.template.suspense_count() + } + + pub(crate) fn nth_suspense_mut(&mut self, index: &mut usize) -> Option<&mut SuspenseSpec> { + self.template.nth_suspense_mut(index) + } + + pub(crate) fn collect_ready_suspense_keys(&self, out: &mut Vec) { + self.template.collect_ready_suspense_keys(out); + } + + pub(crate) fn wake_ready_suspense(&mut self, key: SuspenseReadyKey) { + self.template.wake_ready_suspense(key); + } + + pub(crate) fn wake_mutation_for_ready_key( + &self, + key: SuspenseReadyKey, + ) -> Option { + self.template.wake_mutation_for_ready_key(key) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum TemplateCacheKey { + Expanded(Vec), +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct TemplateSpec { + pub(crate) cache_key: Option, + pub(crate) roots: Vec, +} + +impl TemplateSpec { + pub(crate) fn normalize_in_place(&mut self) { + self.roots.truncate(MAX_ROOTS); + if self.roots.is_empty() { + self.roots.push(TemplateNodeSpec::Element { + tag: 0, + namespace: None, + attrs: Vec::new(), + children: Vec::new(), + }); + } + + let mut attr_slot = 0; + for root in &mut self.roots { + root.normalize_in_place(&mut attr_slot); + } + } + + pub(crate) fn dynamic_count(&self) -> usize { + self.roots.iter().map(TemplateNodeSpec::dynamic_count).sum() + } + + pub(crate) fn attr_count(&self) -> usize { + self.roots.iter().map(TemplateNodeSpec::attr_count).sum() + } + + pub(crate) fn node_count(&self) -> u64 { + self.roots.iter().map(TemplateNodeSpec::node_count).sum() + } + + pub(crate) fn vnode_count(&self) -> usize { + self.roots.iter().map(TemplateNodeSpec::vnode_count).sum() + } + + pub(crate) fn nth_vnode_mut(&mut self, index: &mut usize) -> Option<&mut VNodeSpec> { + for root in &mut self.roots { + if let Some(found) = root.nth_vnode_mut(index) { + return Some(found); + } + } + None + } + + pub(crate) fn nth_dynamic_mut(&mut self, index: &mut usize) -> Option<&mut DynamicSpec> { + for root in &mut self.roots { + if let Some(found) = root.nth_dynamic_mut(index) { + return Some(found); + } + } + None + } + + pub(crate) fn nth_dynamic_attr_mut(&mut self, index: &mut usize) -> Option<&mut Vec> { + for root in &mut self.roots { + if let Some(found) = root.nth_dynamic_attr_mut(index) { + return Some(found); + } + } + None + } + + pub(crate) fn suspense_count(&self) -> usize { + self.roots + .iter() + .map(TemplateNodeSpec::suspense_count) + .sum() + } + + pub(crate) fn nth_suspense_mut(&mut self, index: &mut usize) -> Option<&mut SuspenseSpec> { + for root in &mut self.roots { + if let Some(found) = root.nth_suspense_mut(index) { + return Some(found); + } + } + None + } + + pub(crate) fn collect_ready_suspense_keys(&self, out: &mut Vec) { + for root in &self.roots { + root.collect_ready_suspense_keys(out); + } + } + + pub(crate) fn wake_ready_suspense(&mut self, key: SuspenseReadyKey) { + for root in &mut self.roots { + root.wake_ready_suspense(key); + } + } + + pub(crate) fn wake_mutation_for_ready_key( + &self, + key: SuspenseReadyKey, + ) -> Option { + self.roots + .iter() + .find_map(|root| root.wake_mutation_for_ready_key(key)) + } + + pub(crate) fn cache_key(&self) -> TemplateCacheKey { + self.cache_key.clone().unwrap_or_else(|| { + TemplateCacheKey::Expanded(self.roots.iter().map(TemplateNodeSpec::shape).collect()) + }) + } + + pub(crate) fn node_paths(&self) -> Vec> { + let mut out = Vec::new(); + for (index, root) in self.roots.iter().enumerate() { + let path = vec![index]; + out.push(path.clone()); + root.collect_node_paths(path, &mut out); + } + out + } + + pub(crate) fn element_paths(&self) -> Vec> { + let mut out = Vec::new(); + for (index, root) in self.roots.iter().enumerate() { + root.collect_element_paths(vec![index], &mut out); + } + out + } + + pub(crate) fn node_mut(&mut self, path: &[usize]) -> Option<&mut TemplateNodeSpec> { + let (&root, rest) = path.split_first()?; + let node = self.roots.get_mut(root)?; + node.descendant_mut(rest) + } + + pub(crate) fn element_mut(&mut self, path: &[usize]) -> Option<&mut TemplateNodeSpec> { + self.node_mut(path) + .filter(|node| matches!(node, TemplateNodeSpec::Element { .. })) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum TemplateNodeSpec { + Element { + tag: u8, + namespace: Option, + attrs: Vec, + children: Vec, + }, + Text(u8), + Dynamic(DynamicSpec), +} + +impl TemplateNodeSpec { + pub(crate) fn from_kind( + kind: &TemplateNodeKind, + next_suspense_id: &mut u64, + next_component_id: &mut u64, + ) -> Self { + match kind { + TemplateNodeKind::Element { tag, namespace } => Self::Element { + tag: *tag, + namespace: *namespace, + attrs: Vec::new(), + children: Vec::new(), + }, + TemplateNodeKind::Text(value) => Self::Text(*value), + TemplateNodeKind::Dynamic(kind) => Self::Dynamic(DynamicSpec::from_kind( + kind, + next_suspense_id, + next_component_id, + )), + } + } + + pub(crate) fn set_kind( + &mut self, + kind: &TemplateNodeKind, + next_suspense_id: &mut u64, + next_component_id: &mut u64, + ) { + match kind { + TemplateNodeKind::Element { tag, namespace } => match self { + Self::Element { + tag: current_tag, + namespace: current_namespace, + .. + } => { + *current_tag = *tag; + *current_namespace = *namespace; + } + _ => *self = Self::from_kind(kind, next_suspense_id, next_component_id), + }, + TemplateNodeKind::Text(value) => *self = Self::Text(*value), + TemplateNodeKind::Dynamic(kind) => match self { + Self::Dynamic(dynamic) => { + dynamic.set_kind(kind, next_suspense_id, next_component_id); + } + _ => { + *self = Self::Dynamic(DynamicSpec::from_kind( + kind, + next_suspense_id, + next_component_id, + )); + } + }, + } + } + + pub(crate) fn normalize_in_place(&mut self, next_attr_slot: &mut usize) { + match self { + Self::Element { + attrs, children, .. + } => { + attrs.truncate(MAX_TEMPLATE_ATTRS); + for attr in attrs { + if let TemplateAttrSpec::Dynamic(dynamic_attrs) = attr { + sort_attrs(*next_attr_slot, dynamic_attrs); + dynamic_attrs.truncate(MAX_DYNAMIC_ATTRS); + *next_attr_slot += 1; + } + } + + children.truncate(MAX_CHILDREN); + for child in children { + child.normalize_in_place(next_attr_slot); + } + } + Self::Dynamic(dynamic) => dynamic.normalize_in_place(), + Self::Text(_) => {} + } + } + + pub(crate) fn shape(&self) -> TemplateNodeShape { + match self { + Self::Element { + tag, + namespace, + attrs, + children, + } => TemplateNodeShape::Element { + tag: *tag, + namespace: *namespace, + attrs: attrs.iter().map(TemplateAttrSpec::shape).collect(), + children: children.iter().map(TemplateNodeSpec::shape).collect(), + }, + Self::Text(value) => TemplateNodeShape::Text(*value), + Self::Dynamic(_) => TemplateNodeShape::Dynamic, + } + } + + pub(crate) fn dynamic_count(&self) -> usize { + match self { + Self::Element { children, .. } => { + children.iter().map(TemplateNodeSpec::dynamic_count).sum() + } + Self::Text(_) => 0, + Self::Dynamic(_) => 1, + } + } + + pub(crate) fn attr_count(&self) -> usize { + match self { + Self::Element { + attrs, children, .. + } => { + attrs + .iter() + .filter(|attr| matches!(attr, TemplateAttrSpec::Dynamic(_))) + .count() + + children + .iter() + .map(TemplateNodeSpec::attr_count) + .sum::() + } + Self::Text(_) | Self::Dynamic(_) => 0, + } + } + + pub(crate) fn node_count(&self) -> u64 { + match self { + Self::Element { + attrs, children, .. + } => { + 1 + attrs.len() as u64 + + attrs.iter().map(TemplateAttrSpec::node_count).sum::() + + children + .iter() + .map(TemplateNodeSpec::node_count) + .sum::() + } + Self::Text(_) => 1, + Self::Dynamic(dynamic) => 1 + dynamic.node_count(), + } + } + + pub(crate) fn vnode_count(&self) -> usize { + match self { + Self::Element { children, .. } => { + children.iter().map(TemplateNodeSpec::vnode_count).sum() + } + Self::Text(_) => 0, + Self::Dynamic(dynamic) => dynamic.vnode_count(), + } + } + + pub(crate) fn nth_vnode_mut(&mut self, index: &mut usize) -> Option<&mut VNodeSpec> { + match self { + Self::Element { children, .. } => { + for child in children { + if let Some(found) = child.nth_vnode_mut(index) { + return Some(found); + } + } + None + } + Self::Text(_) => None, + Self::Dynamic(dynamic) => dynamic.nth_vnode_mut(index), + } + } + + pub(crate) fn nth_dynamic_mut(&mut self, index: &mut usize) -> Option<&mut DynamicSpec> { + match self { + Self::Element { children, .. } => { + for child in children { + if let Some(found) = child.nth_dynamic_mut(index) { + return Some(found); + } + } + None + } + Self::Text(_) => None, + Self::Dynamic(dynamic) => { + if *index == 0 { + return Some(dynamic); + } + *index -= 1; + None + } + } + } + + pub(crate) fn nth_dynamic_attr_mut(&mut self, index: &mut usize) -> Option<&mut Vec> { + match self { + Self::Element { + attrs, children, .. + } => { + for attr in attrs { + let TemplateAttrSpec::Dynamic(attrs) = attr else { + continue; + }; + if *index == 0 { + return Some(attrs); + } + *index -= 1; + } + + for child in children { + if let Some(found) = child.nth_dynamic_attr_mut(index) { + return Some(found); + } + } + None + } + Self::Text(_) | Self::Dynamic(_) => None, + } + } + + pub(crate) fn suspense_count(&self) -> usize { + match self { + Self::Element { children, .. } => { + children.iter().map(TemplateNodeSpec::suspense_count).sum() + } + Self::Text(_) => 0, + Self::Dynamic(dynamic) => dynamic.suspense_count(), + } + } + + pub(crate) fn nth_suspense_mut(&mut self, index: &mut usize) -> Option<&mut SuspenseSpec> { + match self { + Self::Element { children, .. } => { + for child in children { + if let Some(found) = child.nth_suspense_mut(index) { + return Some(found); + } + } + None + } + Self::Text(_) => None, + Self::Dynamic(dynamic) => dynamic.nth_suspense_mut(index), + } + } + + pub(crate) fn collect_ready_suspense_keys(&self, out: &mut Vec) { + match self { + Self::Element { children, .. } => { + for child in children { + child.collect_ready_suspense_keys(out); + } + } + Self::Text(_) => {} + Self::Dynamic(dynamic) => dynamic.collect_ready_suspense_keys(out), + } + } + + pub(crate) fn wake_ready_suspense(&mut self, key: SuspenseReadyKey) { + match self { + Self::Element { children, .. } => { + for child in children { + child.wake_ready_suspense(key); + } + } + Self::Text(_) => {} + Self::Dynamic(dynamic) => dynamic.wake_ready_suspense(key), + } + } + + pub(crate) fn wake_mutation_for_ready_key( + &self, + key: SuspenseReadyKey, + ) -> Option { + match self { + Self::Element { children, .. } => children + .iter() + .find_map(|child| child.wake_mutation_for_ready_key(key)), + Self::Text(_) => None, + Self::Dynamic(dynamic) => dynamic.wake_mutation_for_ready_key(key), + } + } + + pub(crate) fn descendant_mut(&mut self, path: &[usize]) -> Option<&mut TemplateNodeSpec> { + let Some((&index, rest)) = path.split_first() else { + return Some(self); + }; + let Self::Element { children, .. } = self else { + return None; + }; + children.get_mut(index)?.descendant_mut(rest) + } + + pub(crate) fn collect_node_paths(&self, path: Vec, out: &mut Vec>) { + let Self::Element { children, .. } = self else { + return; + }; + for (index, child) in children.iter().enumerate() { + let mut child_path = path.clone(); + child_path.push(index); + out.push(child_path.clone()); + child.collect_node_paths(child_path, out); + } + } + + pub(crate) fn collect_element_paths(&self, path: Vec, out: &mut Vec>) { + let Self::Element { children, .. } = self else { + return; + }; + out.push(path.clone()); + for (index, child) in children.iter().enumerate() { + let mut child_path = path.clone(); + child_path.push(index); + child.collect_element_paths(child_path, out); + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum TemplateNodeKind { + Element { tag: u8, namespace: Option }, + Text(u8), + Dynamic(DynamicKind), +} + +// `Mutate` is hand-written below (see `BiasedTemplateAttrSpecMutator`) so the +// `name` byte gets folded into the shared name pool every time it's mutated, +// not just when a new attribute is first generated. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) enum TemplateAttrSpec { + Static { + name: u8, + value: u8, + namespace: Option, + }, + Dynamic(Vec), +} + +impl TemplateAttrSpec { + pub(crate) fn shape(&self) -> TemplateAttrShape { + match self { + Self::Static { + name, + value, + namespace, + } => TemplateAttrShape::Static { + name: *name, + value: *value, + namespace: *namespace, + }, + Self::Dynamic(_) => TemplateAttrShape::Dynamic, + } + } + + fn node_count(&self) -> u64 { + match self { + Self::Static { .. } => 0, + Self::Dynamic(attrs) => attrs.len() as u64, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum TemplateNodeShape { + Element { + tag: u8, + namespace: Option, + attrs: Vec, + children: Vec, + }, + Text(u8), + Dynamic, +} + +impl TemplateNodeShape { + pub(crate) fn dynamic_count(&self) -> usize { + match self { + Self::Element { children, .. } => { + children.iter().map(TemplateNodeShape::dynamic_count).sum() + } + Self::Text(_) => 0, + Self::Dynamic => 1, + } + } + + pub(crate) fn attr_count(&self) -> usize { + match self { + Self::Element { + attrs, children, .. + } => { + attrs + .iter() + .filter(|attr| matches!(attr, TemplateAttrShape::Dynamic)) + .count() + + children + .iter() + .map(TemplateNodeShape::attr_count) + .sum::() + } + Self::Text(_) | Self::Dynamic => 0, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum TemplateAttrShape { + Static { + name: u8, + value: u8, + namespace: Option, + }, + Dynamic, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum DynamicSpec { + Empty, + Text(u8), + Placeholder, + Fragment(Vec), + ComponentA(ComponentSpec), + ComponentB(ComponentSpec), + Suspense(SuspenseSpec), +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ComponentSpec { + pub(crate) id: u64, + pub(crate) child: Box, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SuspenseSpec { + pub(crate) id: u64, + pub(crate) ready_generation: u64, + pub(crate) ready_wake_count: u8, + pub(crate) mode: SuspenseMode, + pub(crate) wake_mutation: WakeMutationSpec, + pub(crate) wake_applied: bool, + pub(crate) child: Box, +} + +impl ComponentSpec { + pub(crate) fn new(id: u64) -> Self { + Self { + id, + child: Box::new(VNodeSpec::minimal()), + } + } +} + +impl SuspenseSpec { + pub(crate) fn new(id: u64, mode: SuspenseMode) -> Self { + Self { + id, + ready_generation: 0, + ready_wake_count: 0, + mode, + wake_mutation: WakeMutationSpec::None, + wake_applied: false, + child: Box::new(VNodeSpec::minimal()), + } + } + + pub(crate) fn ready_key(&self) -> SuspenseReadyKey { + SuspenseReadyKey { + id: self.id, + generation: self.ready_generation, + } + } + + pub(crate) fn set_mode(&mut self, mode: SuspenseMode) { + if mode.is_ready() && self.mode != mode { + self.ready_generation += 1; + self.ready_wake_count = 0; + } + self.mode = mode; + self.wake_applied = false; + } + + pub(crate) fn set_wake_mutation(&mut self, mutation: WakeMutationSpec) { + self.wake_mutation = mutation; + self.wake_applied = false; + } + + pub(crate) fn wake_ready(&mut self) { + if !self.mode.is_ready() { + return; + } + self.ready_wake_count = self.ready_wake_count.saturating_add(1); + if self.ready_wake_count >= self.mode.required_ready_wake_count().unwrap_or(1) { + self.mode = SuspenseMode::Resolved; + self.wake_applied = self.wake_mutation != WakeMutationSpec::None; + } + } +} + +impl DynamicSpec { + pub(crate) fn from_kind( + kind: &DynamicKind, + next_suspense_id: &mut u64, + next_component_id: &mut u64, + ) -> Self { + let mut dynamic = Self::Empty; + dynamic.set_kind(kind, next_suspense_id, next_component_id); + dynamic + } + + pub(crate) fn normalize_in_place(&mut self) { + match self { + Self::Fragment(nodes) => { + nodes.truncate(MAX_FRAGMENT_CHILDREN); + for node in nodes { + node.normalize_in_place(); + } + } + Self::ComponentA(component) | Self::ComponentB(component) => { + component.child.normalize_in_place(); + } + Self::Suspense(spec) => { + spec.child.normalize_in_place(); + } + Self::Empty | Self::Text(_) | Self::Placeholder => {} + } + } + + pub(crate) fn set_kind( + &mut self, + kind: &DynamicKind, + next_suspense_id: &mut u64, + next_component_id: &mut u64, + ) { + match kind { + DynamicKind::Empty => *self = Self::Empty, + DynamicKind::Text(value) => *self = Self::Text(*value), + DynamicKind::Placeholder => *self = Self::Placeholder, + DynamicKind::Fragment { children, key_base } => { + if !matches!(self, Self::Fragment(_)) { + *self = Self::Fragment(Vec::new()); + } + let Self::Fragment(nodes) = self else { + unreachable!(); + }; + let len = (*children as usize).min(MAX_FRAGMENT_CHILDREN); + nodes.resize_with(len, VNodeSpec::minimal); + nodes.truncate(len); + match key_base { + Some(base) => { + for (index, child) in nodes.iter_mut().enumerate() { + child.key = Some(base.wrapping_add(index as u8)); + } + } + None => { + for child in nodes { + child.key = None; + } + } + } + } + DynamicKind::ComponentA => { + if !matches!(self, Self::ComponentA(_)) { + let id = *next_component_id; + *next_component_id += 1; + *self = Self::ComponentA(ComponentSpec::new(id)); + } + } + DynamicKind::ComponentB => { + if !matches!(self, Self::ComponentB(_)) { + let id = *next_component_id; + *next_component_id += 1; + *self = Self::ComponentB(ComponentSpec::new(id)); + } + } + DynamicKind::Suspense { mode } => match self { + Self::Suspense(spec) => spec.set_mode(*mode), + _ => { + let id = *next_suspense_id; + *next_suspense_id += 1; + *self = Self::Suspense(SuspenseSpec::new(id, *mode)); + } + }, + } + } + + pub(crate) fn vnode_count(&self) -> usize { + match self { + Self::Empty | Self::Text(_) | Self::Placeholder => 0, + Self::Fragment(nodes) => nodes.iter().map(VNodeSpec::vnode_count).sum(), + Self::ComponentA(component) | Self::ComponentB(component) => { + component.child.vnode_count() + } + Self::Suspense(spec) => spec.child.vnode_count(), + } + } + + pub(crate) fn nth_vnode_mut(&mut self, index: &mut usize) -> Option<&mut VNodeSpec> { + match self { + Self::Empty | Self::Text(_) | Self::Placeholder => None, + Self::Fragment(nodes) => { + for node in nodes { + if let Some(found) = node.nth_vnode_mut(index) { + return Some(found); + } + } + None + } + Self::ComponentA(component) | Self::ComponentB(component) => { + component.child.nth_vnode_mut(index) + } + Self::Suspense(spec) => spec.child.nth_vnode_mut(index), + } + } + + pub(crate) fn node_count(&self) -> u64 { + match self { + Self::Empty | Self::Text(_) | Self::Placeholder => 1, + Self::Fragment(nodes) => 1 + nodes.iter().map(VNodeSpec::node_count).sum::(), + Self::ComponentA(component) | Self::ComponentB(component) => { + 1 + component.child.node_count() + } + Self::Suspense(spec) => { + let wake_roots = if spec.wake_mutation.adds_root() { 1 } else { 0 }; + 1 + wake_roots + spec.child.node_count() + } + } + } + + pub(crate) fn suspense_count(&self) -> usize { + match self { + Self::Empty | Self::Text(_) | Self::Placeholder => 0, + Self::Fragment(nodes) => nodes.iter().map(VNodeSpec::suspense_count).sum(), + Self::ComponentA(component) | Self::ComponentB(component) => { + component.child.suspense_count() + } + Self::Suspense(spec) => 1 + spec.child.suspense_count(), + } + } + + pub(crate) fn nth_suspense_mut(&mut self, index: &mut usize) -> Option<&mut SuspenseSpec> { + match self { + Self::Empty | Self::Text(_) | Self::Placeholder => None, + Self::Fragment(nodes) => { + for node in nodes { + if let Some(found) = node.nth_suspense_mut(index) { + return Some(found); + } + } + None + } + Self::ComponentA(component) | Self::ComponentB(component) => { + component.child.nth_suspense_mut(index) + } + Self::Suspense(spec) => { + if *index == 0 { + return Some(spec); + } + *index -= 1; + spec.child.nth_suspense_mut(index) + } + } + } + + pub(crate) fn collect_ready_suspense_keys(&self, out: &mut Vec) { + match self { + Self::Empty | Self::Text(_) | Self::Placeholder => {} + Self::Fragment(nodes) => { + for node in nodes { + node.collect_ready_suspense_keys(out); + } + } + Self::ComponentA(component) | Self::ComponentB(component) => { + component.child.collect_ready_suspense_keys(out) + } + Self::Suspense(spec) => { + if spec.mode.is_ready() { + out.push(spec.ready_key()); + } + spec.child.collect_ready_suspense_keys(out); + } + } + } + + pub(crate) fn wake_ready_suspense(&mut self, key: SuspenseReadyKey) { + match self { + Self::Empty | Self::Text(_) | Self::Placeholder => {} + Self::Fragment(nodes) => { + for node in nodes { + node.wake_ready_suspense(key); + } + } + Self::ComponentA(component) | Self::ComponentB(component) => { + component.child.wake_ready_suspense(key) + } + Self::Suspense(spec) => { + if spec.mode.is_ready() && spec.ready_key() == key { + spec.wake_ready(); + } + spec.child.wake_ready_suspense(key); + } + } + } + + pub(crate) fn wake_mutation_for_ready_key( + &self, + key: SuspenseReadyKey, + ) -> Option { + match self { + Self::Empty | Self::Text(_) | Self::Placeholder => None, + Self::Fragment(nodes) => nodes + .iter() + .find_map(|node| node.wake_mutation_for_ready_key(key)), + Self::ComponentA(component) | Self::ComponentB(component) => { + component.child.wake_mutation_for_ready_key(key) + } + Self::Suspense(spec) => { + if spec.ready_key() == key { + Some(spec.wake_mutation) + } else { + spec.child.wake_mutation_for_ready_key(key) + } + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum DynamicKind { + Empty, + Text(u8), + Fragment { children: u8, key_base: Option }, + ComponentA, + ComponentB, + Suspense { mode: SuspenseMode }, + Placeholder, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum SuspenseMode { + Resolved, + Pending, + Ready { wake_after: u8 }, +} + +impl SuspenseMode { + pub(crate) fn is_ready(self) -> bool { + matches!(self, Self::Ready { .. }) + } + + pub(crate) fn required_ready_wake_count(self) -> Option { + let Self::Ready { wake_after } = self else { + return None; + }; + Some((wake_after % MAX_READY_WAKE_COUNT) + 1) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum WakeMutationSpec { + None, + PrependStaticRoot { tag: u8 }, +} + +impl WakeMutationSpec { + fn adds_root(self) -> bool { + matches!(self, Self::PrependStaticRoot { .. }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct SuspenseReadyKey { + pub(crate) id: u64, + pub(crate) generation: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SuspenseTaskKey { + Pending(u64), + Ready(SuspenseReadyKey), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum FragmentKeyMode { + Unkeyed, + Keyed { base: u8 }, +} + +// `Mutate` is hand-written below (see `BiasedAttrSpecMutator`) so the `name` +// byte gets folded into the shared name pool on every in-place mutation. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct AttrSpec { + pub(crate) name: u8, + pub(crate) namespace: Option, + pub(crate) value: AttrValueSpec, + pub(crate) volatile: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum AttrValueSpec { + Text(u8), + Float(u8), + Int(u8), + Bool(bool), + Any(u8), + None, + Listener, +} + +pub(crate) fn select(items: Vec, selector: u8) -> Option { + let len = items.len(); + if len == 0 { + return None; + } + items.into_iter().nth(selector as usize % len) +} + +pub(crate) fn sort_attrs(slot: usize, attrs: &mut Vec) { + attrs.sort_by_cached_key(|attr| attr_sort_key(slot, attr)); + attrs.dedup_by(|left, right| attr_sort_key(slot, left) == attr_sort_key(slot, right)); +} + +fn attr_sort_key(slot: usize, attr: &AttrSpec) -> String { + match attr.value { + AttrValueSpec::Listener => format!("onevent{slot}_{}", attr.name), + _ if attr.name & 0x80 != 0 => format!("onevent{slot}_{}", attr.name & 0x7f), + _ => format!("attr{}", attr.name), + } +} + +// --- Pool-biased mutators for attribute names ------------------------------- +// +// The derived `Mutate` impl mutates `u8` fields uniformly across 0..=255, +// which makes static/dynamic name collisions on the same element vanishingly +// rare. These hand-written mutators fold the `name` byte into the shared +// `ATTR_NAME_POOL_MASK` pool on every in-place mutation, while keeping the +// other fields' mutations identical to the derive's behaviour. A rare +// out-of-pool escape preserves coverage of the "no static collides" path. + +/// Mutate a `u8` name field. Half the time we fold the byte into the shared +/// pool so static/dynamic collisions on the same element become probable; +/// the other half we keep a uniform byte so the diff merge's "extra on one +/// side" arms (and other diversity-sensitive paths) keep getting exercised. +fn pool_mutate_name(ctx: &mut mutatis::Context, name: &mut u8) { + let r = ctx.rng().gen_u8(); + *name = if r & 0x80 == 0 { + r & ATTR_NAME_POOL_MASK + } else { + r + }; +} + +fn pool_generate_name(ctx: &mut mutatis::Context) -> u8 { + let r = ctx.rng().gen_u8(); + if r & 0x80 == 0 { + r & ATTR_NAME_POOL_MASK + } else { + r + } +} + +#[derive(Default)] +pub(crate) struct BiasedAttrSpecMutator { + namespace: as DefaultMutate>::DefaultMutate, + value: ::DefaultMutate, + volatile: ::DefaultMutate, +} + +impl Mutate for BiasedAttrSpecMutator { + fn mutate( + &mut self, + candidates: &mut Candidates<'_>, + value: &mut AttrSpec, + ) -> MutatisResult<()> { + candidates.mutation(|ctx| { + pool_mutate_name(ctx, &mut value.name); + Ok(()) + })?; + self.namespace.mutate(candidates, &mut value.namespace)?; + self.value.mutate(candidates, &mut value.value)?; + self.volatile.mutate(candidates, &mut value.volatile)?; + Ok(()) + } +} + +impl Generate for BiasedAttrSpecMutator { + fn generate(&mut self, ctx: &mut mutatis::Context) -> MutatisResult { + Ok(AttrSpec { + name: pool_generate_name(ctx), + namespace: self.namespace.generate(ctx)?, + value: self.value.generate(ctx)?, + volatile: self.volatile.generate(ctx)?, + }) + } +} + +impl DefaultMutate for AttrSpec { + type DefaultMutate = BiasedAttrSpecMutator; +} + +#[derive(Default)] +pub(crate) struct BiasedTemplateAttrSpecMutator { + static_value: ::DefaultMutate, + static_namespace: as DefaultMutate>::DefaultMutate, + dynamic_attrs: as DefaultMutate>::DefaultMutate, +} + +impl Mutate for BiasedTemplateAttrSpecMutator { + fn mutate( + &mut self, + candidates: &mut Candidates<'_>, + value: &mut TemplateAttrSpec, + ) -> MutatisResult<()> { + // Variant-switching candidate: flip between the two variants. + let current_is_static = matches!(value, TemplateAttrSpec::Static { .. }); + candidates.mutation_group(1, |ctx, _which| { + *value = if current_is_static { + TemplateAttrSpec::Dynamic(self.dynamic_attrs.generate(ctx)?) + } else { + TemplateAttrSpec::Static { + name: pool_generate_name(ctx), + value: self.static_value.generate(ctx)?, + namespace: self.static_namespace.generate(ctx)?, + } + }; + Ok(()) + })?; + + match value { + TemplateAttrSpec::Static { + name, + value, + namespace, + } => { + candidates.mutation(|ctx| { + pool_mutate_name(ctx, name); + Ok(()) + })?; + self.static_value.mutate(candidates, value)?; + self.static_namespace.mutate(candidates, namespace)?; + } + TemplateAttrSpec::Dynamic(attrs) => { + self.dynamic_attrs.mutate(candidates, attrs)?; + } + } + + Ok(()) + } +} + +impl Generate for BiasedTemplateAttrSpecMutator { + fn generate(&mut self, ctx: &mut mutatis::Context) -> MutatisResult { + Ok(if ctx.rng().gen_index(2).unwrap_or(0) == 0 { + TemplateAttrSpec::Static { + name: pool_generate_name(ctx), + value: self.static_value.generate(ctx)?, + namespace: self.static_namespace.generate(ctx)?, + } + } else { + TemplateAttrSpec::Dynamic(self.dynamic_attrs.generate(ctx)?) + }) + } +} + +impl DefaultMutate for TemplateAttrSpec { + type DefaultMutate = BiasedTemplateAttrSpecMutator; +} diff --git a/packages/fuzz/src/ops.rs b/packages/fuzz/src/ops.rs new file mode 100644 index 0000000000..a5b55669e5 --- /dev/null +++ b/packages/fuzz/src/ops.rs @@ -0,0 +1,713 @@ +// `Mutate` derive expands to a `new` ctor with one param per generic mutator +// field, which exceeds clippy's default for enums with many variants. +#![allow(clippy::too_many_arguments)] + +use crate::{context::HarnessContext, model::*}; +use mutatis::{Candidates, DefaultMutate, Generate, Mutate, Result as MutatisResult}; +use serde::{Deserialize, Serialize}; +use std::{ + future::Future, + marker::PhantomData, + pin::Pin, + task::{Context, Poll, Waker}, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum Op { + Rerender, + WakeSuspense { + suspense: u8, + }, + FireEvent { + target: u8, + behavior: EventBehaviorSpec, + }, + Mutate(ModelEdit), +} + +impl Op { + pub(crate) fn wake_suspense(suspense: u8) -> Self { + Self::WakeSuspense { suspense } + } + + pub(crate) fn fire_event(target: u8, behavior: EventBehaviorSpec) -> Self { + Self::FireEvent { target, behavior } + } + + pub(crate) fn template(vnode: u8, edit: TemplateEdit) -> Self { + Self::Mutate(ModelEdit::VNode { vnode, edit }) + } + + pub(crate) fn dynamic(vnode: u8, node: u8, kind: DynamicKind) -> Self { + Self::Mutate(ModelEdit::VNode { + vnode, + edit: TemplateEdit::SetNode { + node, + kind: TemplateNodeKind::Dynamic(kind), + }, + }) + } + + pub(crate) fn dynamic_attrs(vnode: u8, attr: u8, edit: ListEdit) -> Self { + Self::Mutate(ModelEdit::VNode { + vnode, + edit: TemplateEdit::DynamicAttrs { attr, edit }, + }) + } + + pub(crate) fn fragment(vnode: u8, node: u8, edit: FragmentEdit) -> Self { + Self::Mutate(ModelEdit::VNode { + vnode, + edit: TemplateEdit::Fragment { node, edit }, + }) + } + + pub(crate) fn suspense(suspense: u8, mode: SuspenseMode) -> Self { + Self::Mutate(ModelEdit::Suspense { + suspense, + edit: SuspenseEdit::Mode(mode), + }) + } + + pub(crate) fn suspense_wake_mutation(suspense: u8, mutation: WakeMutationSpec) -> Self { + Self::Mutate(ModelEdit::Suspense { + suspense, + edit: SuspenseEdit::WakeMutation(mutation), + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum EventBehaviorSpec { + Noop, + DispatchNestedEvent { target: u8 }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum ModelEdit { + VNode { vnode: u8, edit: TemplateEdit }, + Suspense { suspense: u8, edit: SuspenseEdit }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum SuspenseEdit { + Mode(SuspenseMode), + WakeMutation(WakeMutationSpec), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum TemplateEdit { + SetNode { + node: u8, + kind: TemplateNodeKind, + }, + Roots { + edit: ListEdit, + }, + Children { + element: u8, + edit: ListEdit, + }, + Attrs { + element: u8, + edit: ListEdit, + }, + Fragment { + node: u8, + edit: FragmentEdit, + }, + DynamicAttrs { + attr: u8, + edit: ListEdit, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Mutate)] +pub(crate) enum FragmentEdit { + KeyMode(FragmentKeyMode), + Children(ListEdit>), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) enum ListEdit { + Insert { index: u8, item: T }, + Remove { index: u8 }, + Move { from: u8, to: u8 }, +} + +#[derive(Clone, Debug)] +pub(crate) struct ListEditMutator { + item: M, + _phantom: PhantomData T>, +} + +impl Default for ListEditMutator +where + M: Default, +{ + fn default() -> Self { + Self { + item: M::default(), + _phantom: PhantomData, + } + } +} + +impl DefaultMutate for ListEdit +where + T: DefaultMutate, + T::DefaultMutate: Generate, +{ + type DefaultMutate = ListEditMutator; +} + +impl Generate> for ListEditMutator +where + M: Generate, +{ + fn generate(&mut self, context: &mut mutatis::Context) -> MutatisResult> { + Ok(match context.rng().gen_index(3).unwrap() { + 0 => ListEdit::Insert { + index: context.rng().gen_u8(), + item: self.item.generate(context)?, + }, + 1 => ListEdit::Remove { + index: context.rng().gen_u8(), + }, + _ => ListEdit::Move { + from: context.rng().gen_u8(), + to: context.rng().gen_u8(), + }, + }) + } +} + +impl Mutate> for ListEditMutator +where + M: Generate + Mutate, +{ + fn mutate( + &mut self, + candidates: &mut Candidates<'_>, + value: &mut ListEdit, + ) -> MutatisResult<()> { + let replacement_count = if candidates.shrink() { 2 } else { 3 }; + candidates.mutation_group(replacement_count, |context, which| { + *value = match which { + 0 => ListEdit::Remove { + index: context.rng().gen_u8(), + }, + 1 => ListEdit::Move { + from: context.rng().gen_u8(), + to: context.rng().gen_u8(), + }, + _ => ListEdit::Insert { + index: context.rng().gen_u8(), + item: self.item.generate(context)?, + }, + }; + Ok(()) + })?; + + match value { + ListEdit::Insert { index, item } => { + candidates.mutation(|context| { + *index = context.rng().gen_u8(); + Ok(()) + })?; + self.item.mutate(candidates, item)?; + } + ListEdit::Remove { index } => { + candidates.mutation(|context| { + *index = context.rng().gen_u8(); + Ok(()) + })?; + } + ListEdit::Move { from, to } => { + candidates.mutation(|context| { + *from = context.rng().gen_u8(); + Ok(()) + })?; + candidates.mutation(|context| { + *to = context.rng().gen_u8(); + Ok(()) + })?; + } + } + + Ok(()) + } +} + +#[derive(Default)] +pub(crate) struct SuspenseReadyRegistry { + wake_counts: Vec<(SuspenseReadyKey, usize)>, + wakers: Vec<(SuspenseReadyKey, Waker)>, +} + +impl SuspenseReadyRegistry { + fn wake_count(&self, key: SuspenseReadyKey) -> usize { + self.wake_counts + .iter() + .find_map(|(wake_key, count)| (*wake_key == key).then_some(*count)) + .unwrap_or(0) + } + + fn released(&self, key: SuspenseReadyKey, required_wakes: usize) -> bool { + self.wake_count(key) >= required_wakes + } + + fn register_waker(&mut self, key: SuspenseReadyKey, waker: Waker) { + if let Some((_, existing)) = self + .wakers + .iter_mut() + .find(|(wake_key, _)| *wake_key == key) + { + *existing = waker; + } else { + self.wakers.push((key, waker)); + } + } + + fn release(&mut self, key: SuspenseReadyKey) { + if let Some((_, count)) = self + .wake_counts + .iter_mut() + .find(|(wake_key, _)| *wake_key == key) + { + *count = count.saturating_add(1); + } else { + self.wake_counts.push((key, 1)); + } + + if let Some((_, waker)) = self.wakers.iter().find(|(wake_key, _)| *wake_key == key) { + waker.wake_by_ref(); + } + } + + fn registered_keys(&self) -> Vec { + self.wakers.iter().map(|(key, _)| *key).collect() + } + + fn clear(&mut self) { + self.wake_counts.clear(); + self.wakers.clear(); + } +} + +struct SuspenseReadyRegistrationGuard { + context: HarnessContext, + previous: bool, +} + +impl Drop for SuspenseReadyRegistrationGuard { + fn drop(&mut self) { + self.context + .register_suspense_ready_wakers + .set(self.previous); + } +} + +impl HarnessContext { + pub(crate) fn read_model(&self) -> Model { + self.model.borrow().clone() + } + + pub(crate) fn with_model(&self, f: impl FnOnce(&mut Model) -> R) -> R { + f(&mut self.model.borrow_mut()) + } + + fn suspense_ready_released(&self, key: SuspenseReadyKey, required_wakes: usize) -> bool { + self.register_suspense_ready_wakers.get() + && self.suspense_ready.borrow().released(key, required_wakes) + } + + fn register_suspense_ready_waker(&self, key: SuspenseReadyKey, waker: Waker) { + if self.register_suspense_ready_wakers.get() { + self.suspense_ready.borrow_mut().register_waker(key, waker); + } + } + + pub(crate) fn release_suspense_ready_task(&self, key: SuspenseReadyKey) { + self.suspense_ready.borrow_mut().release(key); + } + + pub(crate) fn selected_registered_ready_suspense_key( + &self, + selector: u8, + ) -> Option { + let registered = self.suspense_ready.borrow().registered_keys(); + + let mut ready = Vec::new(); + self.read_model() + .root + .collect_ready_suspense_keys(&mut ready); + ready.retain(|key| registered.contains(key)); + select(ready, selector) + } + + pub(crate) fn clear_suspense_ready_tasks(&self) { + self.suspense_ready.borrow_mut().clear(); + } + + pub(crate) fn without_suspense_ready_registration(&self, f: impl FnOnce() -> R) -> R { + let previous = self.register_suspense_ready_wakers.replace(false); + let _guard = SuspenseReadyRegistrationGuard { + context: self.clone(), + previous, + }; + f() + } + + pub(crate) fn apply_to_model(&self, op: &Op) { + let Op::Mutate(edit) = op else { + return; + }; + + self.with_model(|model| { + let can_grow = model.can_grow(); + apply_model_edit(model, edit, can_grow); + }); + } +} + +pub(crate) struct SuspenseReadyFuture { + pub(crate) context: HarnessContext, + pub(crate) key: SuspenseReadyKey, + pub(crate) required_wakes: usize, +} + +impl Future for SuspenseReadyFuture { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let key = self.key; + if self + .context + .suspense_ready_released(key, self.required_wakes) + { + Poll::Ready(()) + } else { + self.context + .register_suspense_ready_waker(key, cx.waker().clone()); + Poll::Pending + } + } +} + +pub(crate) fn apply_strategy_op_to_model(model: &mut Model, op: &Op) { + if matches!(op, Op::Rerender | Op::FireEvent { .. }) { + return; + } + + let can_grow = model.can_grow(); + match op { + Op::Rerender => {} + Op::FireEvent { .. } => {} + Op::WakeSuspense { suspense } => { + if let Some(key) = model.selected_ready_suspense_key(*suspense) { + model.wake_ready_suspense(key); + } + } + Op::Mutate(edit) => apply_model_edit(model, edit, can_grow), + } +} + +fn apply_model_edit(model: &mut Model, edit: &ModelEdit, can_grow: bool) { + match edit { + ModelEdit::VNode { vnode, edit } => apply_vnode_edit(model, *vnode, edit, can_grow), + ModelEdit::Suspense { suspense, edit } => match edit { + SuspenseEdit::Mode(mode) => model.set_selected_suspense_mode(*suspense, *mode), + SuspenseEdit::WakeMutation(mutation) => { + model.set_selected_suspense_wake_mutation(*suspense, *mutation); + } + }, + } +} + +fn apply_vnode_edit(model: &mut Model, vnode: u8, edit: &TemplateEdit, can_grow: bool) { + let mut next_suspense_id = model.next_suspense_id; + let mut next_component_id = model.next_component_id; + { + let vnode = model.selected_vnode_mut(vnode); + apply_template_edit( + vnode, + edit, + can_grow, + &mut next_suspense_id, + &mut next_component_id, + ); + vnode.normalize_in_place(); + } + model.next_suspense_id = next_suspense_id; + model.next_component_id = next_component_id; +} + +fn apply_template_edit( + vnode: &mut VNodeSpec, + edit: &TemplateEdit, + can_grow: bool, + next_suspense_id: &mut u64, + next_component_id: &mut u64, +) { + match edit { + TemplateEdit::SetNode { node, kind } => { + vnode.template.cache_key = None; + if let Some(path) = select(vnode.template.node_paths(), *node) { + if let Some(node) = vnode.template.node_mut(&path) { + if can_apply_template_node_kind(kind, can_grow) { + node.set_kind(kind, next_suspense_id, next_component_id); + } + } + } + } + TemplateEdit::Roots { edit } => { + vnode.template.cache_key = None; + apply_template_node_list_edit( + &mut vnode.template.roots, + edit, + 1, + MAX_ROOTS, + can_grow, + next_suspense_id, + next_component_id, + ); + } + TemplateEdit::Children { element, edit } => { + vnode.template.cache_key = None; + if let Some(path) = select(vnode.template.element_paths(), *element) { + if let Some(TemplateNodeSpec::Element { children, .. }) = + vnode.template.element_mut(&path) + { + apply_template_node_list_edit( + children, + edit, + 0, + MAX_CHILDREN, + can_grow, + next_suspense_id, + next_component_id, + ); + } + } + } + TemplateEdit::Attrs { element, edit } => { + vnode.template.cache_key = None; + if let Some(path) = select(vnode.template.element_paths(), *element) { + if let Some(TemplateNodeSpec::Element { attrs, .. }) = + vnode.template.element_mut(&path) + { + apply_template_attr_list_edit(attrs, edit); + } + } + } + TemplateEdit::Fragment { node, edit } => { + apply_fragment_edit(vnode, *node, edit, can_grow); + } + TemplateEdit::DynamicAttrs { attr, edit } => { + if let Some(attrs) = selected_dynamic_attr_mut(vnode, *attr) { + apply_attr_list_edit(attrs, edit); + } + } + } +} + +fn can_apply_template_node_kind(kind: &TemplateNodeKind, can_grow: bool) -> bool { + can_grow + || matches!( + kind, + TemplateNodeKind::Element { .. } + | TemplateNodeKind::Text(_) + | TemplateNodeKind::Dynamic( + DynamicKind::Empty | DynamicKind::Text(_) | DynamicKind::Placeholder + ) + | TemplateNodeKind::Dynamic(DynamicKind::Fragment { children: 0, .. }) + ) +} + +fn apply_fragment_edit(vnode: &mut VNodeSpec, slot: u8, edit: &FragmentEdit, can_grow: bool) { + match edit { + FragmentEdit::KeyMode(mode) => { + if let Some(children) = selected_fragment_mut(vnode, slot) { + apply_fragment_key_mode(children, mode); + } + } + FragmentEdit::Children(ListEdit::Insert { index, item }) => { + if can_grow { + if let Some(children) = selected_fragment_mut(vnode, slot) { + insert_fragment_child(children, *index, *item); + } + } + } + FragmentEdit::Children(ListEdit::Remove { index }) => { + if let Some(children) = selected_existing_fragment_mut(vnode, slot) { + remove_selected(children, *index, 0); + } + } + FragmentEdit::Children(ListEdit::Move { from, to }) => { + if let Some(children) = selected_existing_fragment_mut(vnode, slot) { + move_selected(children, *from, *to); + } + } + } +} + +fn apply_template_node_list_edit( + nodes: &mut Vec, + edit: &ListEdit, + min_len: usize, + max_len: usize, + can_grow: bool, + next_suspense_id: &mut u64, + next_component_id: &mut u64, +) { + match edit { + ListEdit::Insert { index, item } => { + if can_grow && nodes.len() < max_len { + let index = insert_index(nodes.len(), *index); + nodes.insert( + index, + TemplateNodeSpec::from_kind(item, next_suspense_id, next_component_id), + ); + } + } + ListEdit::Remove { index } => { + remove_selected(nodes, *index, min_len); + } + ListEdit::Move { from, to } => { + move_selected(nodes, *from, *to); + } + } +} + +fn apply_template_attr_list_edit( + attrs: &mut Vec, + edit: &ListEdit, +) { + match edit { + ListEdit::Insert { index, item } => { + if attrs.len() < MAX_TEMPLATE_ATTRS { + let index = insert_index(attrs.len(), *index); + attrs.insert(index, item.clone()); + } + } + ListEdit::Remove { index } => { + remove_selected(attrs, *index, 0); + } + ListEdit::Move { from, to } => { + move_selected(attrs, *from, *to); + } + } +} + +fn apply_attr_list_edit(attrs: &mut Vec, edit: &ListEdit) { + match edit { + ListEdit::Insert { index, item } => { + if attrs.len() < MAX_DYNAMIC_ATTRS { + let index = insert_index(attrs.len(), *index); + attrs.insert(index, item.clone()); + } + } + ListEdit::Remove { index } => { + remove_selected(attrs, *index, 0); + } + ListEdit::Move { from, to } => { + move_selected(attrs, *from, *to); + } + } +} + +fn insert_index(len: usize, selector: u8) -> usize { + selector as usize % (len + 1) +} + +fn remove_selected(items: &mut Vec, selector: u8, min_len: usize) { + if items.len() <= min_len { + return; + } + let index = selector as usize % items.len(); + items.remove(index); +} + +fn move_selected(items: &mut Vec, from: u8, to: u8) { + if items.len() <= 1 { + return; + } + let from = from as usize % items.len(); + let item = items.remove(from); + let to = to as usize % (items.len() + 1); + items.insert(to, item); +} + +fn selected_dynamic_mut(vnode: &mut VNodeSpec, selector: u8) -> Option<&mut DynamicSpec> { + let dynamic_count = vnode.template.dynamic_count(); + if dynamic_count == 0 { + return None; + } + let mut index = selector as usize % dynamic_count; + vnode.template.nth_dynamic_mut(&mut index) +} + +fn selected_dynamic_attr_mut(vnode: &mut VNodeSpec, selector: u8) -> Option<&mut Vec> { + let attr_count = vnode.template.attr_count(); + if attr_count == 0 { + return None; + } + let mut index = selector as usize % attr_count; + vnode.template.nth_dynamic_attr_mut(&mut index) +} + +fn selected_fragment_mut(vnode: &mut VNodeSpec, selector: u8) -> Option<&mut Vec> { + let dynamic = selected_dynamic_mut(vnode, selector)?; + if !matches!(dynamic, DynamicSpec::Fragment(_)) { + *dynamic = DynamicSpec::Fragment(Vec::new()); + } + let DynamicSpec::Fragment(children) = dynamic else { + unreachable!(); + }; + Some(children) +} + +fn selected_existing_fragment_mut( + vnode: &mut VNodeSpec, + selector: u8, +) -> Option<&mut Vec> { + match selected_dynamic_mut(vnode, selector)? { + DynamicSpec::Fragment(children) => Some(children), + _ => None, + } +} + +fn apply_fragment_key_mode(children: &mut [VNodeSpec], mode: &FragmentKeyMode) { + for (index, child) in children.iter_mut().enumerate() { + child.key = match mode { + FragmentKeyMode::Unkeyed => None, + FragmentKeyMode::Keyed { base } => Some(base.wrapping_add(index as u8)), + }; + } +} + +fn insert_fragment_child(children: &mut Vec, index: u8, key: Option) { + if children.len() >= MAX_FRAGMENT_CHILDREN { + return; + } + let mut child = VNodeSpec::minimal(); + child.key = fragment_child_key(children, key); + let index = insert_index(children.len(), index); + children.insert(index, child); +} + +fn fragment_child_key(children: &[VNodeSpec], requested: Option) -> Option { + match children.first().and_then(|child| child.key) { + Some(_) => Some(unique_fragment_key(children, requested.unwrap_or(0))), + None if children.is_empty() => requested, + None => None, + } +} + +fn unique_fragment_key(children: &[VNodeSpec], mut candidate: u8) -> u8 { + while children.iter().any(|child| child.key == Some(candidate)) { + candidate = candidate.wrapping_add(1); + } + candidate +} diff --git a/packages/fuzz/src/reducer.rs b/packages/fuzz/src/reducer.rs new file mode 100644 index 0000000000..22722fb3c6 --- /dev/null +++ b/packages/fuzz/src/reducer.rs @@ -0,0 +1,1063 @@ +use crate::{ + FuzzCase, FuzzFailure, encode_case_vec, + model::{ + AttrSpec, AttrValueSpec, DynamicKind, FragmentKeyMode, SuspenseMode, TemplateAttrSpec, + TemplateNodeKind, WakeMutationSpec, + }, + ops::{EventBehaviorSpec, FragmentEdit, ListEdit, ModelEdit, Op, SuspenseEdit, TemplateEdit}, + run_case, +}; +use std::{ + collections::HashSet, + hash::Hash, + panic::{self, AssertUnwindSafe}, + sync::Mutex, +}; + +#[derive(Clone)] +pub struct ReductionOptions { + random_multi_attempts: usize, + max_attempts: Option, +} + +impl ReductionOptions { + pub fn random_multi_attempts(mut self, attempts: usize) -> Self { + self.random_multi_attempts = attempts; + self + } + + pub fn max_attempts(mut self, attempts: usize) -> Self { + self.max_attempts = Some(attempts); + self + } +} + +impl Default for ReductionOptions { + fn default() -> Self { + Self { + random_multi_attempts: 2048, + max_attempts: None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct FailureSignature { + summary: String, +} + +impl FailureSignature { + fn new(failure: &FuzzFailure) -> Self { + Self { + summary: failure_summary(failure).to_string(), + } + } + + fn matches(&self, failure: &FuzzFailure) -> bool { + self.summary == failure_summary(failure) + } +} + +struct Reducer { + options: ReductionOptions, + signature: FailureSignature, + failing_step: usize, + rng: ReductionRng, + attempts: usize, +} + +enum ReductionRun { + Passed, + Failed(FuzzFailure), + Panicked, +} + +pub(crate) fn reduce_case_to_encoded_vec( + case: &FuzzCase, + encoded_len: usize, + max_size: usize, + options: ReductionOptions, +) -> Option> { + let original_failure = match run_case_for_reduction(case) { + ReductionRun::Failed(failure) => failure, + ReductionRun::Passed | ReductionRun::Panicked => return None, + }; + let original_ops = case.ops.len(); + let signature = FailureSignature::new(&original_failure); + let mut reducer = Reducer { + options, + signature, + failing_step: original_failure.step, + rng: ReductionRng::new(seed_from_case(case)), + attempts: 0, + }; + let mut case = case.clone_case(); + + reducer.truncate_after_failure(&mut case); + reducer.reduce_to_local_minimum(&mut case); + reducer.reduce_by_random_multistep(&mut case); + reducer.reduce_to_local_minimum(&mut case); + reducer.reduce_by_random_multistep(&mut case); + reducer.reduce_to_local_minimum(&mut case); + + let encoded = encode_case_vec(&case)?; + let reduced_ops = case.ops.len() < original_ops; + let reduced_bytes = encoded.len() < encoded_len; + + (encoded.len() <= max_size && (reduced_ops || reduced_bytes)).then_some(encoded) +} + +impl Reducer { + fn reduce_to_local_minimum(&mut self, case: &mut FuzzCase) { + self.reduce_by_chunk_deletion(case); + self.reduce_by_single_deletion(case); + self.reduce_values(case); + self.reduce_by_peepholes(case); + } + + fn accepts(&mut self, case: &FuzzCase) -> Option { + if self + .options + .max_attempts + .is_some_and(|max_attempts| self.attempts >= max_attempts) + { + return None; + } + + self.attempts += 1; + let ReductionRun::Failed(failure) = run_case_for_reduction(case) else { + return None; + }; + if self.signature.matches(&failure) { + Some(failure) + } else { + None + } + } + + fn try_replace(&mut self, case: &mut FuzzCase, mut candidate: FuzzCase) -> bool { + let Some(failure) = self.accepts(&candidate) else { + return false; + }; + candidate.ops.truncate(failure.step + 1); + *case = candidate; + self.failing_step = failure.step; + true + } + + fn truncate_after_failure(&mut self, case: &mut FuzzCase) { + let needed_len = self.failing_step + 1; + if needed_len >= case.ops.len() { + return; + } + + let mut candidate = case.clone_case(); + candidate.ops.truncate(needed_len); + self.try_replace(case, candidate); + } + + fn reduce_by_chunk_deletion(&mut self, case: &mut FuzzCase) { + let mut granularity = 2; + + while case.ops.len() > 1 { + let len = case.ops.len(); + let chunk_size = len.div_ceil(granularity); + let mut changed = false; + let mut start = 0; + + while start < case.ops.len() { + let end = (start + chunk_size).min(case.ops.len()); + if start == 0 && end == case.ops.len() { + break; + } + + if self.try_remove_range(case, start, end) { + changed = true; + } else { + start = end; + } + } + + if changed { + granularity = 2; + } else if granularity >= len { + break; + } else { + granularity = (granularity * 2).min(len); + } + } + } + + fn reduce_by_single_deletion(&mut self, case: &mut FuzzCase) { + let mut index = 0; + while index < case.ops.len() { + if !self.try_remove_range(case, index, index + 1) { + index += 1; + } + } + } + + fn try_remove_range(&mut self, case: &mut FuzzCase, start: usize, end: usize) -> bool { + if start >= end || end > case.ops.len() || end - start == case.ops.len() { + return false; + } + + let mut ops = Vec::with_capacity(case.ops.len() - (end - start)); + ops.extend_from_slice(&case.ops[..start]); + ops.extend_from_slice(&case.ops[end..]); + self.try_replace(case, FuzzCase::new(ops)) + } + + fn reduce_values(&mut self, case: &mut FuzzCase) { + let mut index = 0; + while index < case.ops.len() { + let candidates = simplified_ops(&case.ops[index]); + let mut changed = false; + for replacement in candidates { + let mut candidate = case.clone_case(); + candidate.ops[index] = replacement; + if self.try_replace(case, candidate) { + changed = true; + break; + } + } + + if !changed { + index += 1; + } + } + } + + fn reduce_by_peepholes(&mut self, case: &mut FuzzCase) { + loop { + let mut changed = false; + for index in 0..case.ops.len() { + for candidate in peephole_cases(case, index) { + if self.try_replace(case, candidate) { + changed = true; + break; + } + } + if changed { + break; + } + } + + if !changed { + break; + } + } + } + + fn reduce_by_random_multistep(&mut self, case: &mut FuzzCase) { + for _ in 0..self.options.random_multi_attempts { + if case.ops.len() <= 1 { + return; + } + + let mut candidate = case.clone_case(); + let changed = + random_multistep_shrink_case_with(&mut candidate, |len| self.rng.index(len)); + + if changed { + self.try_replace(case, candidate); + } + } + } +} + +fn run_case_for_reduction(case: &FuzzCase) -> ReductionRun { + static PANIC_HOOK_LOCK: Mutex<()> = Mutex::new(()); + + let _lock = PANIC_HOOK_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous_hook = panic::take_hook(); + panic::set_hook(Box::new(|_| {})); + let result = panic::catch_unwind(AssertUnwindSafe(|| run_case(case))); + panic::set_hook(previous_hook); + + match result { + Ok(Ok(())) => ReductionRun::Passed, + Ok(Err(failure)) => ReductionRun::Failed(failure), + Err(_) => ReductionRun::Panicked, + } +} + +fn failure_summary(failure: &FuzzFailure) -> &str { + failure.message.lines().next().unwrap_or(&failure.message) +} + +pub(crate) fn simplified_ops(op: &Op) -> Vec { + let mut out = HashSet::new(); + if !matches!(op, Op::Rerender) { + out.insert(Op::Rerender); + } + + match op { + Op::Rerender => {} + Op::WakeSuspense { suspense } => { + for suspense in simpler_u8_values(*suspense) { + out.insert(Op::wake_suspense(suspense)); + } + } + Op::FireEvent { target, behavior } => { + for target in simpler_u8_values(*target) { + out.insert(Op::fire_event(target, *behavior)); + } + for behavior in simplified_event_behaviors(*behavior) { + out.insert(Op::fire_event(*target, behavior)); + } + } + Op::Mutate(edit) => simplified_model_edit_ops(edit, &mut out), + } + + out.into_iter().collect() +} + +fn simplified_event_behaviors(behavior: EventBehaviorSpec) -> Vec { + let mut out = HashSet::new(); + match behavior { + EventBehaviorSpec::Noop => {} + EventBehaviorSpec::DispatchNestedEvent { target } => { + for target in simpler_u8_values(target) { + out.insert(EventBehaviorSpec::DispatchNestedEvent { target }); + } + out.insert(EventBehaviorSpec::Noop); + } + } + out.into_iter().collect() +} + +fn simplified_model_edit_ops(edit: &ModelEdit, out: &mut HashSet) { + match edit { + ModelEdit::VNode { vnode, edit } => simplified_vnode_edit_ops(*vnode, edit, out), + ModelEdit::Suspense { suspense, edit } => { + for suspense in simpler_u8_values(*suspense) { + out.insert(Op::Mutate(ModelEdit::Suspense { + suspense, + edit: *edit, + })); + } + match edit { + SuspenseEdit::Mode(mode) => { + for mode in simplified_suspense_modes(*mode) { + out.insert(Op::suspense(*suspense, mode)); + } + } + SuspenseEdit::WakeMutation(mutation) => { + for mutation in simplified_wake_mutations(*mutation) { + out.insert(Op::suspense_wake_mutation(*suspense, mutation)); + } + } + } + } + } +} + +fn simplified_vnode_edit_ops(vnode: u8, edit: &TemplateEdit, out: &mut HashSet) { + for simpler_vnode in simpler_u8_values(vnode) { + out.insert(Op::Mutate(ModelEdit::VNode { + vnode: simpler_vnode, + edit: edit.clone(), + })); + } + + for edit in simplified_template_edits(edit) { + out.insert(Op::template(vnode, edit)); + } +} + +fn peephole_cases(case: &FuzzCase, index: usize) -> Vec { + let mut out = Vec::new(); + fold_key_mode_into_previous_insert(case, index, &mut out); + out +} + +fn fold_key_mode_into_previous_insert(case: &FuzzCase, index: usize, out: &mut Vec) { + if index == 0 { + return; + } + + let Op::Mutate(ModelEdit::VNode { + vnode, + edit: + TemplateEdit::Fragment { + node, + edit: FragmentEdit::KeyMode(FragmentKeyMode::Keyed { base }), + }, + }) = &case.ops[index] + else { + return; + }; + + let Op::Mutate(ModelEdit::VNode { + vnode: previous_vnode, + edit: + TemplateEdit::Fragment { + node: previous_node, + edit: FragmentEdit::Children(ListEdit::Insert { item, .. }), + }, + }) = &case.ops[index - 1] + else { + return; + }; + + if vnode != previous_vnode || node != previous_node || item.is_some() { + return; + } + + let mut candidate = case.clone_case(); + let Op::Mutate(ModelEdit::VNode { + edit: + TemplateEdit::Fragment { + edit: FragmentEdit::Children(ListEdit::Insert { item, .. }), + .. + }, + .. + }) = &mut candidate.ops[index - 1] + else { + unreachable!(); + }; + *item = Some(*base); + candidate.ops.remove(index); + out.push(candidate); +} + +pub(crate) fn random_multistep_shrink_case(case: &mut FuzzCase, rng: &mut mutatis::Rng) -> bool { + random_multistep_shrink_case_with(case, |len| rng.gen_index(len).unwrap()) +} + +fn random_multistep_shrink_case_with( + case: &mut FuzzCase, + mut random_index: impl FnMut(usize) -> usize, +) -> bool { + if case.ops.len() <= 1 { + return false; + } + + let steps = 2 + random_index(case.ops.len().min(8)); + let mut changed = 0; + + for _ in 0..steps { + if apply_random_reduction(case, &mut random_index) { + changed += 1; + } + if case.ops.len() <= 1 { + break; + } + } + + changed >= 2 +} + +fn apply_random_reduction( + case: &mut FuzzCase, + random_index: &mut impl FnMut(usize) -> usize, +) -> bool { + if case.ops.is_empty() { + return false; + } + + match random_index(5) { + 0 => random_delete_range(random_index, case), + 1 => random_truncate(random_index, case), + 2 | 3 => random_simplify_op(random_index, case), + _ => random_peephole(random_index, case), + } +} + +fn random_delete_range(random_index: &mut impl FnMut(usize) -> usize, case: &mut FuzzCase) -> bool { + if case.ops.len() <= 1 { + return false; + } + + let max_delete = case.ops.len() - 1; + let len = 1 + random_index(max_delete); + let start = random_index(case.ops.len() - len + 1); + case.ops.drain(start..start + len); + true +} + +fn random_truncate(random_index: &mut impl FnMut(usize) -> usize, case: &mut FuzzCase) -> bool { + if case.ops.len() <= 1 { + return false; + } + + let new_len = 1 + random_index(case.ops.len() - 1); + case.ops.truncate(new_len); + true +} + +fn random_simplify_op(random_index: &mut impl FnMut(usize) -> usize, case: &mut FuzzCase) -> bool { + for _ in 0..case.ops.len().min(16) { + let index = random_index(case.ops.len()); + let replacements = simplified_ops(&case.ops[index]); + if replacements.is_empty() { + continue; + } + + case.ops[index] = replacements[random_index(replacements.len())].clone(); + return true; + } + false +} + +fn random_peephole(random_index: &mut impl FnMut(usize) -> usize, case: &mut FuzzCase) -> bool { + for _ in 0..case.ops.len().min(16) { + let index = random_index(case.ops.len()); + let candidates = peephole_cases(case, index); + if candidates.is_empty() { + continue; + } + + *case = candidates[random_index(candidates.len())].clone_case(); + return true; + } + false +} + +fn seed_from_case(case: &FuzzCase) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for byte in format!("{:?}", case.ops).bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +#[derive(Clone, Debug)] +struct ReductionRng { + state: u64, +} + +impl ReductionRng { + fn new(seed: u64) -> Self { + Self { state: seed.max(1) } + } + + fn next(&mut self) -> u64 { + let mut x = self.state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.state = x; + x + } + + fn index(&mut self, len: usize) -> usize { + debug_assert!(len > 0); + (self.next() as usize) % len + } +} + +fn simplified_template_edits(edit: &TemplateEdit) -> Vec { + let mut out = HashSet::new(); + match edit { + TemplateEdit::SetNode { node, kind } => { + for node in simpler_u8_values(*node) { + out.insert(TemplateEdit::SetNode { + node, + kind: kind.clone(), + }); + } + for kind in simplified_template_node_kinds(kind) { + out.insert(TemplateEdit::SetNode { node: *node, kind }); + } + } + TemplateEdit::Roots { edit } => { + for edit in simplified_list_edits(edit, simplified_template_node_kinds) { + out.insert(TemplateEdit::Roots { edit }); + } + } + TemplateEdit::Children { element, edit } => { + for element in simpler_u8_values(*element) { + out.insert(TemplateEdit::Children { + element, + edit: edit.clone(), + }); + } + for edit in simplified_list_edits(edit, simplified_template_node_kinds) { + out.insert(TemplateEdit::Children { + element: *element, + edit, + }); + } + } + TemplateEdit::Attrs { element, edit } => { + for element in simpler_u8_values(*element) { + out.insert(TemplateEdit::Attrs { + element, + edit: edit.clone(), + }); + } + for edit in simplified_list_edits(edit, simplified_template_attr_specs) { + out.insert(TemplateEdit::Attrs { + element: *element, + edit, + }); + } + } + TemplateEdit::Fragment { node, edit } => { + for node in simpler_u8_values(*node) { + out.insert(TemplateEdit::Fragment { + node, + edit: edit.clone(), + }); + } + for edit in simplified_fragment_edits(edit) { + out.insert(TemplateEdit::Fragment { node: *node, edit }); + } + } + TemplateEdit::DynamicAttrs { attr, edit } => { + for attr in simpler_u8_values(*attr) { + out.insert(TemplateEdit::DynamicAttrs { + attr, + edit: edit.clone(), + }); + } + for edit in simplified_list_edits(edit, simplified_attr_specs) { + out.insert(TemplateEdit::DynamicAttrs { attr: *attr, edit }); + } + } + } + out.into_iter().collect() +} + +fn simplified_template_node_kinds(kind: &TemplateNodeKind) -> Vec { + let mut out = HashSet::new(); + match kind { + TemplateNodeKind::Element { tag, namespace } => { + for tag in simpler_u8_values(*tag) { + out.insert(TemplateNodeKind::Element { + tag, + namespace: *namespace, + }); + } + for namespace in simplified_options(*namespace) { + out.insert(TemplateNodeKind::Element { + tag: *tag, + namespace, + }); + } + out.insert(TemplateNodeKind::Text(0)); + out.insert(TemplateNodeKind::Dynamic(DynamicKind::Empty)); + } + TemplateNodeKind::Text(value) => { + for value in simpler_u8_values(*value) { + out.insert(TemplateNodeKind::Text(value)); + } + out.insert(TemplateNodeKind::Dynamic(DynamicKind::Empty)); + } + TemplateNodeKind::Dynamic(kind) => { + for kind in simplified_dynamic_kinds(kind) { + out.insert(TemplateNodeKind::Dynamic(kind)); + } + } + } + out.into_iter().collect() +} + +fn simplified_template_attr_specs(attr: &TemplateAttrSpec) -> Vec { + let mut out = HashSet::new(); + match attr { + TemplateAttrSpec::Static { + name, + value, + namespace, + } => { + for name in simpler_u8_values(*name) { + out.insert(TemplateAttrSpec::Static { + name, + value: *value, + namespace: *namespace, + }); + } + for value in simpler_u8_values(*value) { + out.insert(TemplateAttrSpec::Static { + name: *name, + value, + namespace: *namespace, + }); + } + for namespace in simplified_options(*namespace) { + out.insert(TemplateAttrSpec::Static { + name: *name, + value: *value, + namespace, + }); + } + } + TemplateAttrSpec::Dynamic(attrs) => { + for attrs in simplified_attr_vecs(attrs) { + out.insert(TemplateAttrSpec::Dynamic(attrs)); + } + } + } + out.into_iter().collect() +} + +fn simplified_attr_vecs(attrs: &[AttrSpec]) -> Vec> { + let mut out = HashSet::new(); + if !attrs.is_empty() { + out.insert(Vec::new()); + } + + for index in 0..attrs.len() { + let mut candidate = attrs.to_vec(); + candidate.remove(index); + out.insert(candidate); + + for attr in simplified_attr_specs(&attrs[index]) { + let mut candidate = attrs.to_vec(); + candidate[index] = attr; + out.insert(candidate); + } + } + + out.into_iter().collect() +} + +fn simplified_dynamic_kinds(kind: &DynamicKind) -> Vec { + let mut out = HashSet::new(); + match kind { + DynamicKind::Empty => {} + DynamicKind::Text(value) => { + for value in simpler_u8_values(*value) { + out.insert(DynamicKind::Text(value)); + } + out.insert(DynamicKind::Empty); + } + DynamicKind::Placeholder => { + out.insert(DynamicKind::Empty); + } + DynamicKind::Fragment { children, key_base } => { + for children in simpler_u8_values(*children) { + out.insert(DynamicKind::Fragment { + children, + key_base: *key_base, + }); + } + for key_base in simplified_options(*key_base) { + out.insert(DynamicKind::Fragment { + children: *children, + key_base, + }); + } + out.insert(DynamicKind::Empty); + } + DynamicKind::ComponentA => { + out.insert(DynamicKind::Fragment { + children: 0, + key_base: None, + }); + out.insert(DynamicKind::Empty); + } + DynamicKind::ComponentB => { + out.insert(DynamicKind::ComponentA); + out.insert(DynamicKind::Fragment { + children: 0, + key_base: None, + }); + out.insert(DynamicKind::Empty); + } + DynamicKind::Suspense { mode } => { + for mode in simplified_suspense_modes(*mode) { + out.insert(DynamicKind::Suspense { mode }); + } + out.insert(DynamicKind::ComponentA); + out.insert(DynamicKind::Fragment { + children: 0, + key_base: None, + }); + out.insert(DynamicKind::Empty); + } + } + out.into_iter().collect() +} + +fn simplified_fragment_edits(edit: &FragmentEdit) -> Vec { + let mut out = HashSet::new(); + match edit { + FragmentEdit::KeyMode(mode) => { + for mode in simplified_fragment_key_modes(mode) { + out.insert(FragmentEdit::KeyMode(mode)); + } + } + FragmentEdit::Children(edit) => { + for edit in simplified_list_edits(edit, simplified_option_values) { + out.insert(FragmentEdit::Children(edit)); + } + } + } + out.into_iter().collect() +} + +fn simplified_fragment_key_modes(mode: &FragmentKeyMode) -> Vec { + let mut out = HashSet::new(); + match mode { + FragmentKeyMode::Unkeyed => {} + FragmentKeyMode::Keyed { base } => { + for base in simpler_u8_values(*base) { + out.insert(FragmentKeyMode::Keyed { base }); + } + out.insert(FragmentKeyMode::Unkeyed); + } + } + out.into_iter().collect() +} + +fn simplified_attr_specs(attr: &AttrSpec) -> Vec { + let mut out = HashSet::new(); + for name in simpler_u8_values(attr.name) { + let mut candidate = attr.clone(); + candidate.name = name; + out.insert(candidate); + } + for namespace in simplified_options(attr.namespace) { + let mut candidate = attr.clone(); + candidate.namespace = namespace; + out.insert(candidate); + } + for value in simplified_attr_values(&attr.value) { + let mut candidate = attr.clone(); + candidate.value = value; + out.insert(candidate); + } + if attr.volatile { + let mut candidate = attr.clone(); + candidate.volatile = false; + out.insert(candidate); + } + out.into_iter().collect() +} + +fn simplified_attr_values(value: &AttrValueSpec) -> Vec { + let mut out = HashSet::new(); + match value { + AttrValueSpec::Text(value) => { + for value in simpler_u8_values(*value) { + out.insert(AttrValueSpec::Text(value)); + } + } + AttrValueSpec::Float(value) => { + for value in simpler_u8_values(*value) { + out.insert(AttrValueSpec::Float(value)); + } + out.insert(AttrValueSpec::Int(*value)); + out.insert(AttrValueSpec::Text(0)); + } + AttrValueSpec::Int(value) => { + for value in simpler_u8_values(*value) { + out.insert(AttrValueSpec::Int(value)); + } + out.insert(AttrValueSpec::Text(0)); + } + AttrValueSpec::Bool(value) => { + if *value { + out.insert(AttrValueSpec::Bool(false)); + } + out.insert(AttrValueSpec::Text(0)); + } + AttrValueSpec::Any(value) => { + for value in simpler_u8_values(*value) { + out.insert(AttrValueSpec::Any(value)); + } + out.insert(AttrValueSpec::Text(0)); + } + AttrValueSpec::None => { + out.insert(AttrValueSpec::Text(0)); + } + AttrValueSpec::Listener => { + out.insert(AttrValueSpec::None); + out.insert(AttrValueSpec::Text(0)); + } + } + out.into_iter().collect() +} + +fn simplified_wake_mutations(mutation: WakeMutationSpec) -> Vec { + let mut out = HashSet::new(); + match mutation { + WakeMutationSpec::None => {} + WakeMutationSpec::PrependStaticRoot { tag } => { + for tag in simpler_u8_values(tag) { + out.insert(WakeMutationSpec::PrependStaticRoot { tag }); + } + out.insert(WakeMutationSpec::None); + } + } + out.into_iter().collect() +} + +fn simplified_suspense_modes(mode: SuspenseMode) -> Vec { + let mut out = HashSet::new(); + if let SuspenseMode::Ready { wake_after } = mode { + for wake_after in simpler_u8_values(wake_after) { + out.insert(SuspenseMode::Ready { wake_after }); + } + out.insert(SuspenseMode::Ready { wake_after: 0 }); + } + for candidate in [SuspenseMode::Resolved, SuspenseMode::Pending] { + if candidate != mode { + out.insert(candidate); + } + } + out.insert(SuspenseMode::Ready { wake_after: 0 }); + out.into_iter().collect() +} + +fn simplified_list_edits(edit: &ListEdit, simplify_item: fn(&T) -> Vec) -> Vec> +where + T: Clone + Eq + Hash, +{ + let mut out = HashSet::new(); + match edit { + ListEdit::Insert { index, item } => { + for index in simpler_u8_values(*index) { + out.insert(ListEdit::Insert { + index, + item: item.clone(), + }); + } + for item in simplify_item(item) { + out.insert(ListEdit::Insert { + index: *index, + item, + }); + } + out.insert(ListEdit::Remove { index: *index }); + } + ListEdit::Remove { index } => { + for index in simpler_u8_values(*index) { + out.insert(ListEdit::Remove { index }); + } + } + ListEdit::Move { from, to } => { + for from in simpler_u8_values(*from) { + out.insert(ListEdit::Move { from, to: *to }); + } + for to in simpler_u8_values(*to) { + out.insert(ListEdit::Move { from: *from, to }); + } + out.insert(ListEdit::Remove { index: *from }); + } + } + out.into_iter().collect() +} + +fn simplified_options(value: Option) -> Vec> { + let mut out = HashSet::new(); + if let Some(value) = value { + out.insert(None); + for value in simpler_u8_values(value) { + out.insert(Some(value)); + } + } + out.into_iter().collect() +} + +fn simplified_option_values(value: &Option) -> Vec> { + simplified_options(*value) +} + +fn simpler_u8_values(value: u8) -> Vec { + let mut out = HashSet::new(); + for candidate in [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + value % 8, + value % 16, + value / 2, + value.saturating_sub(1), + ] { + if candidate < value { + out.insert(candidate); + } + } + let mut out = out.into_iter().collect::>(); + out.sort_unstable(); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn passing_case_is_not_reduced() { + let case = FuzzCase::default(); + assert!( + reduce_case_to_encoded_vec(&case, usize::MAX, usize::MAX, ReductionOptions::default()) + .is_none() + ); + } + + #[test] + fn u8_simplification_prefers_small_values() { + assert_eq!(simpler_u8_values(0), Vec::::new()); + assert_eq!(simpler_u8_values(3), vec![0, 1, 2]); + assert_eq!( + simpler_u8_values(146), + vec![0, 1, 2, 3, 4, 5, 6, 7, 73, 145] + ); + } + + #[test] + fn key_mode_can_fold_into_previous_insert() { + let case = FuzzCase::new(vec![ + Op::template( + 0, + TemplateEdit::SetNode { + node: 0, + kind: TemplateNodeKind::Dynamic(DynamicKind::Fragment { + children: 0, + key_base: None, + }), + }, + ), + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: None, + }), + ), + Op::fragment( + 0, + 0, + FragmentEdit::KeyMode(FragmentKeyMode::Keyed { base: 3 }), + ), + ]); + + let candidates = peephole_cases(&case, 2); + assert_eq!(candidates.len(), 1); + assert_eq!( + candidates[0].ops[1], + Op::fragment( + 0, + 0, + FragmentEdit::Children(ListEdit::Insert { + index: 0, + item: Some(3), + }), + ) + ); + assert_eq!(candidates[0].ops.len(), 2); + } + + #[test] + fn random_multistep_can_compose_reductions() { + let mut case = FuzzCase::new(vec![Op::Rerender, Op::Rerender, Op::Rerender, Op::Rerender]); + + assert!(random_multistep_shrink_case_with(&mut case, |_| 0)); + assert_eq!(case.ops.len(), 2); + } +} diff --git a/packages/fuzz/src/vdom.rs b/packages/fuzz/src/vdom.rs new file mode 100644 index 0000000000..82e6bafd66 --- /dev/null +++ b/packages/fuzz/src/vdom.rs @@ -0,0 +1,1155 @@ +#![allow(non_snake_case)] + +use crate::{ + cache::InternSet, context::HarnessContext, lifecycle::LifecycleRole, model::*, + ops::SuspenseReadyFuture, +}; +use dioxus::prelude::*; +use dioxus_core::{ + Attribute, AttributeValue, DynamicNode, Task, Template, TemplateAttribute, TemplateNode, + VComponent, VNode, VText, +}; +use std::{ + borrow::Borrow, + future::pending, + hash::{Hash, Hasher}, +}; + +pub(crate) fn App(context: HarnessContext) -> Element { + let model = context.read_model(); + Ok(build_vnode(&context, &model.root)) +} + +#[derive(Clone, PartialEq, Props)] +struct GeneratedProps { + context: HarnessContext, + id: u64, + suspense_ancestors: Vec, + node: VNodeSpec, +} + +#[derive(Clone, PartialEq, Props)] +struct GeneratedSuspenseProps { + context: HarnessContext, + id: u64, + ready_generation: u64, + required_ready_wake_count: usize, + mode: SuspenseMode, + wake_mutation: WakeMutationSpec, + wake_applied: bool, + suspense_ancestors: Vec, + child: VNodeSpec, +} + +fn GeneratedComponent(props: GeneratedProps) -> Element { + let context = props.context; + track_lifecycle( + &context, + LifecycleRole::ComponentA, + props.id, + &props.suspense_ancestors, + ); + Ok(build_vnode_with_suspense( + &context, + &props.node, + &props.suspense_ancestors, + )) +} + +fn OtherGeneratedComponent(props: GeneratedProps) -> Element { + let context = props.context; + track_lifecycle( + &context, + LifecycleRole::ComponentB, + props.id, + &props.suspense_ancestors, + ); + Ok(build_vnode_with_suspense( + &context, + &props.node, + &props.suspense_ancestors, + )) +} + +fn GeneratedSuspenseBoundary(props: GeneratedSuspenseProps) -> Element { + let context = props.context; + track_lifecycle( + &context, + LifecycleRole::SuspenseBoundary, + props.id, + &props.suspense_ancestors, + ); + let id = props.id; + let ready_generation = props.ready_generation; + let required_ready_wake_count = props.required_ready_wake_count; + let mode = props.mode; + let wake_mutation = props.wake_mutation; + let wake_applied = props.wake_applied; + let suspense_ancestors = props.suspense_ancestors; + let child_spec = props.child; + + if vnode_contains_suspense(&child_spec) { + return rsx! { + SuspenseBoundary { + fallback: |_| rsx! { "suspense-fallback" }, + GeneratedSuspenseChild { + context, + id, + ready_generation, + required_ready_wake_count, + mode, + wake_mutation, + wake_applied, + suspense_ancestors, + child: child_spec, + } + } + }; + } + + let mut child_suspense_ancestors = suspense_ancestors.clone(); + child_suspense_ancestors.push(id); + let child = build_suspense_child_vnode( + &context, + &child_spec, + &child_suspense_ancestors, + wake_mutation, + wake_applied, + ); + rsx! { + SuspenseBoundary { + fallback: |_| rsx! { "suspense-fallback" }, + GeneratedSuspenseChild { + context: context.clone(), + id, + ready_generation, + required_ready_wake_count, + mode, + wake_mutation: WakeMutationSpec::None, + wake_applied: false, + suspense_ancestors, + child: VNodeSpec::minimal(), + } + {child} + } + } +} + +fn GeneratedSuspenseChild(props: GeneratedSuspenseProps) -> Element { + let context = props.context; + track_lifecycle( + &context, + LifecycleRole::SuspenseChild, + props.id, + &props.suspense_ancestors, + ); + let mut task: Signal> = use_signal(|| None); + let mut task_key: Signal> = use_signal(|| None); + let mut ready_resolved = use_signal(|| false); + let mut applied_wake_mutation = use_signal(|| { + if props.wake_applied { + props.wake_mutation + } else { + WakeMutationSpec::None + } + }); + + let next_task_key = match props.mode { + SuspenseMode::Resolved => None, + SuspenseMode::Pending => Some(SuspenseTaskKey::Pending(props.id)), + SuspenseMode::Ready { .. } => Some(SuspenseTaskKey::Ready(SuspenseReadyKey { + id: props.id, + generation: props.ready_generation, + })), + }; + + if task_key.cloned() != next_task_key { + if let Some(task) = task.take() { + task.cancel(); + } + task_key.set(None); + ready_resolved.set(false); + applied_wake_mutation.set(if props.wake_applied { + props.wake_mutation + } else { + WakeMutationSpec::None + }); + } else if props.wake_applied { + if applied_wake_mutation() != props.wake_mutation { + applied_wake_mutation.set(props.wake_mutation); + } + } else if props.mode == SuspenseMode::Resolved + && applied_wake_mutation() != WakeMutationSpec::None + { + applied_wake_mutation.set(WakeMutationSpec::None); + } + + match props.mode { + SuspenseMode::Resolved => { + if let Some(task) = task.take() { + task.cancel(); + } + } + SuspenseMode::Pending => { + let running = task.cloned().unwrap_or_else(|| { + let new_task = spawn(async { pending::<()>().await }); + task.set(Some(new_task)); + task_key.set(next_task_key); + new_task + }); + suspend(running)?; + } + SuspenseMode::Ready { .. } => { + if !ready_resolved() { + if let Some(running) = task.cloned() { + suspend(running)?; + } else { + let Some(SuspenseTaskKey::Ready(key)) = next_task_key else { + unreachable!(); + }; + let required_wakes = props.required_ready_wake_count; + let task_context = context.clone(); + let new_task = spawn(async move { + SuspenseReadyFuture { + context: task_context.clone(), + key, + required_wakes, + } + .await; + let wake_mutation = + task_context.read_model().wake_mutation_for_ready_key(key); + if wake_mutation != WakeMutationSpec::None { + applied_wake_mutation.set(wake_mutation); + } + ready_resolved.set(true); + }); + task_key.set(next_task_key); + if new_task.poll_now().is_pending() { + task.set(Some(new_task)); + suspend(new_task)?; + } + } + } + } + } + + let local_wake_mutation = applied_wake_mutation(); + let wake_mutation = if local_wake_mutation != WakeMutationSpec::None { + local_wake_mutation + } else { + props.wake_mutation + }; + let mut child_suspense_ancestors = props.suspense_ancestors.clone(); + child_suspense_ancestors.push(props.id); + Ok(build_suspense_child_vnode( + &context, + &props.child, + &child_suspense_ancestors, + wake_mutation, + props.wake_applied || local_wake_mutation != WakeMutationSpec::None, + )) +} + +fn track_lifecycle( + context: &HarnessContext, + role: LifecycleRole, + id: u64, + suspense_ancestors: &[u64], +) { + let suspense_ancestors = suspense_ancestors.to_vec(); + let context = context.clone(); + let guard = use_hook({ + let suspense_ancestors = suspense_ancestors.clone(); + let context = context.clone(); + move || context.lifecycle.track(role, id, &suspense_ancestors) + }); + guard.update(role, id, &suspense_ancestors); +} + +fn build_suspense_child_vnode( + context: &HarnessContext, + child: &VNodeSpec, + suspense_ancestors: &[u64], + wake_mutation: WakeMutationSpec, + wake_applied: bool, +) -> VNode { + let child = build_vnode_with_suspense(context, child, suspense_ancestors); + let WakeMutationSpec::PrependStaticRoot { tag } = wake_mutation else { + return child; + }; + if !wake_applied { + return child; + } + + let template = compile_template(&TemplateSpec { + cache_key: None, + roots: vec![ + TemplateNodeSpec::Element { + tag, + namespace: None, + attrs: Vec::new(), + children: Vec::new(), + }, + TemplateNodeSpec::Dynamic(DynamicSpec::Empty), + ], + }); + + VNode::new( + None, + template, + Box::new([DynamicNode::Fragment(vec![child])]), + Vec::>::new().into_boxed_slice(), + ) +} + +fn vnode_contains_suspense(spec: &VNodeSpec) -> bool { + spec.template + .roots + .iter() + .any(template_node_contains_suspense) +} + +fn template_node_contains_suspense(spec: &TemplateNodeSpec) -> bool { + match spec { + TemplateNodeSpec::Element { children, .. } => { + children.iter().any(template_node_contains_suspense) + } + TemplateNodeSpec::Dynamic(DynamicSpec::Fragment(nodes)) => { + nodes.iter().any(vnode_contains_suspense) + } + TemplateNodeSpec::Dynamic( + DynamicSpec::ComponentA(component) | DynamicSpec::ComponentB(component), + ) => vnode_contains_suspense(&component.child), + TemplateNodeSpec::Dynamic(DynamicSpec::Suspense(_)) => true, + TemplateNodeSpec::Text(_) | TemplateNodeSpec::Dynamic(_) => false, + } +} + +fn build_vnode(context: &HarnessContext, spec: &VNodeSpec) -> VNode { + build_vnode_with_suspense(context, spec, &[]) +} + +fn build_vnode_with_suspense( + context: &HarnessContext, + spec: &VNodeSpec, + suspense_ancestors: &[u64], +) -> VNode { + let spec = spec.clone().normalize(); + let mut dynamics = Vec::new(); + collect_dynamic_specs(&spec.template.roots, &mut dynamics); + let mut attrs = Vec::new(); + collect_dynamic_attr_specs(&spec.template.roots, &mut attrs); + VNode::new( + spec.key.map(|key| format!("k{key}")), + compile_template(&spec.template), + dynamics + .iter() + .map(|dynamic| build_dynamic(context, dynamic, suspense_ancestors)) + .collect(), + attrs + .iter() + .enumerate() + .map(|(slot, attrs)| { + attrs + .iter() + .map(|attr| build_attr(context, slot, attr)) + .collect() + }) + .collect(), + ) +} + +fn collect_dynamic_specs<'a>(nodes: &'a [TemplateNodeSpec], out: &mut Vec<&'a DynamicSpec>) { + for node in nodes { + match node { + TemplateNodeSpec::Element { children, .. } => collect_dynamic_specs(children, out), + TemplateNodeSpec::Text(_) => {} + TemplateNodeSpec::Dynamic(dynamic) => out.push(dynamic), + } + } +} + +fn collect_dynamic_attr_specs<'a>(nodes: &'a [TemplateNodeSpec], out: &mut Vec<&'a [AttrSpec]>) { + for node in nodes { + let TemplateNodeSpec::Element { + attrs, children, .. + } = node + else { + continue; + }; + + for attr in attrs { + if let TemplateAttrSpec::Dynamic(attrs) = attr { + out.push(attrs); + } + } + + collect_dynamic_attr_specs(children, out); + } +} + +fn build_dynamic( + context: &HarnessContext, + spec: &DynamicSpec, + suspense_ancestors: &[u64], +) -> DynamicNode { + match spec { + DynamicSpec::Empty => DynamicNode::Fragment(Vec::new()), + DynamicSpec::Text(value) => DynamicNode::Text(VText::new(format!("text-{value}"))), + DynamicSpec::Placeholder => DynamicNode::Placeholder(Default::default()), + DynamicSpec::Fragment(nodes) => DynamicNode::Fragment( + nodes + .iter() + .map(|node| build_vnode_with_suspense(context, node, suspense_ancestors)) + .collect(), + ), + DynamicSpec::ComponentA(component) => DynamicNode::Component(VComponent::new( + GeneratedComponent, + GeneratedProps { + context: context.clone(), + id: component.id, + suspense_ancestors: suspense_ancestors.to_vec(), + node: component.child.as_ref().clone(), + }, + "GeneratedComponent", + )), + DynamicSpec::ComponentB(component) => DynamicNode::Component(VComponent::new( + OtherGeneratedComponent, + GeneratedProps { + context: context.clone(), + id: component.id, + suspense_ancestors: suspense_ancestors.to_vec(), + node: component.child.as_ref().clone(), + }, + "OtherGeneratedComponent", + )), + DynamicSpec::Suspense(spec) => DynamicNode::Component(VComponent::new( + GeneratedSuspenseBoundary, + GeneratedSuspenseProps { + context: context.clone(), + id: spec.id, + ready_generation: spec.ready_generation, + required_ready_wake_count: spec.mode.required_ready_wake_count().unwrap_or(1) + as usize, + mode: spec.mode, + wake_mutation: spec.wake_mutation, + wake_applied: spec.wake_applied, + suspense_ancestors: suspense_ancestors.to_vec(), + child: spec.child.as_ref().clone(), + }, + "GeneratedSuspenseBoundary", + )), + } +} + +fn build_attr(context: &HarnessContext, slot: usize, spec: &AttrSpec) -> Attribute { + let namespace = spec.namespace.map(namespace_name); + match spec.value { + AttrValueSpec::Text(value) => Attribute::new( + dynamic_attr_name(slot, spec.name), + format!("attr-value-{value}"), + namespace, + spec.volatile, + ), + AttrValueSpec::Float(value) => Attribute::new( + dynamic_attr_name(slot, spec.name), + f64::from(value) / 10.0, + namespace, + spec.volatile, + ), + AttrValueSpec::Int(value) => Attribute::new( + dynamic_attr_name(slot, spec.name), + value as i64, + namespace, + spec.volatile, + ), + AttrValueSpec::Bool(value) => Attribute::new( + dynamic_attr_name(slot, spec.name), + value, + namespace, + spec.volatile, + ), + AttrValueSpec::Any(value) => Attribute::new( + dynamic_attr_name(slot, spec.name), + AttributeValue::any_value(value), + namespace, + spec.volatile, + ), + AttrValueSpec::None => Attribute::new( + dynamic_attr_name(slot, spec.name), + AttributeValue::None, + namespace, + spec.volatile, + ), + AttrValueSpec::Listener => { + let events = context.events.clone(); + Attribute::new( + listener_name(slot, spec.name), + AttributeValue::listener(move |_: Event| events.handle_listener_event()), + None, + spec.volatile, + ) + } + } +} + +fn compile_template(spec: &TemplateSpec) -> Template { + static CACHE: InternSet = InternSet::new(); + + let key = spec.cache_key(); + CACHE + .get_or_insert_with(&key, || CompiledTemplate { + key: key.clone(), + template: compile_template_uncached(spec), + }) + .template +} + +fn compile_template_uncached(spec: &TemplateSpec) -> Template { + Template::new( + intern_template_node_slice(&spec.roots, 0, 0), + intern_path_list(collect_node_paths(&spec.roots)), + intern_path_list(collect_attr_paths(&spec.roots)), + ) +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct TemplateNodeCacheKey { + spec: TemplateNodeShape, + dynamic_base: usize, + attr_base: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct TemplateNodeSliceCacheKey { + specs: Vec, + dynamic_base: usize, + attr_base: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct TemplateAttrSliceCacheKey { + attrs: Vec, + attr_base: usize, +} + +#[derive(Clone)] +struct CompiledTemplate { + key: TemplateCacheKey, + template: Template, +} + +impl Borrow for CompiledTemplate { + fn borrow(&self) -> &TemplateCacheKey { + &self.key + } +} + +impl PartialEq for CompiledTemplate { + fn eq(&self, other: &Self) -> bool { + self.key == other.key + } +} + +impl Eq for CompiledTemplate {} + +impl Hash for CompiledTemplate { + fn hash(&self, state: &mut H) { + self.key.hash(state); + } +} + +#[derive(Clone)] +struct TemplateNodeSliceEntry { + key: TemplateNodeSliceCacheKey, + nodes: &'static [TemplateNode], +} + +impl Borrow for TemplateNodeSliceEntry { + fn borrow(&self) -> &TemplateNodeSliceCacheKey { + &self.key + } +} + +impl PartialEq for TemplateNodeSliceEntry { + fn eq(&self, other: &Self) -> bool { + self.key == other.key + } +} + +impl Eq for TemplateNodeSliceEntry {} + +impl Hash for TemplateNodeSliceEntry { + fn hash(&self, state: &mut H) { + self.key.hash(state); + } +} + +#[derive(Clone)] +struct TemplateNodeEntry { + key: TemplateNodeCacheKey, + node: TemplateNode, +} + +impl Borrow for TemplateNodeEntry { + fn borrow(&self) -> &TemplateNodeCacheKey { + &self.key + } +} + +impl PartialEq for TemplateNodeEntry { + fn eq(&self, other: &Self) -> bool { + self.key == other.key + } +} + +impl Eq for TemplateNodeEntry {} + +impl Hash for TemplateNodeEntry { + fn hash(&self, state: &mut H) { + self.key.hash(state); + } +} + +#[derive(Clone)] +struct TemplateAttrSliceEntry { + key: TemplateAttrSliceCacheKey, + attrs: &'static [TemplateAttribute], +} + +impl Borrow for TemplateAttrSliceEntry { + fn borrow(&self) -> &TemplateAttrSliceCacheKey { + &self.key + } +} + +impl PartialEq for TemplateAttrSliceEntry { + fn eq(&self, other: &Self) -> bool { + self.key == other.key + } +} + +impl Eq for TemplateAttrSliceEntry {} + +impl Hash for TemplateAttrSliceEntry { + fn hash(&self, state: &mut H) { + self.key.hash(state); + } +} + +#[derive(Clone)] +struct PathListEntry { + paths: Vec>, + leaked: &'static [&'static [u8]], +} + +impl Borrow<[Vec]> for PathListEntry { + fn borrow(&self) -> &[Vec] { + &self.paths + } +} + +impl PartialEq for PathListEntry { + fn eq(&self, other: &Self) -> bool { + self.paths == other.paths + } +} + +impl Eq for PathListEntry {} + +impl Hash for PathListEntry { + fn hash(&self, state: &mut H) { + self.paths.hash(state); + } +} + +#[derive(Clone)] +struct PathEntry { + path: Vec, + leaked: &'static [u8], +} + +impl Borrow<[u8]> for PathEntry { + fn borrow(&self) -> &[u8] { + &self.path + } +} + +impl PartialEq for PathEntry { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} + +impl Eq for PathEntry {} + +impl Hash for PathEntry { + fn hash(&self, state: &mut H) { + self.path.hash(state); + } +} + +#[derive(Clone)] +struct StaticString { + text: String, + leaked: &'static str, +} + +impl Borrow for StaticString { + fn borrow(&self) -> &str { + &self.text + } +} + +impl PartialEq for StaticString { + fn eq(&self, other: &Self) -> bool { + self.text == other.text + } +} + +impl Eq for StaticString {} + +impl Hash for StaticString { + fn hash(&self, state: &mut H) { + self.text.hash(state); + } +} + +fn intern_template_node_slice( + specs: &[TemplateNodeSpec], + dynamic_base: usize, + attr_base: usize, +) -> &'static [TemplateNode] { + if specs.is_empty() { + return &[]; + } + + static CACHE: InternSet = InternSet::new(); + let key = TemplateNodeSliceCacheKey { + specs: specs.iter().map(TemplateNodeSpec::shape).collect(), + dynamic_base, + attr_base, + }; + CACHE + .get_or_insert_with(&key, || { + let mut dynamic_base = key.dynamic_base; + let mut attr_base = key.attr_base; + let mut nodes = Vec::with_capacity(key.specs.len()); + for spec in &key.specs { + nodes.push(intern_template_node(spec, dynamic_base, attr_base)); + dynamic_base += spec.dynamic_count(); + attr_base += spec.attr_count(); + } + TemplateNodeSliceEntry { + key: key.clone(), + nodes: Box::leak(nodes.into_boxed_slice()), + } + }) + .nodes +} + +fn intern_template_node( + spec: &TemplateNodeShape, + dynamic_base: usize, + attr_base: usize, +) -> TemplateNode { + static CACHE: InternSet = InternSet::new(); + let key = TemplateNodeCacheKey { + spec: spec.clone(), + dynamic_base, + attr_base, + }; + CACHE + .get_or_insert_with(&key, || TemplateNodeEntry { + node: compile_template_node(&key), + key: key.clone(), + }) + .node +} + +fn compile_template_node(key: &TemplateNodeCacheKey) -> TemplateNode { + match &key.spec { + TemplateNodeShape::Element { + tag, + namespace, + attrs, + children, + } => { + let static_attrs = intern_template_attr_shape_slice(attrs, key.attr_base); + let children_attr_base = key.attr_base + dynamic_attr_count(attrs); + TemplateNode::Element { + tag: tag_name(*tag), + namespace: namespace.map(namespace_name), + attrs: static_attrs, + children: intern_template_node_shape_slice( + children, + key.dynamic_base, + children_attr_base, + ), + } + } + TemplateNodeShape::Text(value) => TemplateNode::Text { + text: text_value(*value), + }, + TemplateNodeShape::Dynamic => TemplateNode::Dynamic { + id: key.dynamic_base, + }, + } +} + +fn intern_template_node_shape_slice( + specs: &[TemplateNodeShape], + dynamic_base: usize, + attr_base: usize, +) -> &'static [TemplateNode] { + if specs.is_empty() { + return &[]; + } + + static CACHE: InternSet = InternSet::new(); + let key = TemplateNodeSliceCacheKey { + specs: specs.to_vec(), + dynamic_base, + attr_base, + }; + CACHE + .get_or_insert_with(&key, || { + let mut dynamic_base = key.dynamic_base; + let mut attr_base = key.attr_base; + let mut nodes = Vec::with_capacity(key.specs.len()); + for spec in &key.specs { + nodes.push(intern_template_node(spec, dynamic_base, attr_base)); + dynamic_base += spec.dynamic_count(); + attr_base += spec.attr_count(); + } + TemplateNodeSliceEntry { + key: key.clone(), + nodes: Box::leak(nodes.into_boxed_slice()), + } + }) + .nodes +} + +#[cfg(test)] +fn intern_template_attr_slice( + attrs: &[TemplateAttrSpec], + attr_base: usize, +) -> &'static [TemplateAttribute] { + let attrs = attrs + .iter() + .map(TemplateAttrSpec::shape) + .collect::>(); + intern_template_attr_shape_slice(&attrs, attr_base) +} + +fn intern_template_attr_shape_slice( + attrs: &[TemplateAttrShape], + attr_base: usize, +) -> &'static [TemplateAttribute] { + if attrs.is_empty() { + return &[]; + } + + static CACHE: InternSet = InternSet::new(); + let key = TemplateAttrSliceCacheKey { + attrs: attrs.to_vec(), + attr_base, + }; + CACHE + .get_or_insert_with(&key, || { + let mut next_attr = key.attr_base; + let mut static_attrs = Vec::new(); + let mut dynamic_attrs = Vec::new(); + for attr in &key.attrs { + match attr { + TemplateAttrShape::Static { + name, + value, + namespace, + } => { + let name = attr_name(*name); + static_attrs.push(( + name, + TemplateAttribute::Static { + name, + value: attr_static_value(*value), + namespace: namespace.map(namespace_name), + }, + )); + } + TemplateAttrShape::Dynamic => { + let id = next_attr; + next_attr += 1; + dynamic_attrs.push(TemplateAttribute::Dynamic { id }); + } + } + } + static_attrs.sort_by_key(|(name, _)| *name); + let attrs = static_attrs + .into_iter() + .map(|(_, attr)| attr) + .chain(dynamic_attrs) + .collect::>(); + TemplateAttrSliceEntry { + key: key.clone(), + attrs: Box::leak(attrs.into_boxed_slice()), + } + }) + .attrs +} + +fn dynamic_attr_count(attrs: &[TemplateAttrShape]) -> usize { + attrs + .iter() + .filter(|attr| matches!(attr, TemplateAttrShape::Dynamic)) + .count() +} + +fn collect_node_paths(roots: &[TemplateNodeSpec]) -> Vec> { + let mut out = Vec::new(); + for (index, root) in roots.iter().enumerate() { + let path = vec![index as u8]; + collect_node_paths_from_node(root, path, &mut out); + } + out +} + +fn collect_node_paths_from_node(node: &TemplateNodeSpec, path: Vec, out: &mut Vec>) { + match node { + TemplateNodeSpec::Dynamic(_) => out.push(path), + TemplateNodeSpec::Element { children, .. } => { + for (index, child) in children.iter().enumerate() { + let mut child_path = path.clone(); + child_path.push(index as u8); + collect_node_paths_from_node(child, child_path, out); + } + } + TemplateNodeSpec::Text(_) => {} + } +} + +fn collect_attr_paths(roots: &[TemplateNodeSpec]) -> Vec> { + let mut out = Vec::new(); + for (index, root) in roots.iter().enumerate() { + let path = vec![index as u8]; + collect_attr_paths_from_node(root, path, &mut out); + } + out +} + +fn collect_attr_paths_from_node(node: &TemplateNodeSpec, path: Vec, out: &mut Vec>) { + let TemplateNodeSpec::Element { + attrs, children, .. + } = node + else { + return; + }; + + for attr in attrs { + if matches!(attr, TemplateAttrSpec::Dynamic(_)) { + out.push(path.clone()); + } + } + + for (index, child) in children.iter().enumerate() { + let mut child_path = path.clone(); + child_path.push(index as u8); + collect_attr_paths_from_node(child, child_path, out); + } +} + +fn intern_path_list(paths: Vec>) -> &'static [&'static [u8]] { + if paths.is_empty() { + return &[]; + } + + static CACHE: InternSet = InternSet::new(); + CACHE + .get_or_insert_with(paths.as_slice(), || { + let leaked = paths.iter().cloned().map(intern_path).collect::>(); + PathListEntry { + paths: paths.clone(), + leaked: Box::leak(leaked.into_boxed_slice()), + } + }) + .leaked +} + +fn intern_path(path: Vec) -> &'static [u8] { + if path.is_empty() { + return &[]; + } + + static CACHE: InternSet = InternSet::new(); + CACHE + .get_or_insert_with(path.as_slice(), || PathEntry { + leaked: Box::leak(path.clone().into_boxed_slice()), + path: path.clone(), + }) + .leaked +} + +fn leak_str(value: String) -> &'static str { + static CACHE: InternSet = InternSet::new(); + CACHE + .get_or_insert_with(value.as_str(), || StaticString { + leaked: Box::leak(value.clone().into_boxed_str()), + text: value.clone(), + }) + .leaked +} + +fn tag_name(value: u8) -> &'static str { + leak_str(format!("tag{value}")) +} + +fn namespace_name(value: u8) -> &'static str { + leak_str(format!("ns{value}")) +} + +fn attr_name(value: u8) -> &'static str { + leak_str(format!("attr{value}")) +} + +fn dynamic_attr_name(slot: usize, value: u8) -> &'static str { + if value & 0x80 == 0 { + attr_name(value) + } else { + listener_name(slot, value & 0x7f) + } +} + +fn listener_name(slot: usize, value: u8) -> &'static str { + leak_str(format!("onevent{slot}_{value}")) +} + +fn attr_static_value(value: u8) -> &'static str { + // Reserve high static values for aliasing dynamic text attributes. + if value >= 128 { + return leak_str(format!("attr-value-{}", value - 128)); + } + + leak_str(format!("static{value}")) +} + +fn text_value(value: u8) -> &'static str { + leak_str(format!("static-text-{value}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ptr; + + fn element( + tag: u8, + attrs: Vec, + children: Vec, + ) -> TemplateNodeSpec { + TemplateNodeSpec::Element { + tag, + namespace: None, + attrs, + children, + } + } + + #[test] + fn identical_expanded_templates_reuse_static_parts() { + let spec = TemplateSpec { + cache_key: None, + roots: vec![element( + 1, + vec![TemplateAttrSpec::Dynamic(Vec::new())], + vec![TemplateNodeSpec::Dynamic(DynamicSpec::Empty)], + )], + }; + + let first = compile_template(&spec); + let second = compile_template(&spec); + + assert!(ptr::eq(first.roots(), second.roots())); + assert!(ptr::eq(first.node_paths(), second.node_paths())); + assert!(ptr::eq(first.attr_paths(), second.attr_paths())); + } + + #[test] + fn related_templates_reuse_shared_child_slices() { + let shared_child = element( + 9, + vec![TemplateAttrSpec::Dynamic(Vec::new())], + vec![TemplateNodeSpec::Dynamic(DynamicSpec::Empty)], + ); + let first = compile_template(&TemplateSpec { + cache_key: None, + roots: vec![element(1, Vec::new(), vec![shared_child.clone()])], + }); + let second = compile_template(&TemplateSpec { + cache_key: None, + roots: vec![element(2, Vec::new(), vec![shared_child])], + }); + + let [ + TemplateNode::Element { + children: first_children, + .. + }, + ] = first.roots() + else { + panic!("expected first root element"); + }; + let [ + TemplateNode::Element { + children: second_children, + .. + }, + ] = second.roots() + else { + panic!("expected second root element"); + }; + + assert!(ptr::eq(*first_children, *second_children)); + } + + #[test] + fn dynamic_subtrees_include_dynamic_base_in_key() { + let spec = element( + 1, + Vec::new(), + vec![TemplateNodeSpec::Dynamic(DynamicSpec::Empty)], + ); + + let base_zero = intern_template_node(&spec.shape(), 0, 0); + let base_one = intern_template_node(&spec.shape(), 1, 0); + + let TemplateNode::Element { + children: [TemplateNode::Dynamic { id: zero_id }], + .. + } = base_zero + else { + panic!("expected base zero dynamic child"); + }; + let TemplateNode::Element { + children: [TemplateNode::Dynamic { id: one_id }], + .. + } = base_one + else { + panic!("expected base one dynamic child"); + }; + + assert_eq!(*zero_id, 0); + assert_eq!(*one_id, 1); + } + + #[test] + fn dynamic_attr_slices_include_attr_base_in_key() { + let attrs = [TemplateAttrSpec::Dynamic(Vec::new())]; + + let base_zero = intern_template_attr_slice(&attrs, 0); + let base_one = intern_template_attr_slice(&attrs, 1); + + assert!(matches!(base_zero, [TemplateAttribute::Dynamic { id: 0 }])); + assert!(matches!(base_one, [TemplateAttribute::Dynamic { id: 1 }])); + assert!(!ptr::eq(base_zero, base_one)); + } +} diff --git a/packages/oracle/Cargo.toml b/packages/oracle/Cargo.toml new file mode 100644 index 0000000000..3d914e095b --- /dev/null +++ b/packages/oracle/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "dioxus-renderer-oracle" +version = { workspace = true } +authors = ["Dioxus Labs"] +edition = "2024" +description = "A fast oracle renderer for validating Dioxus VirtualDom mutations." +license = "MIT OR Apache-2.0" +publish = false +repository = "https://github.com/DioxusLabs/dioxus/" +homepage = "https://dioxuslabs.com" +rust-version = "1.85.0" + +[dependencies] +dioxus-core = { workspace = true } +pretty_assertions = { workspace = true } + +[dev-dependencies] +dioxus = { workspace = true } diff --git a/packages/oracle/src/lib.rs b/packages/oracle/src/lib.rs new file mode 100644 index 0000000000..91a382c75a --- /dev/null +++ b/packages/oracle/src/lib.rs @@ -0,0 +1,15 @@ +//! A fast in-memory renderer for validating Dioxus mutation streams. +//! +//! `RendererOracle` implements [`dioxus_core::WriteMutations`] and maintains a +//! compact mock DOM. It is intended for tests and fuzzers that need renderer +//! semantics without webviews, JS bindings, layout, or serialization. + +mod renderer; +mod snapshot; +mod vdom_snapshot; + +pub use renderer::{EditSummary, OracleNodeId, RendererOracle}; +pub use snapshot::{SnapshotAttr, SnapshotNode}; + +#[cfg(test)] +mod tests; diff --git a/packages/oracle/src/renderer.rs b/packages/oracle/src/renderer.rs new file mode 100644 index 0000000000..6729c42e3d --- /dev/null +++ b/packages/oracle/src/renderer.rs @@ -0,0 +1,734 @@ +use crate::snapshot::{ + SnapshotAttrs, SnapshotListeners, SnapshotNode, attr_to_string, + remove_attr as remove_snapshot_attr, set_attr as set_snapshot_attr, snapshot_attrs, + snapshot_listeners, +}; +use crate::vdom_snapshot::vdom_snapshot; +use dioxus_core::{ + AttributeValue, Element, ElementId, Template, TemplateAttribute, TemplateNode, VirtualDom, + WriteMutations, +}; +use std::fmt; + +type NodeId = usize; +const ROOT: NodeId = 0; + +/// A stable identity token for a node in the oracle's arena. The same node retains +/// the same token across renders, which lets tests verify that the renderer moved a +/// DOM node (preserving its browser-side state — animations, focus, selection) instead +/// of dropping and re-creating it. Recreated nodes get a fresh `OracleNodeId`. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct OracleNodeId(usize); + +#[derive(Clone, Debug)] +enum NodeKind { + Document, + Element { + tag: String, + namespace: Option, + }, + Placeholder, + Text(String), +} + +#[derive(Clone, Debug)] +struct Node { + kind: NodeKind, + attrs: SnapshotAttrs, + listeners: SnapshotListeners, + children: Vec, + parent: Option, +} + +/// A category-level summary of edits applied to the renderer in one render pass. +/// +/// Counts edits by *kind* (load template, remove, replace, set attribute, ...) +/// without exposing any specific `ElementId` or edit ordering. Tests use this to +/// assert structural properties of the diff that final-DOM snapshots cannot +/// observe — e.g. "this rerender patched text in place without recreating +/// elements," "exactly two attributes changed." +/// +/// The summary is returned by [`RendererOracle::rebuild`] and +/// [`RendererOracle::render`]. +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] +pub struct EditSummary { + /// `load_template` calls — a fresh element subtree was created from a template. + pub loads: usize, + /// `remove_node` calls. + pub removes: usize, + /// `replace_node_with` calls. + pub replaces: usize, + /// `set_attribute` calls. + pub set_attrs: usize, + /// `set_node_text` calls — in-place text patches. + pub set_texts: usize, +} + +/// A fast mock renderer that applies Dioxus mutations into an in-memory tree. +pub struct RendererOracle { + arena: Vec>, + element_to_node: Vec>, + stack: Vec, + edit_counters: EditSummary, +} + +impl Default for RendererOracle { + fn default() -> Self { + Self::new() + } +} + +impl RendererOracle { + /// Create an empty document with `ElementId(0)` mapped to the document root. + pub fn new() -> Self { + Self { + arena: vec![Some(Node { + kind: NodeKind::Document, + attrs: SnapshotAttrs::default(), + listeners: SnapshotListeners::default(), + children: Vec::new(), + parent: None, + })], + element_to_node: vec![Some(ROOT)], + stack: vec![ROOT], + edit_counters: EditSummary::default(), + } + } + + /// Remove all nodes and reset the renderer to an empty document. + fn clear(&mut self) { + *self = Self::new(); + } + + /// Return a stable snapshot of the document root's children. + pub fn snapshot(&self) -> Vec { + self.node(ROOT) + .children + .iter() + .filter_map(|&child| self.snapshot_node(child)) + .collect() + } + + /// Return true if two oracle DOMs have the same visible snapshot tree. + /// + /// This is equivalent to comparing [`RendererOracle::snapshot`] output, but it + /// avoids allocating and cloning the full snapshot on the success path. + pub fn snapshot_eq(&self, other: &Self) -> bool { + self.visible_children_eq(ROOT, other, ROOT) + } + + /// Return the number of non-document nodes currently left on the mutation stack. + fn pending_stack_nodes(&self) -> usize { + self.stack.len().saturating_sub(1) + } + + /// Return true when no mutation-created nodes are left on the stack. + fn is_stack_clean(&self) -> bool { + self.stack == [ROOT] + } + + /// Assert that the mutation stack only contains the document root. + pub(crate) fn assert_stack_clean(&self) { + if let Err(error) = self.check_stack_clean() { + panic!("{error}"); + } + } + + /// Check that the mutation stack only contains the document root. + pub fn check_stack_clean(&self) -> Result<(), String> { + if self.is_stack_clean() { + Ok(()) + } else { + Err(format!( + "renderer mutation stack is not clean: expected only document root, got {} extra node(s)", + self.pending_stack_nodes() + )) + } + } + + /// Rebuild `vdom` into this renderer, assert the renderer stack is clean, and + /// return the edit summary for the rebuild. + pub fn rebuild(&mut self, vdom: &mut VirtualDom) -> EditSummary { + self.clear(); + vdom.rebuild(self); + self.assert_stack_clean(); + self.edit_counters + } + + /// Drain pending immediate work from `vdom` into this renderer, assert the + /// stack is clean, and return the edit summary for the render. + pub fn render(&mut self, vdom: &mut VirtualDom) -> EditSummary { + self.edit_counters = EditSummary::default(); + vdom.render_immediate(self); + self.assert_stack_clean(); + self.edit_counters + } + + /// Find the live [`ElementId`] of the unique element whose tag matches + /// `tag` (default namespace). Panics if zero or more than one element + /// matches — tests should make the target unambiguous (add an `id` attr + /// and use [`Self::element_id_by_attr`] instead when multiple elements + /// share a tag). + /// + /// This is the entry point for firing synthetic events without naming a + /// specific `ElementId(N)` literal in test code: look up the target + /// semantically (by tag or by attribute), then pass the returned id to + /// `vdom.runtime().handle_event(...)`. + pub fn element_id_by_tag(&self, tag: &str) -> ElementId { + let mut hits = Vec::new(); + self.visit_elements(ROOT, &mut |node, node_data| { + if let NodeKind::Element { tag: current, .. } = &node_data.kind { + if current == tag { + if let Some(id) = self.element_id_for_node(node) { + hits.push(id); + } + } + } + }); + match hits.as_slice() { + [id] => *id, + [] => panic!("no live element with tag `{tag}` found in the oracle DOM"), + many => panic!( + "tag `{tag}` is ambiguous: {} matching elements (use element_id_by_attr to disambiguate)", + many.len(), + ), + } + } + + /// Find the live [`ElementId`] of the unique element whose attribute + /// `attr_name` (in the default namespace) has the value `attr_value`. + /// Panics if zero or more than one element matches. + pub fn element_id_by_attr(&self, attr_name: &str, attr_value: &str) -> ElementId { + let mut hits = Vec::new(); + let key = (attr_name.to_string(), None); + self.visit_elements(ROOT, &mut |node, node_data| { + if node_data + .attrs + .get(&key) + .is_some_and(|value| value == attr_value) + { + if let Some(id) = self.element_id_for_node(node) { + hits.push(id); + } + } + }); + match hits.as_slice() { + [id] => *id, + [] => panic!("no live element with `{attr_name}={attr_value}` found in the oracle DOM"), + many => panic!( + "`{attr_name}={attr_value}` is ambiguous: {} matching elements", + many.len(), + ), + } + } + + fn element_id_for_node(&self, node: NodeId) -> Option { + for (idx, mapped) in self.element_to_node.iter().enumerate() { + if *mapped == Some(node) { + return Some(ElementId(idx)); + } + } + None + } + + fn visit_elements(&self, node: NodeId, visit: &mut impl FnMut(NodeId, &Node)) { + let node_data = self.node(node); + if matches!(node_data.kind, NodeKind::Element { .. }) { + visit(node, node_data); + } + for &child in &node_data.children { + self.visit_elements(child, visit); + } + } + + /// Walk the DOM and return `(attr_value, identity)` pairs for every element + /// carrying an attribute named `attr_name` in the default namespace. + /// + /// The identity is stable across renders: a node whose `OracleNodeId` matches + /// across two snapshots is *the same DOM node*, not a structurally equivalent + /// re-creation. This is how tests assert that a keyed diff moved nodes instead + /// of dropping and re-allocating them. + pub fn identities_by_attr(&self, attr_name: &str) -> Vec<(String, OracleNodeId)> { + let mut out = Vec::new(); + let key = (attr_name.to_string(), None); + self.visit_elements(ROOT, &mut |node, node_data| { + if let Some(value) = node_data.attrs.get(&key) { + out.push((value.clone(), OracleNodeId(node))); + } + }); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } + + /// Assert that this renderer's mock DOM matches the DOM described by an `rsx!` block. + /// + /// The expected side is built by walking the VNode tree of a throwaway `VirtualDom` + /// directly (via `vdom_snapshot`), without going through any `WriteMutations` path. + /// The actual side is this oracle's mock DOM, which was built by applying every + /// mutation emitted by the renderer under test. Equality therefore validates that + /// the mutation stream produced the correct DOM. + pub fn assert_matches(&self, expected: fn() -> Element) { + let mut tmp = VirtualDom::new(expected); + tmp.rebuild_in_place(); + let expected_snapshot = vdom_snapshot(&tmp); + pretty_assertions::assert_eq!( + self.snapshot(), + expected_snapshot, + "renderer DOM diverged from expected rsx tree" + ); + } + + fn alloc(&mut self, kind: NodeKind) -> NodeId { + let id = self.arena.len(); + self.arena.push(Some(Node { + kind, + attrs: SnapshotAttrs::default(), + listeners: SnapshotListeners::default(), + children: Vec::new(), + parent: None, + })); + id + } + + fn node(&self, id: NodeId) -> &Node { + self.arena + .get(id) + .and_then(Option::as_ref) + .unwrap_or_else(|| panic!("renderer referenced dead node {id}")) + } + + fn node_mut(&mut self, id: NodeId) -> &mut Node { + self.arena + .get_mut(id) + .and_then(Option::as_mut) + .unwrap_or_else(|| panic!("renderer referenced dead node {id}")) + } + + fn set_element_mapping(&mut self, id: ElementId, node: NodeId) { + if id.0 == usize::MAX { + panic!("renderer cannot map ElementId(usize::MAX)"); + } + if self.element_to_node.len() <= id.0 { + self.element_to_node.resize(id.0 + 1, None); + } + if let Some(old) = self.element_to_node[id.0] { + if old == node { + return; + } + if old != node && self.arena.get(old).is_some_and(Option::is_some) { + if self.node(old).parent.is_none() { + self.drop_subtree(old); + } else { + panic!( + "renderer remapped live ElementId({}) from node {old} to node {node}", + id.0 + ); + } + } + } + self.element_to_node[id.0] = Some(node); + } + + fn lookup(&self, id: ElementId) -> NodeId { + self.element_to_node + .get(id.0) + .and_then(|id| *id) + .filter(|&node| self.arena.get(node).is_some_and(Option::is_some)) + .unwrap_or_else(|| panic!("renderer asked for unknown ElementId({})", id.0)) + } + + /// Recursively materialize a template node. Mirrors what `native-dom` and the JS + /// interpreter do: `TemplateNode::Dynamic` becomes a real placeholder node, so + /// mutation paths can be walked as plain positional child indices. + fn clone_template(&mut self, template: &TemplateNode) -> NodeId { + match template { + TemplateNode::Element { + tag, + namespace, + attrs, + children, + } => { + let id = self.alloc(NodeKind::Element { + tag: (*tag).to_string(), + namespace: namespace.map(ToString::to_string), + }); + for attr in *attrs { + if let TemplateAttribute::Static { + name, + value, + namespace, + } = attr + { + self.set_attr( + id, + (*name).to_string(), + namespace.map(ToString::to_string), + (*value).to_string(), + ); + } + } + let child_ids: Vec = children + .iter() + .map(|child| { + let child_id = self.clone_template(child); + self.node_mut(child_id).parent = Some(id); + child_id + }) + .collect(); + self.node_mut(id).children = child_ids; + id + } + TemplateNode::Text { text } => self.alloc(NodeKind::Text((*text).to_string())), + TemplateNode::Dynamic { .. } => self.alloc(NodeKind::Placeholder), + } + } + + /// Walk from `start` through `path`, treating each segment as a positional child + /// index. Since `TemplateNode::Dynamic` slots are materialized as real placeholder + /// nodes (see `clone_template`), positional indices line up with the paths that + /// `dioxus_core` emits. + fn walk_path(&self, start: NodeId, path: &[u8]) -> NodeId { + let mut current = start; + for &segment in path { + let parent = self.node(current); + current = *parent.children.get(segment as usize).unwrap_or_else(|| { + panic!( + "renderer path {path:?} walked past node {current}; child index {segment} out of bounds (len {})", + parent.children.len() + ) + }); + } + current + } + + fn pop_nodes(&mut self, m: usize) -> Vec { + let available = self.stack.len().saturating_sub(1); + if m > available { + panic!( + "renderer stack underflow: tried to pop {m} node(s), only {available} available" + ); + } + let split = self.stack.len() - m; + self.stack.split_off(split) + } + + fn position_in_parent(&self, node: NodeId) -> (NodeId, usize) { + let parent = self + .node(node) + .parent + .unwrap_or_else(|| panic!("node {node} has no parent")); + let index = self + .node(parent) + .children + .iter() + .position(|&child| child == node) + .unwrap_or_else(|| panic!("node {node} is missing from parent {parent}")); + (parent, index) + } + + fn detach(&mut self, node: NodeId) -> (NodeId, usize) { + let (parent, index) = self.position_in_parent(node); + let removed = self.node_mut(parent).children.remove(index); + debug_assert_eq!(removed, node); + self.node_mut(node).parent = None; + (parent, index) + } + + fn unhook(&mut self, node: NodeId) { + if self.node(node).parent.is_some() { + self.detach(node); + } + } + + fn unhook_all(&mut self, nodes: &[NodeId]) { + for &node in nodes { + self.unhook(node); + } + } + + fn insert_detached(&mut self, parent: NodeId, index: usize, nodes: Vec) { + if index > self.node(parent).children.len() { + panic!( + "renderer insertion index {index} out of bounds for parent {parent} with {} children", + self.node(parent).children.len() + ); + } + for &node in nodes.iter() { + self.node_mut(node).parent = Some(parent); + } + let parent_node = self.node_mut(parent); + for (offset, node) in nodes.into_iter().enumerate() { + parent_node.children.insert(index + offset, node); + } + } + + fn append_detached(&mut self, parent: NodeId, nodes: Vec) { + for &node in nodes.iter() { + self.node_mut(node).parent = Some(parent); + } + self.node_mut(parent).children.extend(nodes); + } + + fn drop_subtree(&mut self, node: NodeId) { + if node == ROOT { + panic!("renderer cannot drop document root"); + } + let node_data = self.arena[node] + .take() + .unwrap_or_else(|| panic!("renderer tried to drop already-dead node {node}")); + for child in node_data.children { + // Children of a dropped subtree are still attached (in the dead node's + // `children`), so just recurse — no need to detach them first. + self.arena[child] + .as_mut() + .map(|n| n.parent = None) + .unwrap_or(()); + self.drop_subtree(child); + } + } + + fn assert_element(&self, node: NodeId, operation: &str) { + if !matches!(self.node(node).kind, NodeKind::Element { .. }) { + panic!( + "{operation} expected an element node, got {:?}", + self.node(node).kind + ); + } + } + + fn set_attr(&mut self, node: NodeId, name: String, namespace: Option, value: String) { + self.assert_element(node, "set_attribute"); + set_snapshot_attr(&mut self.node_mut(node).attrs, name, namespace, value); + } + + fn remove_attr(&mut self, node: NodeId, name: &str, namespace: Option<&str>) { + self.assert_element(node, "remove_attribute"); + remove_snapshot_attr(&mut self.node_mut(node).attrs, name, namespace); + } + + fn snapshot_node_eq(&self, node: NodeId, other: &Self, other_node: NodeId) -> bool { + let node_data = self.node(node); + let other_node_data = other.node(other_node); + match (&node_data.kind, &other_node_data.kind) { + (NodeKind::Document, NodeKind::Document) => { + self.visible_children_eq(node, other, other_node) + } + ( + NodeKind::Element { tag, namespace }, + NodeKind::Element { + tag: other_tag, + namespace: other_namespace, + }, + ) => { + tag == other_tag + && namespace == other_namespace + && node_data.attrs == other_node_data.attrs + && node_data.listeners == other_node_data.listeners + && self.visible_children_eq(node, other, other_node) + } + (NodeKind::Text(text), NodeKind::Text(other_text)) => text == other_text, + (NodeKind::Placeholder, NodeKind::Placeholder) => true, + _ => false, + } + } + + fn visible_children_eq(&self, node: NodeId, other: &Self, other_node: NodeId) -> bool { + let mut children = self + .node(node) + .children + .iter() + .copied() + .filter(|&child| !matches!(self.node(child).kind, NodeKind::Placeholder)); + let mut other_children = other + .node(other_node) + .children + .iter() + .copied() + .filter(|&child| !matches!(other.node(child).kind, NodeKind::Placeholder)); + + loop { + match (children.next(), other_children.next()) { + (Some(child), Some(other_child)) => { + if !self.snapshot_node_eq(child, other, other_child) { + return false; + } + } + (None, None) => return true, + _ => return false, + } + } + } + + fn snapshot_node(&self, node: NodeId) -> Option { + let node_data = self.node(node); + match &node_data.kind { + NodeKind::Document => panic!("document root is not part of snapshots"), + NodeKind::Element { tag, namespace } => Some(SnapshotNode::Element { + tag: tag.clone(), + namespace: namespace.clone(), + attrs: snapshot_attrs(&node_data.attrs), + listeners: snapshot_listeners(&node_data.listeners), + children: node_data + .children + .iter() + .filter_map(|&child| self.snapshot_node(child)) + .collect(), + }), + NodeKind::Placeholder => None, + NodeKind::Text(text) => Some(SnapshotNode::Text(text.clone())), + } + } +} + +impl WriteMutations for RendererOracle { + fn append_children(&mut self, id: ElementId, m: usize) { + let nodes = self.pop_nodes(m); + self.unhook_all(&nodes); + self.append_detached(self.lookup(id), nodes); + } + + fn assign_node_id(&mut self, path: &'static [u8], id: ElementId) { + let top = *self + .stack + .last() + .expect("renderer stack unexpectedly empty during assign_node_id"); + let node = self.walk_path(top, path); + self.set_element_mapping(id, node); + } + + fn create_placeholder(&mut self, id: ElementId) { + let node = self.alloc(NodeKind::Placeholder); + self.set_element_mapping(id, node); + self.stack.push(node); + } + + fn create_text_node(&mut self, value: &str, id: ElementId) { + let node = self.alloc(NodeKind::Text(value.to_string())); + self.set_element_mapping(id, node); + self.stack.push(node); + } + + fn load_template(&mut self, template: Template, index: usize, id: ElementId) { + self.edit_counters.loads += 1; + let root = template + .roots() + .get(index) + .unwrap_or_else(|| panic!("renderer loaded missing template root {index}")); + let node = self.clone_template(root); + self.set_element_mapping(id, node); + self.stack.push(node); + } + + fn replace_node_with(&mut self, id: ElementId, m: usize) { + self.edit_counters.replaces += 1; + let nodes = self.pop_nodes(m); + self.unhook_all(&nodes); + let target = self.lookup(id); + let (parent, index) = self.detach(target); + self.drop_subtree(target); + self.insert_detached(parent, index, nodes); + } + + fn replace_placeholder_with_nodes(&mut self, path: &'static [u8], m: usize) { + // Order matters: pop the stack first, then walk_path reads from the top. + // Mirrors `native-dom`'s `replace_placeholder_with_nodes` (mutation_writer.rs). + let nodes = self.pop_nodes(m); + self.unhook_all(&nodes); + let top = *self + .stack + .last() + .expect("renderer stack unexpectedly empty during replace_placeholder_with_nodes"); + let anchor = self.walk_path(top, path); + let (parent, index) = self.detach(anchor); + self.drop_subtree(anchor); + self.insert_detached(parent, index, nodes); + } + + fn insert_nodes_after(&mut self, id: ElementId, m: usize) { + let nodes = self.pop_nodes(m); + self.unhook_all(&nodes); + let anchor = self.lookup(id); + let (parent, index) = self.position_in_parent(anchor); + self.insert_detached(parent, index + 1, nodes); + } + + fn insert_nodes_before(&mut self, id: ElementId, m: usize) { + let nodes = self.pop_nodes(m); + self.unhook_all(&nodes); + let anchor = self.lookup(id); + let (parent, index) = self.position_in_parent(anchor); + self.insert_detached(parent, index, nodes); + } + + fn set_attribute( + &mut self, + name: &'static str, + ns: Option<&'static str>, + value: &AttributeValue, + id: ElementId, + ) { + self.edit_counters.set_attrs += 1; + let node = self.lookup(id); + match attr_to_string(value) { + Some(value) => { + self.set_attr(node, name.to_string(), ns.map(ToString::to_string), value) + } + None => self.remove_attr(node, name, ns), + } + } + + fn set_node_text(&mut self, value: &str, id: ElementId) { + self.edit_counters.set_texts += 1; + let node = self.lookup(id); + match &mut self.node_mut(node).kind { + NodeKind::Text(text) => *text = value.to_string(), + other => panic!("set_node_text expected text node, got {other:?}"), + } + } + + fn create_event_listener(&mut self, name: &'static str, id: ElementId) { + let node = self.lookup(id); + self.assert_element(node, "create_event_listener"); + let listeners = &mut self.node_mut(node).listeners; + listeners.insert(name.to_string()); + } + + fn remove_event_listener(&mut self, name: &'static str, id: ElementId) { + let node = self.lookup(id); + self.assert_element(node, "remove_event_listener"); + let listeners = &mut self.node_mut(node).listeners; + if !listeners.remove(name) { + panic!("renderer removed missing event listener {name:?}"); + } + } + + fn remove_node(&mut self, id: ElementId) { + self.edit_counters.removes += 1; + if id.0 == 0 { + panic!("renderer cannot remove document root ElementId(0)"); + } + let node = self.lookup(id); + self.detach(node); + self.drop_subtree(node); + } + + fn push_root(&mut self, id: ElementId) { + if id.0 == 0 { + panic!("dioxus emitted PushRoot {{ id: ElementId(0) }}"); + } + if id.0 == usize::MAX { + panic!("dioxus emitted PushRoot {{ id: ElementId(usize::MAX) }}"); + } + let node = self.lookup(id); + self.stack.push(node); + } +} + +impl fmt::Debug for RendererOracle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RendererOracle") + .field("snapshot", &self.snapshot()) + .field("pending_stack_nodes", &self.pending_stack_nodes()) + .finish() + } +} diff --git a/packages/oracle/src/snapshot.rs b/packages/oracle/src/snapshot.rs new file mode 100644 index 0000000000..dd466ea296 --- /dev/null +++ b/packages/oracle/src/snapshot.rs @@ -0,0 +1,65 @@ +use dioxus_core::AttributeValue; +use std::collections::{BTreeMap, BTreeSet}; + +/// A stable, comparable view of the mock renderer tree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SnapshotNode { + Element { + tag: String, + namespace: Option, + attrs: Vec, + listeners: Vec, + children: Vec, + }, + Text(String), +} + +/// A stable attribute snapshot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SnapshotAttr { + pub name: String, + pub namespace: Option, + pub value: String, +} + +pub(crate) type SnapshotAttrs = BTreeMap<(String, Option), String>; +pub(crate) type SnapshotListeners = BTreeSet; + +pub(crate) fn set_attr( + attrs: &mut SnapshotAttrs, + name: String, + namespace: Option, + value: String, +) { + attrs.insert((name, namespace), value); +} + +pub(crate) fn remove_attr(attrs: &mut SnapshotAttrs, name: &str, namespace: Option<&str>) { + attrs.remove(&(name.to_string(), namespace.map(ToString::to_string))); +} + +pub(crate) fn snapshot_attrs(attrs: &SnapshotAttrs) -> Vec { + attrs + .iter() + .map(|((name, namespace), value)| SnapshotAttr { + name: name.clone(), + namespace: namespace.clone(), + value: value.clone(), + }) + .collect() +} + +pub(crate) fn snapshot_listeners(listeners: &SnapshotListeners) -> Vec { + listeners.iter().cloned().collect() +} + +pub(crate) fn attr_to_string(value: &AttributeValue) -> Option { + match value { + AttributeValue::Text(s) => Some(s.clone()), + AttributeValue::Bool(b) => Some(b.to_string()), + AttributeValue::Float(f) => Some(f.to_string()), + AttributeValue::Int(i) => Some(i.to_string()), + AttributeValue::None => None, + _ => Some("".to_string()), + } +} diff --git a/packages/oracle/src/tests.rs b/packages/oracle/src/tests.rs new file mode 100644 index 0000000000..5cd04ac0ba --- /dev/null +++ b/packages/oracle/src/tests.rs @@ -0,0 +1,400 @@ +use super::*; +use crate::vdom_snapshot::{assert_no_mutations, fresh_snapshot, vdom_snapshot}; +use dioxus::prelude::*; +use dioxus_core::{Attribute, AttributeValue, Event, ScopeId, VirtualDom, generation}; + +fn simple_app() -> Element { + rsx! { + main { class: "root", "hello" } + } +} + +fn listener_app() -> Element { + rsx! { + button { onclick: move |_| {}, "go" } + } +} + +fn simple_app_with_different_attr() -> Element { + rsx! { + main { class: "different", "hello" } + } +} + +fn empty_dynamic_slot_app() -> Element { + let show = false; + rsx! { + main { + if show { + span { "hidden" } + } + } + } +} + +fn render_app(app: fn() -> Element) -> RendererOracle { + let mut vdom = VirtualDom::new(app); + let mut renderer = RendererOracle::new(); + renderer.rebuild(&mut vdom); + renderer +} + +#[test] +fn rebuilds_static_tree() { + let snapshot = fresh_snapshot(simple_app); + assert_eq!( + snapshot, + vec![SnapshotNode::Element { + tag: "main".to_string(), + namespace: None, + attrs: vec![SnapshotAttr { + name: "class".to_string(), + namespace: None, + value: "root".to_string(), + }], + listeners: Vec::new(), + children: vec![SnapshotNode::Text("hello".to_string())], + }] + ); +} + +#[test] +fn tracks_event_listeners() { + let snapshot = fresh_snapshot(listener_app); + match &snapshot[..] { + [SnapshotNode::Element { listeners, .. }] => assert_eq!(listeners, &["click"]), + other => panic!("unexpected snapshot: {other:#?}"), + } +} + +#[test] +fn vdom_snapshot_removes_listener_shadowed_by_later_none_attr() { + fn app() -> Element { + let attrs = vec![Attribute::new("onclick", AttributeValue::None, None, false)]; + + rsx! { + button { + onclick: move |_| {}, + ..attrs, + } + } + } + + let mut vdom = VirtualDom::new(app); + vdom.rebuild_in_place(); + match &vdom_snapshot(&vdom)[..] { + [ + SnapshotNode::Element { + attrs, listeners, .. + }, + ] => { + assert!(attrs.is_empty()); + assert!(listeners.is_empty()); + } + other => panic!("unexpected snapshot: {other:#?}"), + } +} + +#[test] +#[should_panic(expected = "renderer DOM diverged from expected rsx tree")] +fn assert_matches_rejects_stale_listener_shadowed_by_attr() { + fn expected() -> Element { + let attrs = vec![Attribute::new("onclick", AttributeValue::None, None, false)]; + + rsx! { + button { + onclick: move |_| {}, + ..attrs, + "go" + } + } + } + + render_app(listener_app).assert_matches(expected); +} + +#[test] +fn vdom_snapshot_removes_attr_shadowed_by_later_listener() { + fn app() -> Element { + let attrs = vec![Attribute::new("onclick", "raw-listener", None, false)]; + let listeners = vec![Attribute::new( + "onclick", + AttributeValue::listener(|_: Event<()>| {}), + None, + false, + )]; + + rsx! { + button { + ..attrs, + ..listeners, + } + } + } + + let mut vdom = VirtualDom::new(app); + vdom.rebuild_in_place(); + match &vdom_snapshot(&vdom)[..] { + [ + SnapshotNode::Element { + attrs, listeners, .. + }, + ] => { + assert!(attrs.is_empty()); + assert_eq!(listeners, &["click"]); + } + other => panic!("unexpected snapshot: {other:#?}"), + } +} + +#[test] +fn empty_dynamic_slots_are_not_snapshot_nodes() { + let snapshot = fresh_snapshot(empty_dynamic_slot_app); + assert_eq!( + snapshot, + vec![SnapshotNode::Element { + tag: "main".to_string(), + namespace: None, + attrs: Vec::new(), + listeners: Vec::new(), + children: Vec::new(), + }] + ); +} + +#[test] +fn asserts_no_mutations_for_idle_vdom() { + let mut vdom = VirtualDom::new(simple_app); + let mut renderer = RendererOracle::new(); + vdom.rebuild(&mut renderer); + renderer.assert_stack_clean(); + assert_no_mutations(&mut vdom); +} + +#[test] +fn assert_matches_happy_path() { + let mut vdom = VirtualDom::new(simple_app); + let mut renderer = RendererOracle::new(); + renderer.rebuild(&mut vdom); + renderer.assert_matches(simple_app); +} + +#[test] +fn assert_matches_round_trips_listeners() { + let mut vdom = VirtualDom::new(listener_app); + let mut renderer = RendererOracle::new(); + renderer.rebuild(&mut vdom); + renderer.assert_matches(listener_app); +} + +#[test] +fn snapshot_eq_matches_equal_visible_trees_without_allocated_snapshots() { + let left = render_app(simple_app); + let right = render_app(simple_app); + assert!(left.snapshot_eq(&right)); +} + +#[test] +fn snapshot_eq_detects_visible_tree_differences() { + let left = render_app(simple_app); + let right = render_app(simple_app_with_different_attr); + assert!(!left.snapshot_eq(&right)); +} + +#[test] +fn snapshot_eq_ignores_empty_dynamic_placeholders() { + let left = render_app(empty_dynamic_slot_app); + let right = render_app(empty_dynamic_slot_app); + assert!(left.snapshot_eq(&right)); +} + +#[test] +fn renderer_walks_states_in_order() { + fn app() -> Element { + match generation() { + 0 => rsx! { div { "a" } }, + 1 => rsx! { div { "b" } }, + _ => rsx! { div { "c" } }, + } + } + + fn expected_a() -> Element { + rsx! { div { "a" } } + } + + fn expected_b() -> Element { + rsx! { div { "b" } } + } + + fn expected_c() -> Element { + rsx! { div { "c" } } + } + + let mut vdom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut vdom); + oracle.assert_matches(expected_a); + + vdom.mark_dirty(ScopeId::APP); + oracle.render(&mut vdom); + oracle.assert_matches(expected_b); + + vdom.mark_dirty(ScopeId::APP); + oracle.render(&mut vdom); + oracle.assert_matches(expected_c); +} + +#[test] +fn renderer_tracks_identity_for_moved_nodes() { + fn app() -> Element { + let keys: &[i32] = match generation() { + 0 => &[0, 1, 2, 3], + 1 => &[3, 0, 1, 2], + _ => &[2, 3, 0, 1], + }; + + rsx! { + for k in keys { + div { key: "{k}", id: "{k}", "{k}" } + } + } + } + + let mut vdom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut vdom); + let first = oracle.identities_by_attr("id"); + + vdom.mark_dirty(ScopeId::APP); + oracle.render(&mut vdom); + assert_identities_preserved(&first, &oracle.identities_by_attr("id"), "id", 1); + let second = oracle.identities_by_attr("id"); + + vdom.mark_dirty(ScopeId::APP); + oracle.render(&mut vdom); + assert_identities_preserved(&second, &oracle.identities_by_attr("id"), "id", 2); +} + +#[test] +fn renderer_can_run_assertions_between_steps() { + use std::cell::Cell; + + fn app() -> Element { + match generation() { + 0 => rsx! { div { "a" } }, + 1 => rsx! { div { "b" } }, + _ => rsx! { div { "c" } }, + } + } + + let calls = Cell::new(0); + let mut vdom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut vdom); + + calls.set(calls.get() + 1); + vdom.mark_dirty(ScopeId::APP); + oracle.render(&mut vdom); + + calls.set(calls.get() + 1); + vdom.mark_dirty(ScopeId::APP); + oracle.render(&mut vdom); + + assert_eq!(calls.get(), 2); +} + +#[test] +#[should_panic(expected = "node identity for `id=hot` was not preserved")] +fn identity_check_catches_recreation() { + // Two unkeyed elements of different tag — the diff has to drop the old + // node and create a new one. The identity comparison catches that. + fn app() -> Element { + match generation() { + 0 => rsx! { div { id: "hot", "before" } }, + _ => rsx! { span { id: "hot", "after" } }, + } + } + + let mut vdom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + oracle.rebuild(&mut vdom); + let previous = oracle.identities_by_attr("id"); + + vdom.mark_dirty(ScopeId::APP); + oracle.render(&mut vdom); + assert_identities_preserved(&previous, &oracle.identities_by_attr("id"), "id", 1); +} + +#[test] +fn edit_summary_counts_rebuild_then_in_place_patch() { + // First step builds the tree; rerender with the same shape but a + // different *dynamic* text body should patch in place — same template, + // just a new value for the dynamic slot. + fn app() -> Element { + let value = match generation() { + 0 => "alpha", + _ => "beta", + }; + rsx! { div { id: "0", "{value}" } } + } + + fn expected_alpha() -> Element { + rsx! { div { id: "0", "alpha" } } + } + + fn expected_beta() -> Element { + rsx! { div { id: "0", "beta" } } + } + + let mut vdom = VirtualDom::new(app); + let mut oracle = RendererOracle::new(); + + let rebuild = oracle.rebuild(&mut vdom); + oracle.assert_matches(expected_alpha); + assert!( + rebuild.loads >= 1, + "rebuild should load at least one template" + ); + + vdom.mark_dirty(ScopeId::APP); + let patch = oracle.render(&mut vdom); + oracle.assert_matches(expected_beta); + assert_eq!( + patch.loads, 0, + "in-place text patch should not load templates" + ); + assert_eq!(patch.set_texts, 1, "exactly one text patch expected"); + assert_eq!(patch.removes, 0); + assert_eq!(patch.replaces, 0); +} + +#[test] +#[should_panic(expected = "renderer DOM diverged from expected rsx tree")] +fn assert_matches_fails_on_divergence() { + fn other() -> Element { + rsx! { main { class: "different", "hello" } } + } + let mut vdom = VirtualDom::new(simple_app); + let mut renderer = RendererOracle::new(); + renderer.rebuild(&mut vdom); + renderer.assert_matches(other); +} + +fn assert_identities_preserved( + previous: &[(String, OracleNodeId)], + current: &[(String, OracleNodeId)], + attr: &str, + step: usize, +) { + for (value, previous_id) in previous { + if let Some((_, current_id)) = current + .iter() + .find(|(current_value, _)| current_value == value) + { + assert_eq!( + previous_id, current_id, + "step {step}: node identity for `{attr}={value}` was not preserved" + ); + } + } +} diff --git a/packages/oracle/src/vdom_snapshot.rs b/packages/oracle/src/vdom_snapshot.rs new file mode 100644 index 0000000000..ebc01e105a --- /dev/null +++ b/packages/oracle/src/vdom_snapshot.rs @@ -0,0 +1,171 @@ +#[cfg(test)] +use crate::renderer::RendererOracle; +use crate::snapshot::{ + SnapshotAttrs, SnapshotListeners, SnapshotNode, attr_to_string, + remove_attr as remove_snapshot_attr, set_attr as set_snapshot_attr, snapshot_attrs, + snapshot_listeners, +}; +#[cfg(test)] +use dioxus_core::Element; +use dioxus_core::{ + Attribute, AttributeValue, DynamicNode, TemplateAttribute, TemplateNode, VNode, VirtualDom, +}; + +/// Render `app` from scratch into a stable snapshot. +#[cfg(test)] +pub(crate) fn fresh_snapshot(app: fn() -> Element) -> Vec { + let mut vdom = VirtualDom::new(app); + let mut renderer = RendererOracle::new(); + vdom.rebuild(&mut renderer); + renderer.assert_stack_clean(); + pretty_assertions::assert_eq!(renderer.snapshot(), vdom_snapshot(&vdom)); + renderer.snapshot() +} + +/// Snapshot the raw rendered VDOM tree without using renderer mutations. +pub(crate) fn vdom_snapshot(vdom: &VirtualDom) -> Vec { + vnode_snapshot(vdom, vdom.base_scope().root_node()) +} + +/// Assert that an immediate render emits no Dioxus mutations. +#[cfg(test)] +pub(crate) fn assert_no_mutations(vdom: &mut VirtualDom) { + use dioxus_core::Mutations; + + let mut mutations = Mutations::default(); + vdom.render_immediate(&mut mutations); + assert!( + mutations.edits.is_empty(), + "expected no mutations, got {} mutation(s):\n{:#?}", + mutations.edits.len(), + mutations.edits + ); +} + +fn vnode_snapshot(vdom: &VirtualDom, vnode: &VNode) -> Vec { + let mut out = Vec::new(); + for (root_idx, root) in vnode.template.roots().iter().enumerate() { + let path = [root_idx as u8]; + out.extend(template_node_snapshot(vdom, vnode, root, &path)); + } + out +} + +fn template_node_snapshot( + vdom: &VirtualDom, + vnode: &VNode, + node: &TemplateNode, + path: &[u8], +) -> Vec { + match node { + TemplateNode::Element { + tag, + namespace, + attrs, + children, + } => { + let mut element_attrs = SnapshotAttrs::default(); + let mut listeners = SnapshotListeners::default(); + + for attr in *attrs { + if let TemplateAttribute::Static { + name, + value, + namespace, + } = attr + { + set_snapshot_attr( + &mut element_attrs, + (*name).to_string(), + namespace.map(ToString::to_string), + (*value).to_string(), + ); + } + } + + for (idx, attr_path) in vnode.template.attr_paths().iter().enumerate() { + if *attr_path == path { + for attr in &*vnode.dynamic_attrs[idx] { + apply_dynamic_attr(&mut element_attrs, &mut listeners, attr); + } + } + } + + let mut rendered_children = Vec::new(); + for (child_idx, child) in children.iter().enumerate() { + let mut child_path = Vec::with_capacity(path.len() + 1); + child_path.extend_from_slice(path); + child_path.push(child_idx as u8); + rendered_children.extend(template_node_snapshot(vdom, vnode, child, &child_path)); + } + + vec![SnapshotNode::Element { + tag: (*tag).to_string(), + namespace: namespace.map(ToString::to_string), + attrs: snapshot_attrs(&element_attrs), + listeners: snapshot_listeners(&listeners), + children: rendered_children, + }] + } + TemplateNode::Text { text } => vec![SnapshotNode::Text((*text).to_string())], + TemplateNode::Dynamic { id } => dynamic_node_snapshot(vdom, vnode, *id), + } +} + +fn dynamic_node_snapshot(vdom: &VirtualDom, owner: &VNode, id: usize) -> Vec { + match &owner.dynamic_nodes[id] { + DynamicNode::Text(text) => vec![SnapshotNode::Text(text.value.clone())], + DynamicNode::Fragment(nodes) => nodes + .iter() + .flat_map(|node| vnode_snapshot(vdom, node)) + .collect(), + DynamicNode::Component(component) => { + let scope = component.mounted_scope(id, owner, vdom).unwrap_or_else(|| { + panic!( + "component dynamic node {id} ({}) is not mounted", + component.name + ) + }); + vnode_snapshot(vdom, scope.root_node()) + } + DynamicNode::Placeholder(_) => Vec::new(), + } +} + +fn apply_dynamic_attr( + attrs: &mut SnapshotAttrs, + listeners: &mut SnapshotListeners, + attr: &Attribute, +) { + match &attr.value { + AttributeValue::Listener(_) => { + remove_snapshot_attr(attrs, attr.name, attr.namespace); + listeners.insert(listener_name(attr.name).to_string()); + } + value => match attr_to_string(value) { + Some(value) => { + remove_listener_for_attr(listeners, attr); + set_snapshot_attr( + attrs, + attr.name.to_string(), + attr.namespace.map(ToString::to_string), + value, + ); + } + None => { + remove_listener_for_attr(listeners, attr); + remove_snapshot_attr(attrs, attr.name, attr.namespace); + } + }, + } +} + +fn listener_name(attr_name: &str) -> &str { + attr_name.strip_prefix("on").unwrap_or(attr_name) +} + +fn remove_listener_for_attr(listeners: &mut SnapshotListeners, attr: &Attribute) { + if attr.namespace.is_none() { + listeners.remove(listener_name(attr.name)); + } +} diff --git a/packages/rsx-hotreload/src/extensions.rs b/packages/rsx-hotreload/src/extensions.rs index 88ab4d2b2d..55456dd5fa 100644 --- a/packages/rsx-hotreload/src/extensions.rs +++ b/packages/rsx-hotreload/src/extensions.rs @@ -27,6 +27,32 @@ pub(crate) fn html_tag_and_namespace( .unwrap_or((intern(attribute_name_rust.as_str()), None)) } +fn sorted_template_attributes( + attributes: &[Attribute], +) -> Vec { + let mut static_attrs = Vec::new(); + let mut dynamic_attrs = Vec::new(); + + for attr in attributes { + let template_attr = to_template_attribute::(attr); + match &template_attr { + dioxus_core::TemplateAttribute::Static { name, .. } => { + static_attrs.push((*name, template_attr)); + } + dioxus_core::TemplateAttribute::Dynamic { .. } => { + dynamic_attrs.push(template_attr); + } + } + } + + static_attrs.sort_by_key(|(left, _)| *left); + static_attrs + .into_iter() + .map(|(_, attr)| attr) + .chain(dynamic_attrs) + .collect() +} + pub fn to_template_attribute( attr: &Attribute, ) -> dioxus_core::TemplateAttribute { @@ -76,12 +102,7 @@ pub fn to_template_node(node: &BodyNode) -> dioxus_cor .map(|c| to_template_node::(c)) .collect::>(), ), - attrs: intern( - el.merged_attributes - .iter() - .map(|attr| to_template_attribute::(attr)) - .collect::>(), - ), + attrs: intern(sorted_template_attributes::(&el.merged_attributes)), } } BodyNode::Text(text) => text_to_template_node(text), diff --git a/packages/rsx/src/element.rs b/packages/rsx/src/element.rs index 352ead79c6..980112b952 100644 --- a/packages/rsx/src/element.rs +++ b/packages/rsx/src/element.rs @@ -116,46 +116,51 @@ impl ToTokens for Element { ElementName::Custom(_) => quote! { None }, }; - let static_attrs = el - .merged_attributes - .iter() - .map(|attr| { - // Rendering static attributes requires a bit more work than just a dynamic attrs - // Early return for dynamic attributes - let Some((name, value)) = attr.as_static_str_literal() else { - let id = attr.dyn_idx.get(); - return quote! { dioxus_core::TemplateAttribute::Dynamic { id: #id } }; - }; - - let ns = match name { - AttributeName::BuiltIn(name) => ns(quote!(#name.1)), - AttributeName::Custom(_) => quote!(None), - AttributeName::Spread(_) => { - unreachable!("spread attributes should not be static") - } - }; + let mut static_attrs = Vec::new(); + let mut dynamic_attrs = Vec::new(); + for attr in &el.merged_attributes { + // Rendering static attributes requires a bit more work than just a dynamic attrs + let Some((name, value)) = attr.as_static_str_literal() else { + let id = attr.dyn_idx.get(); + dynamic_attrs.push(quote! { dioxus_core::TemplateAttribute::Dynamic { id: #id } }); + continue; + }; - let name = match (el_name, name) { - (ElementName::Ident(_), AttributeName::BuiltIn(_)) => { - quote! { dioxus_elements::#el_name::#name.0 } - } - //hmmmm I think we could just totokens this, but the to_string might be inserting quotes - _ => { - let as_string = name.to_string(); - quote! { #as_string } - } - }; + let ns = match name { + AttributeName::BuiltIn(name) => ns(quote!(#name.1)), + AttributeName::Custom(_) => quote!(None), + AttributeName::Spread(_) => { + unreachable!("spread attributes should not be static") + } + }; - let value = value.to_static().unwrap(); + let name = match (el_name, name) { + (ElementName::Ident(_), AttributeName::BuiltIn(_)) => { + quote! { dioxus_elements::#el_name::#name.0 } + } + //hmmmm I think we could just totokens this, but the to_string might be inserting quotes + _ => { + let as_string = name.to_string(); + quote! { #as_string } + } + }; + + let value = value.to_static().unwrap(); - quote! { + static_attrs.push(quote! { dioxus_core::TemplateAttribute::Static { name: #name, namespace: #ns, value: #value, } - } - }) + }); + } + // Dynamic attrs must stay ordered by their dynamic id, but static attrs need to be + // searchable by the emitted DOM name. Let core sort the fully expanded names so raw + // identifiers and RSX aliases do not use their Rust spelling as the sort key. + let template_attrs = static_attrs + .into_iter() + .chain(dynamic_attrs) .collect::>(); // Render either the child @@ -202,7 +207,7 @@ impl ToTokens for Element { dioxus_core::TemplateNode::Element { tag: #el_name, namespace: #ns, - attrs: &[ #(#static_attrs),* ], + attrs: &dioxus_core::internal::sort_template_attributes([ #(#template_attrs),* ]), children: &[ #(#children),* ], } }