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 @@ + + + + +