From 7be1a0247f1ea84db2e951ab27716c68de5b0650 Mon Sep 17 00:00:00 2001
From: Simon H <5968653+dummdidumm@users.noreply.github.com>
Date: Wed, 8 Apr 2026 22:49:04 +0200
Subject: [PATCH] fix: ensure proper HMR updates for dynamic components
(#18079)
The BranchManager in `branches.js` does create a temporary anchor when
creating a new branch offscreen, and deletes it once the branch is
committed. Normally this is fine, but the combination of HMR and dynamic
components leads to a bug: Since `svelte-component.js` passes the
temporary anchor along to the component it generates, which is the HMR
wrapper, this wrapper will have an obsolete, disconnected anchor on
updates, leading to the content disappearing.
The fix is to add a dev-only symbol which we set on the original (then
obsolete) anchor to tell about the updated anchor that should be used
for HMR updates.
Fixes https://github.com/sveltejs/kit/issues/14699 Fixes #17211
---
.changeset/all-masks-dig.md | 5 ++++
.../svelte/src/internal/client/constants.js | 2 ++
.../svelte/src/internal/client/dev/hmr.js | 9 ++++--
.../internal/client/dom/blocks/branches.js | 8 +++++
.../hmr-dynamic-component/Component.svelte | 1 +
.../samples/hmr-dynamic-component/_config.js | 30 +++++++++++++++++++
.../samples/hmr-dynamic-component/main.svelte | 9 ++++++
7 files changed, 61 insertions(+), 3 deletions(-)
create mode 100644 .changeset/all-masks-dig.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/Component.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/hmr-dynamic-component/main.svelte
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 @@
+
+
+
+
+