Skip to content

feat: preserve $state across HMR component swaps#17995

Open
alexkahndev wants to merge 2 commits intosveltejs:mainfrom
alexkahndev:feat/hmr-preserve-state
Open

feat: preserve $state across HMR component swaps#17995
alexkahndev wants to merge 2 commits intosveltejs:mainfrom
alexkahndev:feat/hmr-preserve-state

Conversation

@alexkahndev
Copy link
Copy Markdown

@alexkahndev alexkahndev commented Mar 23, 2026

Summary

Preserves $state signal 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's rerender() (ref).

Fixes #14434

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 set by $.tag() in dev mode. Saves their .v values to a Map.

  2. hmr.js — Sets globalThis.__hmr_preserved_state__ with the captured values before the branch() that creates the new component.

  3. tracing.jstag() checks __hmr_preserved_state__ and restores the signal's .v before template effects render the DOM with the default value.

  4. hmr.js — Clears the global after branch() completes, so it only affects HMR swaps.

What it preserves

  • Direct $state in .svelte components
  • $state inside composables (.svelte.ts modules via $.proxy) — found through recursive derived dep walking

What it does NOT change

  • Non-HMR behavior is unaffected (the global is only set during HMR swaps)
  • $derived values re-derive naturally from the restored $state
  • $effect cleanup functions still run normally during destroy_effect()

Test plan

  • Added hmr-preserve-state test: mounts a counter component, clicks 3 times (count=3), triggers HMR swap via wrapper[HMR].update(), asserts count stays at 3
  • All existing HMR tests pass (hmr-removal, hmr-each-keyed-unshift, component-transition-hmr, css-props-hmr)
  • Full test suite: 7423 passed, 0 failed, 51 skipped
  • pnpm check (typecheck): passes
  • pnpm lint: passes
  • pnpm build: passes

Real-world validation

Tested in AbsoluteJS (multi-framework meta-framework) with 30 rapid HMR edits:

  • Counter state preserved across all 30 edits
  • P50: 22ms, P95: 29ms, 100% under 50ms
  • Works for both page component edits and child component edits

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 23, 2026

🦋 Changeset detected

Latest commit: 15c05d0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
svelte Patch

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
@alexkahndev alexkahndev force-pushed the feat/hmr-preserve-state branch from 4d0c82f to 8d285d5 Compare March 23, 2026 23:15
@Rich-Harris
Copy link
Copy Markdown
Member

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 count from one of its children, likely resulting in those children being destroyed. This is much worse than the current behaviour in which things simply reset to a known good starting point.

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 10, but instead it will be whatever the previous value happened to be. In all likelihood, I will just have to reload the page.

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.
@alexkahndev
Copy link
Copy Markdown
Author

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 - collect_state() now scopes to the current component by marking HMR block effects as boundaries. When it hits a child component's boundary, it collects that child's state into a separate registry keyed by wrapper function (FIFO per instance), so children still preserve their own state when a parent HMR's, but labels never collide across components. Your {#each Array(count)} + Counter example works correctly now, editing the parent preserves count = 2 without picking up a child's count.

Initial value changes - tag() now records source.initial before restoration and collect_state() saves both {value, initial}. On the next HMR cycle, we deep-compare the old initial with the new one. If you change $state(0) to $state(10), the comparison fails and the new value of 10 is used instead of restoring the old runtime value.

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, $.tag() is already generated in dev mode when the binding is reassigned, so composable state is collected and restored through the dep chain without any compiler changes needed. Added a test confirming this works.

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.

@svelte-docs-bot
Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Svelte 5: Preserve local state during HMR

3 participants