From d72a8a40dc5b991282130adb99fb2c4a081efa9f Mon Sep 17 00:00:00 2001 From: Michal Pokrywka Date: Fri, 11 Sep 2026 16:28:25 +0200 Subject: [PATCH] feat: add WithStableLoader to persist form state during background resource reloads --- CHANGES.md | 6 +++ Cargo.lock | 12 +++-- Cargo.toml | 4 +- README.md | 2 +- src/lib.rs | 2 +- src/with_loader.rs | 84 +++++++++++++++++++++++++++-- storybook/src/lib.rs | 9 ++++ storybook/src/with_stable_loader.rs | 63 ++++++++++++++++++++++ 8 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 storybook/src/with_stable_loader.rs diff --git a/CHANGES.md b/CHANGES.md index da593cc..bb97f23 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,12 @@ +## Unreleased + +### Added + +* `with_stable_loader` and `WithStableLoader` - a loader that renders once, when the resource first becomes ready, and keeps the rendered content on later `Loading`/`Error`, so edit forms survive background re-fetches + ## 0.2.1 - 2026-09-07 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 1734c37..bf27ce4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "cc" version = "1.4.5" @@ -467,7 +473,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d973870822864b0a6ae9bc191172ed162ae2ed492aafe52850bfd1c67ddf3001" dependencies = [ - "base64", + "base64 0.22.1", "log", "vertigo-macro", ] @@ -476,7 +482,7 @@ dependencies = [ name = "vertigo-forms" version = "0.2.1" dependencies = [ - "base64", + "base64 0.23.1", "either", "vertigo", ] @@ -511,7 +517,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61132b314900597417642d2aeaf495fd4cbcb32acb6c2e17ca14853a8ffaca8c" dependencies = [ - "base64", + "base64 0.22.1", "crc", "darling", "heck", diff --git a/Cargo.toml b/Cargo.toml index 5465f76..b9327f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ name = "vertigo-forms" version = "0.2.1" authors = ["MichaƂ Pokrywka "] description = "Building block for forms in vertigo" -readme = "README.md" +readme = { workspace = true } keywords = ["wasm", "web", "isomorphic", "reactive", "javascript"] homepage = "https://vertigo.znoj.pl" repository = "https://github.com/vertigo-web/vertigo-forms" @@ -19,7 +19,7 @@ edition = "2024" vertigo = "0.13" [dependencies] -base64 = "0.22" +base64 = "0.23" either = "1.15" vertigo = { workspace = true } diff --git a/README.md b/README.md index c479e79..ef06f0e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ See [Changelog](https://github.com/vertigo-web/vertigo-forms/blob/master/CHANGES Dependencies: ```toml -vertigo = "0.12" +vertigo = "0.13" vertigo-forms = "0.1" ``` diff --git a/src/lib.rs b/src/lib.rs index 62dcfa8..3df1336 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,7 +28,7 @@ pub use { spinner::Spinner, switch::{Switch, SwitchParams}, tabs::{Tab, Tabs, TabsContent, TabsContentMapped, TabsHeader, TabsParams}, - with_loader::{WithLoader, with_loader}, + with_loader::{WithLoader, WithStableLoader, with_loader, with_stable_loader}, }; pub type ValidationErrors = HashMap; diff --git a/src/with_loader.rs b/src/with_loader.rs index 00a660c..6c33ee8 100644 --- a/src/with_loader.rs +++ b/src/with_loader.rs @@ -1,12 +1,12 @@ -use std::rc::Rc; -use vertigo::{Computed, DomNode, Resource, component, dom}; +use std::{cell::Cell, rc::Rc}; +use vertigo::{Computed, DomNode, Resource, component, dom, transaction}; use crate::Spinner; /// Wraps [Resource\](https://docs.rs/vertigo/latest/vertigo/enum.Resource.html) /// and shows [Spinner], error or renders value based on Resource variant. /// -/// See also [with_loader]. +/// See also [with_loader] and [WithStableLoader]. #[component] pub fn WithLoader( resource: Computed>, @@ -18,7 +18,7 @@ pub fn WithLoader( /// Wraps [Resource\](https://docs.rs/vertigo/latest/vertigo/enum.Resource.html) /// and shows [Spinner], error or renders value based on Resource variant. /// -/// See also [WithLoader]. +/// See also [WithLoader] and [with_stable_loader]. pub fn with_loader( resource: Computed>, render: Rc DomNode>, @@ -33,3 +33,79 @@ pub fn with_loader( }, }) } + +/// Which branch of a [Resource\](https://docs.rs/vertigo/latest/vertigo/enum.Resource.html) +/// is showing, without the value itself. +/// +/// Keeping the value out is the whole point: this is what the stable loader below subscribes +/// to, so a re-fetch that lands on the same branch changes nothing. +#[derive(Clone, PartialEq)] +enum LoadState { + Loading, + Ready, + Error(String), +} + +/// Like [with_loader], but `render` runs once, when the resource first becomes ready, +/// instead of on every value it takes afterwards. +/// +/// See also [WithStableLoader]. +/// +/// [with_loader] subscribes to the whole `Resource`, so every re-fetch rebuilds the subtree +/// it renders. That is right for a read-only view and wrong for an edit form: re-fetching +/// after a save would throw away the fields the user is working in, the active tab and any +/// "saved" label. So the subscription here is on the `Loading` / `Ready` / `Error` branch +/// alone, and `render` gets the value as it stands when the resource becomes ready. +/// +/// Ready is sticky: once the content is up, a later `Loading` or `Error` leaves it alone. +/// A form the user is halfway through is worth more than the error message from a background +/// refresh that failed - the save itself is expected to report its own outcome. +/// +/// Anything that *should* follow later updates - a page heading, say - subscribes to the +/// resource itself, alongside this. +pub fn with_stable_loader( + resource: Computed>, + render: Rc DomNode>, +) -> DomNode { + // Only ever flipped on, so it does not matter how often the closure below runs. + let was_ready = Rc::new(Cell::new(false)); + + let state = resource.map({ + let was_ready = was_ready.clone(); + + move |res| match res { + Resource::Ready(_) => { + was_ready.set(true); + LoadState::Ready + } + Resource::Loading if was_ready.get() => LoadState::Ready, + Resource::Loading => LoadState::Loading, + Resource::Error(_) if was_ready.get() => LoadState::Ready, + Resource::Error(err) => LoadState::Error(err), + } + }); + + state.render_value(move |state| match state { + LoadState::Loading => dom! { }, + LoadState::Error(err) => dom! {
{err}
}, + LoadState::Ready => match transaction(|ctx| resource.get(ctx)) { + Resource::Ready(value) => render(value), + // Unreachable: `state` only *enters* Ready on the change that made the + // resource ready, and a sticky Ready afterwards is not a change, so this + // callback does not run again. + _ => dom! { }, + }, + }) +} + +/// Like [WithLoader], but `render` runs once, when the resource first becomes ready, +/// instead of on every value it takes afterwards. +/// +/// See [with_stable_loader] for the details. +#[component] +pub fn WithStableLoader( + resource: Computed>, + render: Rc DomNode>, +) { + with_stable_loader(resource, render) +} diff --git a/storybook/src/lib.rs b/storybook/src/lib.rs index 9ac63c3..1796ab5 100644 --- a/storybook/src/lib.rs +++ b/storybook/src/lib.rs @@ -16,6 +16,7 @@ mod select_search; mod switch; mod tabs; mod with_loader; +mod with_stable_loader; #[main] fn render() -> DomNode { @@ -36,6 +37,7 @@ fn render() -> DomNode { Login, Spinner, WithLoader, + WithStableLoader, } impl From for TabRoute { @@ -56,6 +58,7 @@ fn render() -> DomNode { "/login" => Self::Login, "/spinner" => Self::Spinner, "/with_loader" => Self::WithLoader, + "/with_stable_loader" => Self::WithStableLoader, _ => Self::Input, } } @@ -79,6 +82,7 @@ fn render() -> DomNode { Self::Login => write!(f, "/login"), Self::Spinner => write!(f, "/spinner"), Self::WithLoader => write!(f, "/with_loader"), + Self::WithStableLoader => write!(f, "/with_stable_loader"), } } } @@ -159,6 +163,11 @@ fn render() -> DomNode { name: "With Loader".to_string(), render: Rc::new(|_| with_loader::with_loader()), }, + Tab { + key: TabRoute::WithStableLoader, + name: "With Stable Loader".to_string(), + render: Rc::new(|_| with_stable_loader::with_stable_loader()), + }, ]; let current_tab = Router::::new_history_router(); diff --git a/storybook/src/with_stable_loader.rs b/storybook/src/with_stable_loader.rs new file mode 100644 index 0000000..3f5a5b6 --- /dev/null +++ b/storybook/src/with_stable_loader.rs @@ -0,0 +1,63 @@ +use vertigo::{DomNode, Resource, Value, bind, bind_rc, css, dom}; +use vertigo_forms::{Input, WithLoader, WithStableLoader}; + +pub fn with_stable_loader() -> DomNode { + let resource = Value::new(Resource::Loading); + + // A field the viewer can type into, so it is visible which of the two loaders keeps + // the state the user is working in and which one throws it away. + let render = bind_rc!(|value: String| { + let draft = Value::default(); + + dom! { +
+

"Resource ready: " {value}

+

"Type here: "

+
+ } + }); + + let set_loading = bind!(resource, |_| resource.set(Resource::Loading)); + let set_ready = bind!(resource, |_| resource + .set(Resource::Ready("Updated value".to_string()))); + let set_error = bind!(resource, |_| resource + .set(Resource::Error("Deliberate error".to_string()))); + + let css_columns = css! {" + display: flex; + gap: 20px; + "}; + + let css_column = css! {" + flex: 1; + border: 1px solid #ccc; + padding: 10px; + "}; + + dom! { +
+

+ + + +

+ +

+ "Both loaders watch the same resource. Click \"Set ready\", type into the field, + then click \"Set ready\" again (or \"Set loading\" / \"Set error\"): the stable + loader keeps what you typed, the plain one rebuilds and loses it." +

+ +
+
+

"WithStableLoader"

+ +
+
+

"WithLoader"

+ +
+
+
+ } +}