Scopes and lifetimes
Where a screen's state lives, how long it lives, and why nothing can keep it alive past its screen.
The store holds the facts your screens present. It is divided into scopes. Each scope is one Rust struct, provided by one type, keyed by who owns it. How long a scope lives is decided by one rule: a scope lives exactly as long as its node is on the path.
The three lifetimes
A scope's key is a Scope:
pub enum Scope {
Process,
Node(RouteId),
}
That gives three lifetimes:
| Key | Lives |
|---|---|
Scope::Process | From the moment it is provided until the store is dropped |
Scope::Node(id), id structural | While id is on the path, which spans all its children |
Scope::Node(id), id a view or instance root | While that screen is on the path |
Use Process for facts the whole app shows, such as a theme setting or the list of documents. Use a node scope for state that belongs to one screen, such as an editor's word count. Declare a structural node when some state must outlive several sibling screens, such as a signed-in session that spans a lobby and a game.
Providing a scope
const EDITOR: Scope = Scope::Node("note");
let mut store = Store::over(&table());
store.provides::<Editor>(EDITOR)?;
Store::over builds a store that knows the table's ids, so a scope keyed on a misspelled id fails here with StoreError::UnknownNode, instead of never mounting. Store::new() builds one without that check.
provides takes a type and no value. A scope's first value comes from its schema's declared starting values. See Schemas. The type must implement Schema and Clone and be 'static. The ScopeSchema trait names those bounds and is implemented for you. Each field you set must also implement PartialEq, because every set is compared with the value already there.
A process scope mounts as soon as it is provided. A node scope waits for its node to join the path.
One scope has one provider. Providing a second time is StoreError::AlreadyProvided. A scope's schema must be a record of named fields, or provides returns StoreError::NotARecord.
Mounting is the walk's job
When router.resolve moves the path, it drops the scopes of nodes that left and mounts the scopes of nodes that joined, at their declared starting values. leave runs before the drop, so a departing node can still read its scope. enter runs after the mount, so an arriving node finds its scope in place.
There is no public way to mount or unmount a scope. That is deliberate. If you could mount one by hand, you could keep one alive past its screen, and stale state that outlived its screen would be back. Instead, state from one editing session simply cannot leak into the next. The notes example tests this: reopen a note and its editor starts from the saved text, not the abandoned draft.
Reading
if let Some(e) = store.read::<Editor>(EDITOR) {
println!("{} ({} words)", e.title, e.words);
}
read returns &T of the last committed value, or None while the scope is not mounted. There is no map or lookup on the read path. You downcast once and read fields directly.
Other read-side questions:
| Method | Answers |
|---|---|
is_mounted(scope) | Whether the scope exists right now |
version(scope) | A stamp that changes on every commit that changed it, and on mount and unmount. 0 when not mounted |
reflection(scope) | The schema's Kind, for validation and diagnostics |
provided_as::<T>(scope) | Whether the scope is provided as T |
Version stamps come from one counter for the whole store, so a scope that leaves and comes back never reuses a stamp. A renderer can hold the last stamp it drew and compare one integer.
read with the wrong type returns None and fails a debug assertion. If your code reads a scope that something else provides, call provided_as once at startup, which returns StoreError::WrongType naming both types, instead of finding out on the first frame the screen opens.
Subscribing
Reading is pull: you read what you need each frame. Subscribing is push: you are told when a field you care about changed.
let watcher = store.subscriber();
store.subscribe(watcher, EDITOR, "dirty")?;
A SubscriberId is an opaque id, not a callback. After each tick, the Notice says who is owed a re-render. See Producers and the tick. You can subscribe to a node scope that is not mounted yet. A scope mounting or unmounting wakes its subscribers too, on the next tick. unsubscribe_all(who) drops every subscription a subscriber holds.
Fields are top level
A scope's fields are the record's top-level fields. A nested struct is one field and changes as a whole. A Vec is one field, so changing one row wakes everyone watching the list. Keyed collections are not built yet. If you need finer updates, flatten.
What belongs in a scope
The store carries facts about what should be presented, not the presentation's bulk data. A selected id, a title, a count, a flag: yes. A vertex buffer, an image, ten thousand rows: no. A scope is cloned on every commit that touched it, so bulk data costs you every frame. Keep large data in your model and put the facts about it in the store.
Next
Schemas: describing a scope's struct so the store can mount it.