leptos-rs / leptos

Build fast web applications with Rust.
https://leptos.dev
MIT License
16.22k stars 640 forks source link

Hot Module/StateReloading (HMR/HSR) #2379

Open Tehnix opened 8 months ago

Tehnix commented 8 months ago

Is your feature request related to a problem? Please describe. Hot Module/State Reloading is one of the more powerful features in Frontend development that allows developers to quickly iterate on their work, by not needing to redo state (e.g. form wizards, filters, etc) after making a change and wanting to see that change reflected in the UI.

Perseus is the only example I'm aware of from a Rust-based framework that provides this functionality (more info on how here). They also go a bit beyond by recommending cranelift as a way to speed up compilation, to make this more impactful (docs here).

Some examples from JS-land include webpack/rspack, next.js, vite.

Describe the solution you'd like

There are probably many good reasons this would not be feasible, but if we took inspiration from Perseus, than one could imagine a similar approach in Leptos:

  1. All signal/reactivity state is serialized
  2. Upon change/recompile, freeze the serialized state
  3. Reload the newly generated WASM bundle
  4. Deserialize the state and reapply it to all signals

Describe alternatives you've considered

I haven't pondered enough over this to have any alternatives.

Additional context

We started an informal discussion a bit in https://github.com/leptos-rs/leptos/issues/1830, and as suggested in https://github.com/leptos-rs/leptos/issues/1830#issuecomment-1960381922 I've extracted this to it's own issue, to avoid making the Roadmap issue any more noisy than it needs to be :)

I've included @gbj's comment here for context:

Reading through the Perseus docs on this my take-aways are the following:

  1. looks like primarily a DX/development-mode feature, so you can recompile + reload the page without losing state
  2. in Perseus's context, it's tied to the notion of a single blob of reactive state per page, with named fields -- they even emphasize "not using rogue Signals that aren't part of your page state")
  3. as a result for them it lives at the metaframework level, and is one of the benefits from the tradeoff between route-level/page-level state and reactivity -- in exchange for giving up granular state, you get the benefit of hot reloading without losing page state

The big benefit of HMR/state preservation in a JS world comes from the 0ms compile times of JS, which means you can update a page and immediately load the new data. Not so with Rust, so this is mostly "when my app reloads 10 seconds later after recompiling, it restores the same page state."

I have tended toward a more primitive-oriented approach (i.e., building page state up through composing signals and components) rather than the page-level state approach of Perseus, which I think is similar to the NextJS pages directory approach. So this wouldn't work quite as well... i.e., we could save the state of which signals were created in which order, but we don't have an equivalent struct with named fields to serialize/deserialize, so it would likely glitch much more often. (e.g., switching the order of two create_signal calls would break it)

It would certainly be possible to implement at a fairly low level. I'm not sure whether there are real benefits.

Tehnix commented 8 months ago

in Perseus's context, it's tied to the notion of a single blob of reactive state per page, with named fields -- they even emphasize "not using rogue Signals that aren't part of your page state")

I definitely don't know enough about Leptos's internals yet, but I remember from some early explanations/examples that the signals were tracked centrally somehow.

For serialization/deserialization, could it make sense to have something as simple as a hashmap?

Since it's a DX feature, a best-effort approach could be good enough, e.g.:

That of course could also introduce too many "surprises", so might not be a good developer experience in the end 🤔 (and I'm probably massively simplifying how things are implemented 😅 )

The big benefit of HMR/state preservation in a JS world comes from the 0ms compile times of JS, which means you can update a page and immediately load the new data. Not so with Rust, so this is mostly "when my app reloads 10 seconds later after recompiling, it restores the same page state."

Compile times are definitely a point of friction, but even in large JS codebases with older build systems (so, also recompile times of +10 seconds), I've found it quite worthwhile.

It really shines when you're changing logic in components that are part of a deep user-flow, e.g. a modal that was opened with some data filled in, a multi-step form, and various other state that might not be represented in the routes.

Since you mentioned it, I dug a bit further into Perseus and saw that they recommended using Cranelift for development, to help combat the longer compile times. I haven't actually tried Cranelift yet, but will try and experiment with it to see what difference it might make and if it's worth the hassle :)

Tehnix commented 7 months ago

A bit of a hacky POC:

I basically create a macro, tracked_signal, which does the following:

We use Session Storage instead of Local Storage to make tabs have separate states and to clear the state when the window is closed (it retains the state upon refresh).

The macro definition:

