Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hot Module/StateReloading (HMR/HSR) #2379

Open
Tehnix opened this issue Feb 28, 2024 · 2 comments
Open

Hot Module/StateReloading (HMR/HSR) #2379

Tehnix opened this issue Feb 28, 2024 · 2 comments

Comments

@Tehnix
Copy link

Tehnix commented Feb 28, 2024

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 #1830, and as suggested in #1830 (comment) 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 Tehnix mentioned this issue Feb 28, 2024
10 tasks
@Tehnix
Copy link
Author

Tehnix commented Feb 28, 2024

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.:

  • Signal value is stored under key that uniquely defines that signal (it sounded like order is relevant)
  • When thawing, look up if that exact key is available and use it
  • If not, then fall back to the default value of the signal

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
Copy link
Author

Tehnix commented Mar 5, 2024

A bit of a hacky POC:

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

  • Creates the actual signal as normal, via create_signal
  • Does some regex hacks to extract some of the underlying Signal info from it's debug representation (id, type, filename, column)
  • Creates a key from this in the format "signal-<id>-<type>-<filename>-<column>"
  • Uses Session Storage via leptos-use
    • Retrieves the value from the key if it exists and immediately sets the signal's value to that
    • Sets up a create_effect that stores the signals value to Session Storage every time it changes
  • Finally, it returns the getter and setter of the create_signal we created in the beginning, to keep the "API" the same

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:

  • signal-2v1-alloc::string::String-src/app.rs-28
  • signal-10v1-alloc::string::String-src/app.rs-38

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:

  • The only way to "clear" the storage/values is to close the window and open and new one
  • Using the column as part of the key is an attempt to minimize the impact of swapping around the order of Signals, which would then result in them restoring the values from the wrong signal (if it's the same type at least)
    • The assumption is that often (although not always), the name of the getter and setter are different lengths
    • Changing the names of a signal will then end up clearing the value, which may be desirable or not

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants