Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hmr-preserve-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

Preserve $state values across HMR component swaps. When a component is hot-replaced, labeled signal values are captured from the old effect tree and restored during the new component's initialization via $.tag(). This matches the state preservation behavior of React Fast Refresh and Vue's rerender().
164 changes: 150 additions & 14 deletions packages/svelte/src/internal/client/dev/hmr.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,104 @@ import { set, source } from '../reactivity/sources.js';
import { set_should_intro } from '../render.js';
import { active_effect, get } from '../runtime.js';

/**
* Registry for child component state during parent HMR cycles.
* When a parent HMR's, its children are destroyed and recreated. This
* registry stores each child's state keyed by wrapper function, with
* an array of state maps (one per instance in render order, FIFO).
* @type {Map<Function, Array<Map<string, {value: any, initial: any}>>>}
*/
var child_state_registry = new Map();

/**
* Collect all labeled $state signals from an effect tree, scoped
* to the current component only. At child HMR boundaries, child
* state is collected separately and stored in child_state_registry
* so that when the child is recreated, its HMR wrapper can restore it.
*
* Labels are set by $.tag() in dev mode — each $state variable gets
* a label matching its source-level name (e.g., 'count', 'name').
* Also recurses into derived deps to find signals inside composables.
* @param {Effect} effect
* @returns {Map<string, {value: any, initial: any}>} label → {current value, initial value}
*/
function collect_state(effect) {
/** @type {Map<string, {value: any, initial: any}>} */
var state = new Map();
/** @type {Set<any>} */
var visited = new Set();

/**
* @param {any} dep
* @param {Map<string, {value: any, initial: any}>} into
*/
function collect_from_dep(dep, into) {
if (!dep || typeof dep !== 'object' || visited.has(dep)) return;
visited.add(dep);

if ('label' in dep && dep.label && 'v' in dep) {
into.set(/** @type {string} */ (dep.label), {
value: dep.v,
initial: dep.initial
});
}

// Recurse into derived/reaction deps to find nested signals
if ('deps' in dep && Array.isArray(dep.deps)) {
for (var i = 0; i < dep.deps.length; i++) {
collect_from_dep(dep.deps[i], into);
}
}
}

/**
* @param {Effect | null} e
* @param {Map<string, {value: any, initial: any}>} into
*/
function walk(e, into) {
if (!e) return;

// At child HMR boundaries, collect child state separately and
// store in the registry keyed by the child's wrapper function.
// This prevents label collisions between parent/child while
// still preserving child state across parent HMR cycles.
if (/** @type {any} */ (e).__hmr) {
var child_key = /** @type {any} */ (e).__hmr_key;
if (child_key) {
/** @type {Map<string, {value: any, initial: any}>} */
var child_state = new Map();
var child_inner = e.first;
while (child_inner) {
walk(child_inner, child_state);
child_inner = child_inner.next;
}
if (child_state.size > 0) {
if (!child_state_registry.has(child_key)) {
child_state_registry.set(child_key, []);
}
/** @type {Array<Map<string, {value: any, initial: any}>>} */ (child_state_registry.get(child_key)).push(child_state);
}
}
return;
}

if (e.deps) {
for (var i = 0; i < e.deps.length; i++) {
collect_from_dep(e.deps[i], into);
}
}

var child = e.first;
while (child) {
walk(child, into);
child = child.next;
}
}

walk(effect, state);
return state;
}

/**
* @template {(anchor: Comment, props: any) => any} Component
* @param {Component} fn
Expand All @@ -28,31 +126,69 @@ export function hmr(fn) {
let ran = false;

block(() => {
// Mark this block as an HMR boundary so collect_state() on
// parent components collects child state separately (preventing
// label collisions) and stores it in child_state_registry.
/** @type {any} */ (active_effect).__hmr = true;
/** @type {any} */ (active_effect).__hmr_key = wrapper;

if (component === (component = get(current))) {
return;
}

/** @type {Map<string, {value: any, initial: any}>} */
var preserved_state = new Map();

if (effect) {
// Capture labeled $state signal values before destroying
// the old component tree. These will be restored via
// $.tag() during the new component's initialization.
preserved_state = collect_state(effect);

// @ts-ignore
for (var k in instance) delete instance[k];
destroy_effect(effect);
} else {
// First initialization — check if a parent component HMR'd
// and saved our state in the child registry before destroying us.
var saved = child_state_registry.get(wrapper);
if (saved && saved.length > 0) {
preserved_state = /** @type {Map<string, {value: any, initial: any}>} */ (saved.shift());
if (saved.length === 0) {
child_state_registry.delete(wrapper);
}
}
}

effect = branch(() => {
// when the component is invalidated, replace it without transitions
if (ran) set_should_intro(false);

// preserve getters/setters
var result =
// @ts-expect-error
new.target ? new component(anchor, props) : component(anchor, props);
// a component is not guaranteed to return something and we can't invoke getOwnPropertyDescriptors on undefined
if (result) {
Object.defineProperties(instance, Object.getOwnPropertyDescriptors(result));
}
// Make preserved state available to $.tag() during the
// new component's initialization — before template effects
// render the DOM with default values. Use try/finally to
// guarantee cleanup even if the component throws.
if (preserved_state.size > 0) {
/** @type {any} */ (globalThis).__hmr_preserved_state__ = preserved_state;
}

if (ran) set_should_intro(true);
});
try {
effect = branch(() => {
// when the component is invalidated, replace it without transitions
if (ran) set_should_intro(false);

// preserve getters/setters
var result =
// @ts-expect-error
new.target ? new component(anchor, props) : component(anchor, props);
// a component is not guaranteed to return something and we can't invoke getOwnPropertyDescriptors on undefined
if (result) {
Object.defineProperties(instance, Object.getOwnPropertyDescriptors(result));
}

if (ran) set_should_intro(true);
});
} finally {
// Always clear preserved state — prevents leakage to
// subsequent HMR operations if branch() throws
/** @type {any} */ (globalThis).__hmr_preserved_state__ = undefined;
}

// Forward the nodes from the inner effect to the outer active effect which would
// get them if the HMR wrapper wasn't there. Do this inside the block not outside
Expand Down
62 changes: 62 additions & 0 deletions packages/svelte/src/internal/client/dev/tracing.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,74 @@ export function trace(label, fn) {
}
}

/**
* Recursively compares two values for deep equality.
* Used to detect when a $state initial value has changed in source code
* (e.g., `$state(0)` → `$state(10)`), so we know not to restore the
* old runtime value over the developer's intentional code change.
* @param {unknown} a
* @param {unknown} b
* @returns {boolean}
*/
function deep_equal(a, b) {
if (a === b) return true;

if (typeof a === 'number' && typeof b === 'number' && isNaN(a) && isNaN(b)) return true;

if (a == null || b == null || typeof a !== typeof b) return false;

if (typeof a === 'object') {
if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length) return false;
for (var i = 0; i < a.length; i++) {
if (!deep_equal(a[i], b[i])) return false;
}
return true;
}

if (Array.isArray(b)) return false;

var keys_a = Object.keys(/** @type {object} */ (a));
var keys_b = Object.keys(/** @type {object} */ (b));
if (keys_a.length !== keys_b.length) return false;
for (var i = 0; i < keys_a.length; i++) {
var key = keys_a[i];
if (!(key in /** @type {object} */ (b)) || !deep_equal(/** @type {any} */ (a)[key], /** @type {any} */ (b)[key])) return false;
}
return true;
}

return false;
}

/**
* @param {Value} source
* @param {string} label
*/
export function tag(source, label) {
source.label = label;

// Record the initial value from source code BEFORE any restoration,
// so collect_state() can later save it alongside the runtime value.
// This lets the next HMR cycle detect when the developer changed
// the initial value (e.g., $state(0) → $state(10)).
source.initial = source.v;

// HMR state preservation: if $.hmr() captured state from the
// previous component, restore the signal value here — before
// template effects render the DOM with the default value.
// Only restore if the initial value hasn't changed in source code.
/** @type {Map<string, {value: any, initial: any}> | undefined} */
var preserved = /** @type {any} */ (globalThis).__hmr_preserved_state__;
if (preserved !== undefined && preserved.has(label)) {
var entry = preserved.get(label);
// If the developer changed the initial value, respect their
// code change instead of restoring the old runtime value
if (entry && deep_equal(entry.initial, source.v)) {
source.v = entry.value;
}
}

tag_proxy(source.v, label);

return source;
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/internal/client/reactivity/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface Value<V = unknown> extends Signal {
// dev-only
/** A label (e.g. the `foo` in `let foo = $state(...)`) used for `$inspect.trace()` */
label?: string;
/** The initial value from source code, used for HMR state preservation to detect when the developer changed the initial value */
initial?: V;
/** An error with a stack trace showing when the source was created */
created?: Error | null;
/** An map of errors with stack traces showing when the source was updated, keyed by the stack trace */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
let count = $state(0);
</script>

<button class="child" onclick={() => count += 1}>
child: {count}
</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { flushSync } from 'svelte';
import { HMR } from 'svelte/internal/client';
import { test } from '../../test';

export default test({
compileOptions: {
dev: true,
hmr: true
},

html: `<button class="parent">parent: 1</button><button class="child">child: 0</button>`,

test({ assert, target, mod }) {
const parent_btn = target.querySelector('button.parent');
const child_btn = target.querySelector('button.child');

// Click parent to increase count to 2 (adds another Counter)
flushSync(() => parent_btn?.click());
assert.htmlEqual(
target.innerHTML,
`<button class="parent">parent: 2</button><button class="child">child: 0</button><button class="child">child: 0</button>`
);

// Click the first child counter 5 times
for (let i = 0; i < 5; i++) {
flushSync(() => target.querySelector('button.child')?.click());
}

assert.htmlEqual(
target.innerHTML,
`<button class="parent">parent: 2</button><button class="child">child: 5</button><button class="child">child: 0</button>`
);

// Simulate HMR swap on the parent component.
// Without collision prevention, the parent's `count` could be
// overwritten by a child's `count` value, destroying children.
const hmr_data = mod.default[HMR];
if (hmr_data && hmr_data.update) {
const fake_incoming = /** @type {any} */ ({ [HMR]: { fn: hmr_data.fn, current: null } });
hmr_data.update(fake_incoming);
flushSync();
}

// Parent count should still be 2, not corrupted by child's count
// Children should still be rendered (2 of them)
assert.htmlEqual(
target.innerHTML,
`<button class="parent">parent: 2</button><button class="child">child: 5</button><button class="child">child: 0</button>`
);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script>
import Counter from './Counter.svelte';
let count = $state(1);
</script>

<button class="parent" onclick={() => count++}>parent: {count}</button>

{#each Array(count) as _}
<Counter />
{/each}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { flushSync } from 'svelte';
import { HMR } from 'svelte/internal/client';
import { test } from '../../test';

export default test({
compileOptions: {
dev: true,
hmr: true
},

html: `<button>count: 0</button>`,

test({ assert, target, mod }) {
const button = target.querySelector('button');

// Click 3 times
flushSync(() => button?.click());
flushSync(() => button?.click());
flushSync(() => button?.click());
assert.htmlEqual(target.innerHTML, `<button>count: 3</button>`);

// HMR swap the parent component — composable state should be preserved
const hmr_data = mod.default[HMR];
const fake_incoming = /** @type {any} */ ({ [HMR]: { fn: hmr_data.fn, current: null } });
hmr_data.update(fake_incoming);
flushSync();

// Composable's $state should be preserved across parent HMR
assert.htmlEqual(target.innerHTML, `<button>count: 3</button>`);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function createCounter() {
let count = $state(0);
return {
get count() { return count; },
increment() { count++; }
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
import { createCounter } from './counter.svelte.js';
const counter = createCounter();
</script>

<button onclick={() => counter.increment()}>count: {counter.count}</button>
Loading