/// Create a signal, wrapping `create_signal`, that is tracked in session storage.
///
/// We track the Signal's ID, Type, Filename it's used in, and the column number it's used at. This
/// provides a reasonable heuristic to track the signal across recompiles and changes to the code.
///
/// Line numbers are avoided, as they are expected to change frequently, but column numbers are more
/// stable and help slightly in avoiding restoring the wrong data into a signal if you switch their
/// order around, unless they are used in the same column.
macro_rules! tracked_signal {
    ( $value_type:ty, $value:expr ) => {{
        // Create the signal as normal.
        let (signal_value, signal_setter) = create_signal($value);

        // NOTE: Hacky way to extract the Signal ID, since it's private but is exposed in the
        // debug representation. Example:
        //  ReadSignal { id: NodeId(3v1), ty: PhantomData<alloc::string::String>, defined_at: Location { file: "src/app.rs", line: 26, col: 38 } }
        let signal_repr = format!("{:?}", signal_value);

        // Extract the various pieces of data we need using regex.
        use regex::Regex;

        // Extract the Node ID value.
        let re_id = Regex::new(r"NodeId\((.*?)\)").unwrap();
        let signal_id = re_id
            .captures(&signal_repr)
            .unwrap()
            .get(1)
            .unwrap()
            .as_str()
            .to_string();

        // Extract the type from the PhantomData type.
        let re_type = Regex::new(r"PhantomData<(.*?)>").unwrap();
        let signal_type = re_type
            .captures(&signal_repr)
            .unwrap()
            .get(1)
            .unwrap()
            .as_str()
            .to_string();

        // Extract the filename.
        let re_file = Regex::new(r#"file: "(.*?)""#).unwrap();
        let signal_file = re_file
            .captures(&signal_repr)
            .unwrap()
            .get(1)
            .unwrap()
            .as_str()
            .to_string();

        // Extract the column, but ignore the line number. Line numbers are expected
        // to change frequently, but column numbers are more stable.
        let re_col = Regex::new(r"col: (.*?) ").unwrap();
        let signal_col = re_col
            .captures(&signal_repr)
            .unwrap()
            .get(1)
            .unwrap()
            .as_str()
            .to_string();

        // Construct a unique key from the signal info.
        let localstorage_key = format!("signal-{}-{}-{}-{}", signal_id, signal_type, signal_file, signal_col);

        // Track any changes to this signal, and update our global state.
        use leptos_use::storage::{StorageType, use_storage};
        use leptos_use::utils::JsonCodec;
        let (state, set_state, _) = use_storage::<$value_type, JsonCodec>(StorageType::Session, localstorage_key);
        signal_setter.set(state.get_untracked());

        create_effect(move |_| {
            // Uncomment the logging line to debug the signal value and updates.
            // logging::log!("Signal ({}, {}, {}, {}) = {}", signal_id, signal_type, signal_file, signal_col, signal_value.get());
            set_state.set(signal_value.get())
        });

        // Pass back the Signal getter and setter, as the create_signal would.
        (signal_value, signal_setter)
    }};
}

The majority of the code is my ugly regex hack to extract the internals of the Signal 😅

An example of using it in a Form, which is one of the places that benefit greatly from retaining state (testing with Tauri's default leptos template):

#[component]
pub fn App() -> impl IntoView {
    let (name, set_name) = tracked_signal!(String, String::new());
    let (greet_msg, set_greet_msg) = tracked_signal!(String, String::new());

    let update_name = move |ev| {
        let v = event_target_value(&ev);
        set_name.set(v);
    };

    let greet = move |ev: SubmitEvent| {
        ev.prevent_default();
        spawn_local(async move {
            let name = name.get_untracked();
            if name.is_empty() {
                return;
            }

            let args = to_value(&GreetArgs { name: &name }).unwrap();
            let new_msg = invoke("greet", args).await.as_string().unwrap();
            set_greet_msg.set(new_msg);
        });
    };

    view! {
        <main class="container">
            <form class="row" on:submit=greet>
                <input
                    id="greet-input"
                    placeholder="Enter a name..."
                    value=move || name.get()
                    on:input=update_name
                />
                <button type="submit">"Greet"</button>
            </form>

            <p><b>{ move || greet_msg.get() }</b></p>
        </main>
    }
}

We have our two tracked signals in the beginning of the component:

    let (name, set_name) = tracked_signal!(String, String::new());
    let (greet_msg, set_greet_msg) = tracked_signal!(String, String::new());

which creates two session storage keys:

Each containing the latest values of the signals. They will restore their value on each refresh/reload of the page, e.g. whenever Trunk reloads the page after a recompile.

The compilation loop is quite quick, but even if it was slow, I greatly value having the form inputs retain their values across changes/recompiles.


It's not 100% there yet, some DX snags:

I'm not entirely sure yet on how to make it behave better. I would essentially like to get the variable names (i.e. the let (name, set_name) part of let (name, set_name) = tracked_signal!(String, String::new());), which could help ensure that the signal's semantically is the same or not, without relying on the column.