feat: preserve $state across HMR component swaps#17995
feat: preserve $state across HMR component swaps#17995alexkahndev wants to merge 2 commits intosveltejs:mainfrom
Conversation
🦋 Changeset detectedLatest commit: 15c05d0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
When a component is hot-replaced via $.hmr(), labeled $state signal values are now captured from the old effect tree and restored during the new component's initialization. How it works: 1. hmr.js: Before destroy_effect(), collect_state() walks the effect tree and its deps recursively (including derived deps from composables) to find all signals with a .label property. 2. hmr.js: Sets globalThis.__hmr_preserved_state__ with captured values before the branch() that creates the new component. 3. tracing.js: tag() checks __hmr_preserved_state__ and restores the signal's .v before template effects render the DOM. 4. hmr.js: Clears the global after branch() completes. This preserves reactive state across HMR updates — matching React Fast Refresh (useState) and Vue's rerender() (ref) behavior. Test: hmr-preserve-state — clicks counter to 3, triggers HMR swap, asserts count stays at 3. Fixes sveltejs#14434
4d0c82f to
8d285d5
Compare
|
Thank you! This is an intriguing idea. Unfortunately it's quite buggy — it doesn't handle collisions. Consider a case like this: <script>
import Counter from './Counter.svelte';
let count = $state(1);
</script>
<button onclick={() => count++}>+</button>
{#each Array(count) as _}
<Counter />
{/each}<script>
let count = $state(0);
</script>
<button onclick={() => count += 1}>
clicks: {count}
</button>If I make an innocuous change to the first component, it will try to reinitialize with a random value of Even in less complex cases we observe buggy behaviour. Take the counter by itself, and change the initial declaration: -let count = $state(0);
+let count = $state(10);In this case I would expect the value to be And then there are cases where a source doesn't have a tag at all, so you end up with a mixture of restored and non-restored state. It seems likely that you could end up in a state where that combination results in bugs. So while I'd love for state restoration to exist, there's a reason it doesn't. I don't know if these issues are solvable or not. |
… preservation - Scope state collection to component boundaries using __hmr markers on block effects, preventing parent/child label collisions - Store child component state in a separate registry (keyed by wrapper function, FIFO per instance) so children preserve state when their parent HMR's - Track initial values on sources and compare with deep equality before restoring, so changing $state(0) to $state(10) respects the new value - Add Value.initial to type definitions Tests added for collision prevention, initial value changes, object state deep equality, keyed each ordering, and composable state.
|
Thanks for the detailed examples, I appreciate you walking through those cases. I was only testing with a simple standalone counter so I completely missed the parent/child interaction and initial value scenarios. I've pushed fixes for all three issues: Collisions - Initial value changes - Untagged sources - I looked into this one pretty thoroughly and the scoping fix reduces the blast radius significantly. For composable state in .svelte.js files, Tests added for all of the above plus keyed each blocks and object state with deep equality. If there's anything else that's off please let me know so I can try and fix it, I don't usually work in Svelte (but i want to support it in my meta-framework) so I'm sure there are edge cases and deeper use cases I'm not seeing. |
Summary
Preserves
$statesignal values across HMR component swaps. When a component is hot-replaced via$.hmr(), labeled signal values are captured from the old effect tree and restored during the new component's initialization — matching the state preservation behavior of React Fast Refresh (useState) and Vue'srerender()(ref).Fixes #14434
How it works
hmr.js— Beforedestroy_effect(),collect_state()walks the effect tree and its deps recursively (including derived deps from composables) to find all signals with a.labelproperty set by$.tag()in dev mode. Saves their.vvalues to a Map.hmr.js— SetsglobalThis.__hmr_preserved_state__with the captured values before thebranch()that creates the new component.tracing.js—tag()checks__hmr_preserved_state__and restores the signal's.vbefore template effects render the DOM with the default value.hmr.js— Clears the global afterbranch()completes, so it only affects HMR swaps.What it preserves
$statein.sveltecomponents$stateinside composables (.svelte.tsmodules via$.proxy) — found through recursive derived dep walkingWhat it does NOT change
$derivedvalues re-derive naturally from the restored$state$effectcleanup functions still run normally duringdestroy_effect()Test plan
hmr-preserve-statetest: mounts a counter component, clicks 3 times (count=3), triggers HMR swap viawrapper[HMR].update(), asserts count stays at 3hmr-removal,hmr-each-keyed-unshift,component-transition-hmr,css-props-hmr)pnpm check(typecheck): passespnpm lint: passespnpm build: passesReal-world validation
Tested in AbsoluteJS (multi-framework meta-framework) with 30 rapid HMR edits: