Skip to content

Hot Module/StateReloading (HMR/HSR) #2379

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

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

Hot Module/StateReloading (HMR/HSR) #2379

Tehnix opened this issue Feb 28, 2024 · 10 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.

@Momijiichigo
Copy link

Momijiichigo commented May 12, 2025

Hey, I just wanted to let you know about my project that aims to prove that HMR — the dynamic component replacement on browser without page reloading — is possible, efficient, and high-speed in Rust + WASM.

https://github.com/Momijiichigo/rust-hmr-experiment

Image

  • mod1.wasm will be minimal and imports all library functions at runtime
  • The single module compilation (which should happen in every HMR update) is very fast
    • because linking-with-all-dependencies process is not involved

Current Status

Image

Although I did not go far implementing the tests with Leptos components, I decided to share it now because I am getting busy with graduate school preparation, and concluded I would not be able to make continuous progress on this project.

I hope this Proof-of-Concept will help the development of HMR feature with Rust on browser!

Please feel free to reach me out with questions regards to my implementation of PoC.

@gbj
Copy link
Collaborator

gbj commented May 16, 2025

Hi @Momijiichigo — This looks really cool! I tried to run the demo with cd hmr-server && cargo run and got a bunch of compile errors. Here's some sample output

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
js_glue_file_path: ../target/web-assets/pkg/wasm_project.js
=== Recompile module ===
args: "--edition 2021 --target wasm32-unknown-unknown -C opt-level=0 --crate-type cdylib --emit obj --cfg feature=\"separate-comp\" -L ../target/wasm32-unknown-unknown/debug/deps -L ../target/debug/deps -o ../target/web-assets/wasm/mod1.obj.wasm ../wasm-project/src/mod1.rs --extern wasm_project"
error[E0433]: failed to resolve: use of unresolved module or unlinked crate `wasm_bindgen`
 --> ../wasm-project/src/mod1.rs:2:5
  |
2 | use wasm_bindgen::prelude::*;
  |     ^^^^^^^^^^^^ use of unresolved module or unlinked crate `wasm_bindgen`
  |
  = help: you might be missing a crate named `wasm_bindgen`

I assume I'm doing something wrong. Could you let me know how it's intended to be used? Thanks!

@Momijiichigo
Copy link

Momijiichigo commented May 17, 2025

Hey! I see the problem and just fixed it.

It was the directory path divider / that wouldn't work on Windows 😅:

- target_dir: Some(PathBuf::from("../target")),
+ target_dir: Some(PathBuf::from("..").join("target")),
- if line.contains(".cargo/registry") {
+ if line.contains(".cargo/registry") || line.contains(".cargo\\registry") {

Now it should be able to feed the crate dependencies to compile the single module file.
I hope it would work but please let me know if it still produce errors.

Thank you so much for your attention!

@Momijiichigo
Copy link

Momijiichigo commented May 17, 2025

If it didn't resolve

...Oh but I noticed your system seems to use /, and still fails to extract the dependency names...

FYI, an expected args log is:

=== Recompile module ===
args: "--edition 2024 --target wasm32-unknown-unknown -C opt-level=0 --crate-type cdylib --emit obj --cfg feature=\"separate-comp\" -L ../target/wasm32-unknown-unknown/debug/deps -L ../target/debug/deps -o ../target/web-assets/wasm/mod1.obj.wasm ../wasm-project/src/mod1.rs --extern wasm_project --extern form_urlencoded --extern next_tuple --extern wasm_bindgen_futures --extern toml_datetime --extern regex_automata --extern leptos_dom --extern proc_macro2 --extern toml_edit --extern icu_properties_data --extern or_poisoned --extern quote --extern equivalent --extern utf8_width --extern indexmap --extern linear_map --extern parking_lot --extern registry --extern utf8_iter --extern xxhash_rust --extern proc_macro2_diagnostics --extern libc --extern scopeguard --extern futures_util --extern const_str --extern icu_locid_transform_data --extern tinystr --extern icu_locid_transform --extern memchr --extern toml --extern gloo_net --extern send_wrapper --extern cfg_if --extern serde_wasm_bindgen --extern drain_filter_polyfill --extern camino --extern base64 --extern const_str_slice_concat --extern convert_case --extern writeable --extern unicode_segmentation --extern http --extern itoa --extern regex --extern icu_provider --extern stable_deref_trait --extern crossbeam_utils --extern zerovec --extern num_cpus --extern yoke --extern leptos --extern typed_builder --extern leptos_hot_reload --extern either --extern rstml --extern smallvec --extern gloo_utils --extern self_cell --extern html_escape --extern pathdiff --extern wasm_bindgen --extern bytes --extern hashbrown --extern async_lock --extern reactive_graph --extern idna --extern guardian --extern futures_task --extern utf16_iter --extern url --extern tracing_core --extern same_file --extern itertools --extern futures_sink --extern icu_locid --extern futures_io --extern event_listener_strategy --extern write16 --extern icu_properties --extern yansi --extern regex_syntax --extern futures_executor --extern serde_json --extern const_format --extern throw_error --extern slotmap --extern hydration_context --extern web_sys --extern icu_normalizer --extern icu_collections --extern futures_channel --extern unicode_ident --extern once_cell --extern walkdir --extern anyhow --extern portable_atomic --extern slab --extern pin_utils --extern dashmap --extern concurrent_queue --extern leptos_config --extern rustc_hash --extern event_listener --extern leptos_server --extern zerofrom --extern serde_qs --extern tachys --extern server_fn --extern syn --extern pin_project --extern codee --extern futures --extern wasm_streams --extern any_spawner --extern tracing --extern config --extern aho_corasick --extern lock_api --extern icu_normalizer_data --extern winnow --extern oco_ref --extern idna_adapter --extern futures_core --extern static_cell --extern leptos_reactive --extern thiserror --extern percent_encoding --extern ryu --extern serde_spanned --extern js_sys --extern pin_project_lite --extern erased --extern parking_lot_core --extern reactive_stores --extern either_of --extern litemap --extern serde"

As shown, there should be a lot of --extern arguments in the tail (e.g. --extern wasm_bindgen) that are supposed to clear the missing-crate errors.

If there is no whole set of --extern arguments in the tail, then that means this line is somehow never executed.

It reads the contents of target/wasm32-unknown-unknown/debug/deps/*.d files and extract the crate names.

If my recent fix did not resolve the problem, could you paste one of your .d file content e.g. target/wasm32-unknown-unknown/debug/deps/wasm_bindgen-4b82754459307dc9.d ?

@myypo
Copy link
Contributor

myypo commented May 26, 2025

Haven't seen it mentioned in the issues, so will leave it here for visibility - Dioxius team has been working on hot-reloading: https://crates.io/crates/subsecond

And there has been an effort to integrate it into other projects (Bevy): bevyengine/bevy#19296

@janhohenheim
Copy link

Integrating minimal hotpatching is surprisingly easy, FYI.

  • You call dioxus_devtools::connect_subsecond(); once in your app
  • You call hotpatched functions with HotFn::current(some_function).call((some, parameters, you, care, about));

See https://github.com/TheBevyFlock/bevy_simple_subsecond_system/tree/main?tab=readme-ov-file#first-time-installation for how to set up your environment so that hotpatching works :)

@gbj
Copy link
Collaborator

gbj commented Jun 2, 2025

That sounds great, @janhohenheim. Are you interested in working on a PR?

@janhohenheim
Copy link

Nope, not a Leptos user. I just implemented the Bevy hotpatch prototype :)

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

5 participants