Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ exclude = ["build/vertigo-cli-test/some_app"]
resolver = "2"

[workspace.package]
version = "0.12.0"
version = "0.13.0"
edition = "2024"
repository = "https://github.com/vertigo-web/vertigo"
authors = [
Expand All @@ -32,8 +32,8 @@ keywords = ["wasm", "web", "isomorphic", "reactive", "javascript"]
homepage = "https://vertigo.znoj.pl"

[workspace.dependencies]
vertigo = { path = "./crates/vertigo", version = "0.12.0" }
vertigo-macro = { path = "./crates/vertigo-macro", version = "0.12.0" }
vertigo = { path = "./crates/vertigo", version = "0.13.0" }
vertigo-macro = { path = "./crates/vertigo-macro", version = "0.13.0" }

[workspace.lints.clippy]
unwrap_used = "deny"
Expand Down
5 changes: 5 additions & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ tasks:
cmds:
- cargo test --all-features

reactive-compare:
desc: Compare old vs new reactive graph performance
cmds:
- cargo test -p vertigo --lib reactive_old::compare -- --nocapture

automated-tests:
desc: "NOTE: WebDriver on localhost:9515 needs to be running"
cmds:
Expand Down
160 changes: 160 additions & 0 deletions crates/vertigo/docs/reactive-graph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# The reactive graph

How `Value`, `Computed` and `subscribe` fit together, and what happens between a write and
the resulting recomputation.

## The model

Three kinds of node:

| node | created by | holds |
| ---- | ---------- | ----- |
| value | [`Value::new`](crate::Value::new) | a value you write |
| computed | [`Computed::from`](crate::Computed::from), `map`, `to_computed` | a cached value derived from other nodes |
| subscription | [`Computed::subscribe`](crate::Computed::subscribe) | a callback, no value |

Edges are never declared. They are recorded while a node computes: reading a node through
[`Value::get`](crate::Value::get) / [`Computed::get`](crate::Computed::get) registers the
node that was read as a parent of the node doing the reading. Each run replaces the whole
parent set, so a computed that stops reading something stops depending on it.

A child holds a strong reference to its parents, so a parent cannot be dropped while
something still reads it. The graph itself only holds weak references, so dropping the last
`Value` / `Computed` handle removes the node.

## Transactions

A write is applied immediately, but nothing downstream runs until the outermost transaction
closes:

```rust,ignore
transaction(|_| {
first_name.set("Ada".to_string());
last_name.set("Lovelace".to_string());
});
// one propagation pass here, not two
```

Transactions nest; only the outermost one propagates. `Value::set` outside a transaction is
its own one-write transaction, which is why a lone `set` still works.

After propagation finishes, the callbacks registered with
[`on_after_transaction`](crate::reactive::on_after_transaction) run. The DOM driver uses
one of those to flush its batched commands.

## One propagation pass

The writes leave a set of dirty nodes. The pass repeatedly takes a node whose parents are
all up to date, recomputes it, and then decides whether to carry on:

```text
value written ──> node recomputed ──> value changed? ──yes──> dependents queued
no
└──> stop
```

That last step is the **equality cutoff**. A node's dependents are queued only when its new
value differs (`PartialEq`) from the old one. A change that computes back to the same value
therefore stops where it is instead of travelling through the graph. This is why `Computed`
requires `T: PartialEq`.

The cutoff is what makes wide fan-out cheap:

```rust,ignore
let is_even = number.map(|n| n % 2 == 0);
// thousands of nodes reading `is_even`

number.set(3); // was 1: `is_even` recomputes, stays false, nothing downstream runs
number.set(4); // now it flips, and the fan-out runs
```

A subscription never has dependents, so its callback is a leaf of the pass.

## Laziness

A `Computed` does not compute when it is created. It computes on the first read, and after
that it is kept up to date by propagation for as long as something references it. A compute
closure therefore runs on a schedule you do not control - it must be a pure function of the
nodes it reads.

## Writing from a callback

`Value::set` from a compute closure or a `subscribe` callback is forbidden. The write is
ignored and logged. Those closures run during a wave (or the first refresh of a
subscription); feeding values back into the graph would re-enter the wave. Domain rules:
[`invariants`](crate::reactive::invariants).

```rust,ignore
selected_id.to_computed().subscribe(move |id| {
// ignored: subscribe must not write
form_dirty.set(false);
});
```

Legal places to write: DOM/event handlers, timers, fetch/socket callbacks,
`on_after_transaction`, and `when_connect` / `Value::with_connect`.

A node may still be recomputed more than once in one pass because of the ordering
limitation below. The wave always finishes; the last value of each node is the one that
matches the sources.

## Connecting to the outside world

[`when_connect`](crate::Computed::when_connect) runs a closure **after the wave** in which
a node gains its first dependent, and drops the returned
[`DropResource`](crate::DropResource) after the wave in which it loses its last one.
Watched-then-unwatched in the same wave is a no-op. This is how a node backed by a fetch,
a timer or a socket only does work while something is actually looking at it.
[`Value::with_connect`](crate::Value::with_connect) packages the common shape of it;
because `create` runs after the wave, it may write the `Value`.

## Isolated graphs

`Value::new` and `Computed::from` use one graph per thread. `Graph::new()` creates a
separate one; nodes belonging to different graphs never see each other. Tests use this to
avoid sharing state.

## Known limitation: ordering within a pass

A node can be recomputed more than once in a single pass, and a subscriber can observe an
intermediate value that corresponds to no consistent state of the inputs.

Readiness is decided by counting how many of a node's parents are dirty *at the moment the
node is queued*. The dirty set is not known up front - it grows as the pass runs, because
the cutoff only queues a dependent once its parent's value really changed. So a node can be
declared ready while a parent that is about to become dirty has not been queued yet. When
that parent finally changes, the node runs again.

Two paths of unequal length from two values written together are enough to show it:

```text
d1 ─────────────> b1 ┐
├──> c
d2 ──> e2 ──> f2 ──> b2 ┘
```

Writing `d1` and `d2` in one transaction computes `c` twice: once with the new `b1` and the
old `b2`, then again once `b2` catches up. A conditional read adds a second source of the
same problem, because a parent discovered during this run cannot have been waited for.

The count is one run per incoming path, so it scales with the fan-in. The extra runs are
wasted work and can glitch a subscriber, but the last refresh is the correct value. The
tests in `reactive::propagation_order` pin that. The intended fix is to refresh a dirty
node when it is read instead of serving its cached value, which removes both the extra
runs and the intermediate values.

## Coming from 0.12

* The old `Dependencies` type is gone; use [`transaction`](crate::transaction),
[`Driver::transaction`](crate::Driver::transaction) and
[`Driver::on_after_transaction`](crate::Driver::on_after_transaction).
* `Computed<T>` now requires `T: PartialEq` everywhere, not only for `subscribe`.
* `subscribe_all` is gone. It reported every recomputation including the ones that produced
the same value, and those no longer notify anybody.
* Writing a `Value` from a compute or subscribe callback is ignored and logged (same idea as
0.12's *"You cannot change the source value while the dependency graph is being refreshed"*).
`when_connect` / `Value::with_connect` run after the wave and may write.
* Reading through `transaction(|ctx| ...)` serves the cached value instead of recomputing
the chain behind it.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::fmt::Debug;
use std::{hash::Hash, rc::Rc};

use crate::computed::struct_mut::HashMapMut;
use crate::struct_mut::HashMapMut;

type CreateType<K, V> = Box<dyn Fn(&AutoMap<K, V>, &K) -> V>;

Expand Down
78 changes: 0 additions & 78 deletions crates/vertigo/src/computed/mod.rs

This file was deleted.

Loading
Loading