svelte
svelte copied to clipboard
Svelte 5: Preserve local state during HMR
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:
- Check the checkbox
- Click the button
- Change something in the HTML, like adding more text to the paragraph
- All states get reset - the checkbox gets unchecked, and
textresets 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
Also just ran into this issue with tauri+Svelte 5, would love to have something like the directives or preserveLocalState support
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.
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...
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.
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.
Compared to Flutter, it's terrible. The state of the component should not be reset while editing its html code out of the box.
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-hmrhad 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.
This is stupid. Svelte 3 and 4 handled that beautifully. It was THE reason go with Svelte. Why it's not in Svelte 5?
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? 😁
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.
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.
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.
I also wish there is some config to preserve state like it did before
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.
I'm going to have to agree about preserving HMR, I find it a very useful feature when developing.
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.
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.
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!
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
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 })
};
}
};
};