svelte icon indicating copy to clipboard operation
svelte copied to clipboard

Svelte 5: Preserve local state during HMR

Open bhuynhdev opened this issue 1 year ago • 11 comments

Describe the bug

With a brand new SvelteKit project with Svelte 5, I realized that all my states are reset whenever HMR updates

Whenever I change anything in the HTML part of a Svelte file, HMR kicks in and resets all my $state, which makes it very cumbersome to develop a large and complex form

I read that earlier version support directives such as @hmr:keep-all or preserveLocalState, but according to https://github.com/sveltejs/kit/issues/12985, these are outdated and no longer used

So I am unsure what to do now to preserve states between HMR reloads - is this a bug or a feature request?

Reproduction

Reproduction link in SvelteLab: https://www.sveltelab.dev/63iv5zkf3ed2806

Steps to reproduce:

  1. Check the checkbox
  2. Click the button
  3. Change something in the HTML, like adding more text to the paragraph
  4. All states get reset - the checkbox gets unchecked, and text resets to initial value 🚩

Logs

No response

System Info

System:
    OS: Linux 5.15 Ubuntu 22.04.4 LTS 22.04.4 LTS (Jammy Jellyfish)
    CPU: (8) x64 AMD Ryzen 7 8845HS w/ Radeon 780M Graphics
    Memory: 13.97 GB / 14.90 GB
    Container: Yes
    Shell: 5.1.16 - /bin/bash
  Binaries:
    Node: 23.3.0 - ~/.local/share/mise/installs/node/23/bin/node
    npm: 10.9.0 - ~/.local/share/mise/installs/node/23/bin/npm
    pnpm: 9.14.2 - ~/.local/share/mise/installs/node/23/bin/pnpm
  npmPackages:
    svelte: ^5.1.13 => 5.1.13

Severity

annoyance

bhuynhdev avatar Nov 25 '24 23:11 bhuynhdev

Also just ran into this issue with tauri+Svelte 5, would love to have something like the directives or preserveLocalState support

apekros avatar Dec 05 '24 04:12 apekros

you can add this code to the end of your script block to manually preserve the checked state

	if (import.meta.hot) {
		if (import.meta.hot.data.checked != null) {
			checked = import.meta.hot.data.checked;
		}
		import.meta.hot.accept();
		import.meta.hot.dispose(() => {
			import.meta.hot.data.checked = checked;
		});
  }

It may be possible to extend the svelte compiler to parse annotations and create this code for you, however it should always be used as a scalpel.

dominikg avatar Dec 05 '24 10:12 dominikg

Are there any plans to add this to the kit configuration? It's a shame that by replacing svelte-hmr and vite's default HMR, SvelteKit does not support this functionality anymore. It's kind of a step backwards IMO.

And adding code at the end of a script block to keep track of all the client state is not feasible...

ngoov avatar Mar 19 '25 14:03 ngoov

I agree - HMR not preserving state loses much of its usefulness. It is intended to be a productivity tool.

if I can't adjust my HTML and see the output applied to the existing state, it's no better than just reloading a page and starting over.

There is no way in hell I am bolting in a bunch of stuff for every component for what should be provided out of the box.

Saltallica avatar Apr 09 '25 22:04 Saltallica

It's a shame that by replacing svelte-hmr and vite's default HMR, SvelteKit does not support this functionality anymore. It's kind of a step backwards IMO

this was never a default feature of vite hmr, the code I posted above is what you have to do to preserve local state in vite hmr.
svelte-hmr had a custom implementation to automate some of this for you, but it comes with the caveats described in svelte-hmr readme and was disabled by default.

If state reset is getting in the way of your dx, in some cases it can be a sign that the state shouldn't be local to that component but instead held in a different module. If you refactor it and pass it in via prop, the value will be preserved when the component updates.

dominikg avatar Apr 10 '25 13:04 dominikg

Compared to Flutter, it's terrible. The state of the component should not be reset while editing its html code out of the box.

darkstarx avatar May 03 '25 19:05 darkstarx

It's a shame that by replacing svelte-hmr and vite's default HMR, SvelteKit does not support this functionality anymore. It's kind of a step backwards IMO

this was never a default feature of vite hmr, the code I posted above is what you have to do to preserve local state in vite hmr. svelte-hmr had a custom implementation to automate some of this for you, but it comes with the caveats described in svelte-hmr readme and was disabled by default.

If state reset is getting in the way of your dx, in some cases it can be a sign that the state shouldn't be local to that component but instead held in a different module. If you refactor it and pass it in via prop, the value will be preserved when the component updates.

I guess I was comparing it to React's Fast Refresh then, my bad. It does this OOTB for every component change.

ngoov avatar May 05 '25 06:05 ngoov

This is stupid. Svelte 3 and 4 handled that beautifully. It was THE reason go with Svelte. Why it's not in Svelte 5?

FluffyDiscord avatar Jun 08 '25 16:06 FluffyDiscord

This is stupid. Svelte 3 and 4 handled that beautifully. It was THE reason go with Svelte. Why it's not in Svelte 5?

First, please be more polite. Second: hmr that persist state was your only reason to use svelte? 😁

paoloricciuti avatar Jun 08 '25 16:06 paoloricciuti

Second: hmr that persist state was your only reason to use svelte? 😁

Have I stated, that it was the only reason? I am sure I didn't. I know that reading is hard. We all fail, some fail harder apparently.

FluffyDiscord avatar Jun 08 '25 16:06 FluffyDiscord

Glad to see you skipped the being polite part and instead doubled down with very inappropriate comments. Furthermore all cap THE is usually referring to that being the only/main reason.

paoloricciuti avatar Jun 08 '25 17:06 paoloricciuti

I've noticed this issue too, very frustrating to work with on some pages. I need to keep redoing some actions over and over again. Hope something can be done about this in the future.

IcyFoxe avatar Jun 24 '25 19:06 IcyFoxe

I also wish there is some config to preserve state like it did before

harryqt avatar Jun 29 '25 12:06 harryqt

Not preserving HMR state in my opinion gets to be even more confusing/frustrating in certain scenarios, especially considering that state in .svelte.js/ts files also seem to get wiped whenever a hot reload happens, which just isn't the case in normal .js/ts files. I understand why it shouldn't be the default, however I'd love the option to use it when it makes sense.

Mastriel avatar Jul 15 '25 19:07 Mastriel

I'm going to have to agree about preserving HMR, I find it a very useful feature when developing.

Lepidopteran avatar Aug 19 '25 21:08 Lepidopteran

I ran into this on one of my projects, and ended up using babel-plugin-macros to implement my own pseudo-runes $hmrKeep(someVar); and $hmrKeepAll(); that expand to the import.meta.hot boilerplate from https://github.com/sveltejs/svelte/issues/14434#issuecomment-2519829260. I can try to extract that and release it if that would be helpful.

Full disclosure: a) I did get some help from ChatGPT on this, in case that matters to you. I have actually worked on and tested it, however, and can confirm that it's working properly for me. b) I'm using pirates to patch support for the $lib/whatever.js syntax into babel-plugin-macros, since it's importing the macro at build time and doesn't use Sveltekit's resolver stuff. It's a bit of a hack.

Rhys-T avatar Aug 20 '25 15:08 Rhys-T

Wherever it is necessary to preserve inter-module interaction through an external variable when HMR is triggered, you have to write a boilerplate for each stored variable.

store.svelte.ts

let _intermodular = $state({ count: 1 });

if (import.meta.hot) {
	if (import.meta.hot.data._intermodular) {
		_intermodular = import.meta.hot.data._intermodular;
	}

	import.meta.hot.on('vite:beforeUpdate', () => {
		import.meta.hot!.data._intermodular = _intermodular;
	});
}

export const intermodular = _intermodular;

Although Svelte can and should do this under the hood.

tadmi avatar Sep 09 '25 06:09 tadmi

This was very annoying for me but I published a workaround here: https://www.npmjs.com/package/svelte-preprocess-preserve-hot-reload-state. I found that with SvelteKit at least you can write a preprocessor that will append the relevant code using import.meta.hot to each component (you can probably do it as well without SvelteKit through whatever other build system). If this workaround is helpful for you please try it out and let me know how it works!

jack-arms avatar Oct 15 '25 08:10 jack-arms

So I've found that a limitation of updating state manually like this is that if you have multiple instances of a component, it will end up syncing state across all of them. Wondering if anyone has found a workaround to that, whether the import.meta.hot block is added manually or not?

I also want to echo that this is major frustration to using Svelte 5, and as this issue becomes more obvious the more complex my app gets I'm considering switching back to React for no other reason unfortunately

jack-arms avatar Oct 19 '25 22:10 jack-arms

I've also encountered this problem now and it's unfortunately quite annoying and makes development difficult, quite a big deterioration of DX :(

@jack-arms Thanks for the inspiration, I was thinking about solving the problem with multiple instances. I took the liberty of modifying the code a bit to work with TypeScript. And if the components are loaded in the same order during HMR, then there is a solution for preserving their state across instances.

It would be even better if the state was preserved even if I changed the parent component. But for now I'll settle for this solution:

const sveltePreserveHmrState: () => PreprocessorGroup = () => {
    return {
        name: "preserve-hmr-state",
        script: ({ content, filename }) => {
            if (filename == null || !filename.endsWith(".svelte")) {
                return;
            }

            const states = [
                ...content.matchAll(
                    /\b(?:let)\s+(\w+)(?:\s*:\s*[^=]+)?\s*=\s*\$state/g
                )
            ];

            if (states.length === 0) {
                return;
            }

            const hmrScript = `
                if (import.meta.hot) {
                    import.meta.hot.accept();
            
                    import.meta.hot.data.instanceCounter =
                        import.meta.hot.data.instanceCounter || 0;
            
                    // On mount – each mount receives an index based on its order
                    const instanceId = import.meta.hot.data.instanceCounter++;
            
                    import.meta.hot.data.states = import.meta.hot.data.states || {};
                    import.meta.hot.data.states[instanceId] =
                        import.meta.hot.data.states[instanceId] || {};
            
                    // Restore state
                    if (import.meta.hot.data.states[instanceId] !== undefined) {
                        ${states
                            .map(
                                (match) => `
                                    if (
                                        typeof import.meta.hot.data.states[instanceId].${match[1]} !==
                                        "undefined"
                                    ) {
                                        ${match[1]} = import.meta.hot.data.states[instanceId].${match[1]};
                                    }
                                `
                            )
                            .join("\n")}
                    } else {
                        ${states
                            .map(
                                (match) => `
                                    import.meta.hot.data.states[instanceId].${match[1]} = () => ${match[1]};
                                `
                            )
                            .join("\n")}
                    }
            
                    // On dispose, save state
                    import.meta.hot.dispose(() => {
                        ${states
                            .map(
                                (match) => `
                                    import.meta.hot.data.states[instanceId].${match[1]} = ${match[1]};
                                `
                            )
                            .join("\n")}

                        // Reset counter for next reload
                        import.meta.hot.data.instanceCounter = 0;
                    });
            
                    $effect(() => {
                        ${states
                            .map(
                                (match) => `
                                    import.meta.hot.data.states[instanceId].${match[1]} = ${match[1]};
                                `
                            )
                            .join("\n")}
                    });
                }
            `;

            const result = new MagicString(content);
            result.append(hmrScript);

            return {
                code: result.toString(),
                map: result.generateMap({ source: filename })
            };
        }
    };
};

pepa-linha avatar Nov 23 '25 17:11 pepa-linha