Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
<!-- markdownlint-configure-file { "no-duplicate-heading": { "siblings_only": true } } -->

<!-- markdownlint-disable-next-line first-line-h1 -->
## 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
Expand Down
12 changes: 9 additions & 3 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ name = "vertigo-forms"
version = "0.2.1"
authors = ["Michał Pokrywka <wolfmoon@o2.pl>"]
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"
Expand All @@ -19,7 +19,7 @@ edition = "2024"
vertigo = "0.13"

[dependencies]
base64 = "0.22"
base64 = "0.23"
either = "1.15"
vertigo = { workspace = true }

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```

Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>;
Expand Down
84 changes: 80 additions & 4 deletions src/with_loader.rs
Original file line number Diff line number Diff line change
@@ -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\<T\>](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<T: Clone + PartialEq + 'static>(
resource: Computed<Resource<T>>,
Expand All @@ -18,7 +18,7 @@ pub fn WithLoader<T: Clone + PartialEq + 'static>(
/// Wraps [Resource\<T\>](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<T: Clone + PartialEq + 'static>(
resource: Computed<Resource<T>>,
render: Rc<dyn Fn(T) -> DomNode>,
Expand All @@ -33,3 +33,79 @@ pub fn with_loader<T: Clone + PartialEq + 'static>(
},
})
}

/// Which branch of a [Resource\<T\>](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<T: Clone + PartialEq + 'static>(
resource: Computed<Resource<T>>,
render: Rc<dyn Fn(T) -> 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! { <Spinner /> },
LoadState::Error(err) => dom! { <main>{err}</main> },
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! { <Spinner /> },
},
})
}

/// 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<T: Clone + PartialEq + 'static>(
resource: Computed<Resource<T>>,
render: Rc<dyn Fn(T) -> DomNode>,
) {
with_stable_loader(resource, render)
}
9 changes: 9 additions & 0 deletions storybook/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod select_search;
mod switch;
mod tabs;
mod with_loader;
mod with_stable_loader;

#[main]
fn render() -> DomNode {
Expand All @@ -36,6 +37,7 @@ fn render() -> DomNode {
Login,
Spinner,
WithLoader,
WithStableLoader,
}

impl From<String> for TabRoute {
Expand All @@ -56,6 +58,7 @@ fn render() -> DomNode {
"/login" => Self::Login,
"/spinner" => Self::Spinner,
"/with_loader" => Self::WithLoader,
"/with_stable_loader" => Self::WithStableLoader,
_ => Self::Input,
}
}
Expand All @@ -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"),
}
}
}
Expand Down Expand Up @@ -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::<TabRoute>::new_history_router();
Expand Down
63 changes: 63 additions & 0 deletions storybook/src/with_stable_loader.rs
Original file line number Diff line number Diff line change
@@ -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! {
<div>
<p>"Resource ready: " {value}</p>
<p>"Type here: " <Input value={draft} /></p>
</div>
}
});

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! {
<div>
<p>
<button on_click={set_loading}>"Set loading"</button>
<button on_click={set_ready}>"Set ready"</button>
<button on_click={set_error}>"Set error"</button>
</p>

<p>
"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."
</p>

<div css={css_columns}>
<div css={&css_column}>
<h3>"WithStableLoader"</h3>
<WithStableLoader resource={resource.to_computed()} render={&render} />
</div>
<div css={css_column}>
<h3>"WithLoader"</h3>
<WithLoader resource={resource.to_computed()} {render} />
</div>
</div>
</div>
}
}
Loading