diff --git a/.changeset/all-masks-dig.md b/.changeset/all-masks-dig.md
new file mode 100644
index 0000000000..ffa40655b9
--- /dev/null
+++ b/.changeset/all-masks-dig.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: ensure proper HMR updates for dynamic components
diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js
index df96f4899b..a3ad988ba1 100644
--- a/packages/svelte/src/internal/client/constants.js
+++ b/packages/svelte/src/internal/client/constants.js
@@ -62,6 +62,8 @@ export const STATE_SYMBOL = Symbol('$state');
export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');
export const PROXY_PATH_SYMBOL = Symbol('proxy path');
+/** An anchor might change, via this symbol on the original anchor we can tell HMR about the updated anchor */
+export const HMR_ANCHOR = Symbol('hmr anchor');
/** allow users to ignore aborted signal errors if `reason.name === 'StaleReactionError` */
export const STALE_REACTION = new (class StaleReactionError extends Error {
diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js
index 13ee35b20d..73dba95f9b 100644
--- a/packages/svelte/src/internal/client/dev/hmr.js
+++ b/packages/svelte/src/internal/client/dev/hmr.js
@@ -1,6 +1,6 @@
/** @import { Effect, TemplateNode } from '#client' */
import { FILENAME, HMR } from '../../../constants.js';
-import { EFFECT_TRANSPARENT } from '#client/constants';
+import { EFFECT_TRANSPARENT, HMR_ANCHOR } from '#client/constants';
import { hydrate_node, hydrating } from '../dom/hydration.js';
import { block, branch, destroy_effect } from '../reactivity/effects.js';
import { set, source } from '../reactivity/sources.js';
@@ -15,10 +15,10 @@ export function hmr(fn) {
const current = source(fn);
/**
- * @param {TemplateNode} anchor
+ * @param {TemplateNode} initial_anchor
* @param {any} props
*/
- function wrapper(anchor, props) {
+ function wrapper(initial_anchor, props) {
let component = {};
let instance = {};
@@ -26,6 +26,7 @@ export function hmr(fn) {
let effect;
let ran = false;
+ let anchor = initial_anchor;
block(() => {
if (component === (component = get(current))) {
@@ -39,6 +40,8 @@ export function hmr(fn) {
}
effect = branch(() => {
+ anchor = /** @type {any} */ (anchor)[HMR_ANCHOR] ?? anchor;
+
// when the component is invalidated, replace it without transitions
if (ran) set_should_intro(false);
diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js
index a8096e0a58..33c34f58bb 100644
--- a/packages/svelte/src/internal/client/dom/blocks/branches.js
+++ b/packages/svelte/src/internal/client/dom/blocks/branches.js
@@ -7,8 +7,10 @@ import {
pause_effect,
resume_effect
} from '../../reactivity/effects.js';
+import { HMR_ANCHOR } from '../../constants.js';
import { hydrate_node, hydrating } from '../hydration.js';
import { create_text, should_defer_append } from '../operations.js';
+import { DEV } from 'esm-env';
/**
* @typedef {{ effect: Effect, fragment: DocumentFragment }} Branch
@@ -91,6 +93,12 @@ export class BranchManager {
this.#onscreen.set(key, offscreen.effect);
this.#offscreen.delete(key);
+ if (DEV) {
+ // Tell hmr.js about the anchor it should use for updates,
+ // since the initial one will be removed
+ /** @type {any} */ (offscreen.fragment.lastChild)[HMR_ANCHOR] = this.anchor;
+ }
+
// remove the anchor...
/** @type {TemplateNode} */ (offscreen.fragment.lastChild).remove();
diff --git a/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte
new file mode 100644
index 0000000000..491b4e06ed
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte
@@ -0,0 +1 @@
+component
diff --git a/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js
new file mode 100644
index 0000000000..8adf57f2eb
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js
@@ -0,0 +1,30 @@
+import { flushSync } from 'svelte';
+import { HMR } from 'svelte/internal/client';
+import { test } from '../../test';
+
+export default test({
+ compileOptions: {
+ dev: true,
+ hmr: true
+ },
+
+ async test({ assert, target }) {
+ const [btn] = target.querySelectorAll('button');
+
+ btn.click();
+ flushSync();
+ assert.htmlEqual(target.innerHTML, ` component`);
+
+ // Simulate HMR swap on the child component.
+ const hidden = './_output/client/Component' + '.svelte.js';
+ const mod = await import(/* vite-ignore */ hidden);
+ const hmr_data = mod.default[HMR];
+ const fake_incoming = {
+ // Fake a new component, else HMR source's equality check will ignore the update
+ [HMR]: { fn: /** @param {any} args */ (...args) => hmr_data.fn(...args), current: null }
+ };
+ hmr_data.update(fake_incoming);
+ flushSync();
+ assert.htmlEqual(target.innerHTML, ` component`);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte
new file mode 100644
index 0000000000..7cb6982953
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte
@@ -0,0 +1,9 @@
+
+
+
+
+