From 7dcbf7932d4eed9cac9e8eae91ad04acd588a140 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 19 Dec 2025 21:12:06 -0500 Subject: [PATCH 1/6] fix: prevent infinite loop when HMRing a component with an `await` --- .changeset/giant-gifts-mate.md | 5 ++++ .../3-transform/client/transform-client.js | 10 ++----- .../svelte/src/internal/client/dev/hmr.js | 29 +++++++++---------- playgrounds/sandbox/run.js | 1 + 4 files changed, 22 insertions(+), 23 deletions(-) create mode 100644 .changeset/giant-gifts-mate.md diff --git a/.changeset/giant-gifts-mate.md b/.changeset/giant-gifts-mate.md new file mode 100644 index 000000000000..dc2486b69816 --- /dev/null +++ b/.changeset/giant-gifts-mate.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: prevent infinite loop when HMRing a component with an `await` diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index f51042eb7c62..d16b910f714d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -519,14 +519,9 @@ export function client_component(analysis, options) { if (options.hmr) { const id = b.id(analysis.name); - const HMR = b.id('$.HMR'); - - const existing = b.member(id, HMR, true); - const incoming = b.member(b.id('module.default'), HMR, true); const accept_fn_body = [ - b.stmt(b.assignment('=', b.member(incoming, 'source'), b.member(existing, 'source'))), - b.stmt(b.call('$.set', b.member(existing, 'source'), b.member(incoming, 'original'))) + b.stmt(b.call(b.member(id, b.id('$.HMR'), true), b.id('module.default'))) ]; if (analysis.css.hash) { @@ -535,8 +530,7 @@ export function client_component(analysis, options) { } const hmr = b.block([ - b.stmt(b.assignment('=', id, b.call('$.hmr', id, b.thunk(b.member(existing, 'source'))))), - + b.stmt(b.assignment('=', id, b.call('$.hmr', id))), b.stmt(b.call('import.meta.hot.accept', b.arrow([b.id('module')], b.block(accept_fn_body)))) ]); diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js index 709a1b272220..c44d929bb2e9 100644 --- a/packages/svelte/src/internal/client/dev/hmr.js +++ b/packages/svelte/src/internal/client/dev/hmr.js @@ -1,18 +1,20 @@ -/** @import { Source, Effect, TemplateNode } from '#client' */ +/** @import { Effect, TemplateNode } from '#client' */ import { FILENAME, HMR } from '../../../constants.js'; import { EFFECT_TRANSPARENT } from '#client/constants'; import { hydrate_node, hydrating } from '../dom/hydration.js'; import { block, branch, destroy_effect } from '../reactivity/effects.js'; -import { source } from '../reactivity/sources.js'; +import { source, update } from '../reactivity/sources.js'; import { set_should_intro } from '../render.js'; import { get } from '../runtime.js'; /** * @template {(anchor: Comment, props: any) => any} Component - * @param {Component} original - * @param {() => Source} get_source + * @param {Component} component */ -export function hmr(original, get_source) { +export function hmr(component) { + let v = -1; + let s = source(0); + /** * @param {TemplateNode} anchor * @param {any} props @@ -26,8 +28,9 @@ export function hmr(original, get_source) { let ran = false; block(() => { - const source = get_source(); - const component = get(source); + if (v === (v = get(s))) { + return; + } if (effect) { // @ts-ignore @@ -62,16 +65,12 @@ export function hmr(original, get_source) { } // @ts-expect-error - wrapper[FILENAME] = original[FILENAME]; + wrapper[FILENAME] = component[FILENAME]; // @ts-ignore - wrapper[HMR] = { - // When we accept an update, we set the original source to the new component - original, - // The `get_source` parameter reads `wrapper[HMR].source`, but in the `accept` - // function we always replace it with `previous[HMR].source`, which in practice - // means we only ever update the original - source: source(original) + wrapper[HMR] = (c) => { + component = c; + update(s); }; return wrapper; diff --git a/playgrounds/sandbox/run.js b/playgrounds/sandbox/run.js index 35bffb67a22d..f79243d4e7de 100644 --- a/playgrounds/sandbox/run.js +++ b/playgrounds/sandbox/run.js @@ -95,6 +95,7 @@ for (const generate of /** @type {const} */ (['client', 'server'])) { if (generate === 'server' || FROM_HTML) { from_html = compile(source, { dev: DEV, + hmr: DEV, filename: input, generate, runes: argv.values.runes, From 8fc4f25839fb116e11549035bd973ea1b6e20a0b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 19 Dec 2025 21:26:02 -0500 Subject: [PATCH 2/6] update test --- .../snapshot/samples/hmr/_expected/client/index.svelte.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js index 1fac1338c5f9..5878c51aaed5 100644 --- a/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js @@ -11,11 +11,10 @@ function Hmr($$anchor) { } if (import.meta.hot) { - Hmr = $.hmr(Hmr, () => Hmr[$.HMR].source); + Hmr = $.hmr(Hmr); import.meta.hot.accept((module) => { - module.default[$.HMR].source = Hmr[$.HMR].source; - $.set(Hmr[$.HMR].source, module.default[$.HMR].original); + Hmr[$.HMR](module.default); }); } From 0625ec8ffaedc794e3db7c282f3e2b5eb4f51207 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 19 Dec 2025 21:32:54 -0500 Subject: [PATCH 3/6] fix --- packages/svelte/src/internal/client/dev/hmr.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js index c44d929bb2e9..2303336ef664 100644 --- a/packages/svelte/src/internal/client/dev/hmr.js +++ b/packages/svelte/src/internal/client/dev/hmr.js @@ -12,7 +12,6 @@ import { get } from '../runtime.js'; * @param {Component} component */ export function hmr(component) { - let v = -1; let s = source(0); /** @@ -20,6 +19,7 @@ export function hmr(component) { * @param {any} props */ function wrapper(anchor, props) { + let v = -1; let instance = {}; /** @type {Effect} */ From 60a66db0f573312d8cdb7a10914587a66c33bd15 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 22 Dec 2025 00:19:42 +0100 Subject: [PATCH 4/6] reintroduce old logic to fix ever-growing stack of block effects --- .../3-transform/client/transform-client.js | 2 +- .../svelte/src/internal/client/dev/hmr.js | 35 +++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index d16b910f714d..5c00aa591f92 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -521,7 +521,7 @@ export function client_component(analysis, options) { const id = b.id(analysis.name); const accept_fn_body = [ - b.stmt(b.call(b.member(id, b.id('$.HMR'), true), b.id('module.default'))) + b.stmt(b.call(b.member(b.member(id, b.id('$.HMR'), true), 'update'), b.id('module.default'))) ]; if (analysis.css.hash) { diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js index 2303336ef664..b07ec02be4b1 100644 --- a/packages/svelte/src/internal/client/dev/hmr.js +++ b/packages/svelte/src/internal/client/dev/hmr.js @@ -3,23 +3,21 @@ import { FILENAME, HMR } from '../../../constants.js'; import { EFFECT_TRANSPARENT } from '#client/constants'; import { hydrate_node, hydrating } from '../dom/hydration.js'; import { block, branch, destroy_effect } from '../reactivity/effects.js'; -import { source, update } from '../reactivity/sources.js'; +import { set, source, update } from '../reactivity/sources.js'; import { set_should_intro } from '../render.js'; import { get } from '../runtime.js'; /** * @template {(anchor: Comment, props: any) => any} Component - * @param {Component} component + * @param {Component} original_component */ -export function hmr(component) { - let s = source(0); - +export function hmr(original_component) { /** * @param {TemplateNode} anchor * @param {any} props */ function wrapper(anchor, props) { - let v = -1; + let component = {}; let instance = {}; /** @type {Effect} */ @@ -28,7 +26,7 @@ export function hmr(component) { let ran = false; block(() => { - if (v === (v = get(s))) { + if (component === (component = get(wrapper[HMR].source))) { return; } @@ -65,12 +63,27 @@ export function hmr(component) { } // @ts-expect-error - wrapper[FILENAME] = component[FILENAME]; + wrapper[FILENAME] = original_component[FILENAME]; // @ts-ignore - wrapper[HMR] = (c) => { - component = c; - update(s); + wrapper[HMR] = { + original: original_component, + source: source(original_component), + update: (/** @type {any} */ c) => { + // This logic ensures that the first version of the component is the one + // whose update function and therefore block effect is preserved across updates. + + // We do that by first updating the component instance with the latest one + // (c is the HMR-updated version of the component, already wrapped, so we use c[HMR].original)... + original_component = wrapper[HMR].original = c[HMR].original; + // ...then go the other way to tell the latest version to always + // use the source of the very first version... + c[HMR].source = wrapper[HMR].source; + // ...and finally trigger the block effect update + set(wrapper[HMR].source, original_component); + // If we don't do this dance and instead just use c as the new component + // and then update, we'll create an ever-growing stack of block effects. + } }; return wrapper; From 8ae4726aabf90274e57e61074a49695ea0dc891d Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 22 Dec 2025 01:02:35 +0100 Subject: [PATCH 5/6] update snapshot --- .../tests/snapshot/samples/hmr/_expected/client/index.svelte.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js index 5878c51aaed5..66d1a5af5a4a 100644 --- a/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/hmr/_expected/client/index.svelte.js @@ -14,7 +14,7 @@ if (import.meta.hot) { Hmr = $.hmr(Hmr); import.meta.hot.accept((module) => { - Hmr[$.HMR](module.default); + Hmr[$.HMR].update(module.default); }); } From 819442eaf7d4c018fc58f61285e2b7f67feee9ad Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 22 Dec 2025 13:41:52 -0500 Subject: [PATCH 6/6] tweak names for clarity, simplify `update` slightly --- .../svelte/src/internal/client/dev/hmr.js | 35 +++++++++---------- playgrounds/sandbox/svelte.config.js | 2 +- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/svelte/src/internal/client/dev/hmr.js b/packages/svelte/src/internal/client/dev/hmr.js index b07ec02be4b1..9fa4e6ccbd39 100644 --- a/packages/svelte/src/internal/client/dev/hmr.js +++ b/packages/svelte/src/internal/client/dev/hmr.js @@ -3,15 +3,17 @@ import { FILENAME, HMR } from '../../../constants.js'; import { EFFECT_TRANSPARENT } from '#client/constants'; import { hydrate_node, hydrating } from '../dom/hydration.js'; import { block, branch, destroy_effect } from '../reactivity/effects.js'; -import { set, source, update } from '../reactivity/sources.js'; +import { set, source } from '../reactivity/sources.js'; import { set_should_intro } from '../render.js'; import { get } from '../runtime.js'; /** * @template {(anchor: Comment, props: any) => any} Component - * @param {Component} original_component + * @param {Component} fn */ -export function hmr(original_component) { +export function hmr(fn) { + const current = source(fn); + /** * @param {TemplateNode} anchor * @param {any} props @@ -26,7 +28,7 @@ export function hmr(original_component) { let ran = false; block(() => { - if (component === (component = get(wrapper[HMR].source))) { + if (component === (component = get(current))) { return; } @@ -63,26 +65,23 @@ export function hmr(original_component) { } // @ts-expect-error - wrapper[FILENAME] = original_component[FILENAME]; + wrapper[FILENAME] = fn[FILENAME]; // @ts-ignore wrapper[HMR] = { - original: original_component, - source: source(original_component), - update: (/** @type {any} */ c) => { + fn, + current, + update: (/** @type {any} */ incoming) => { // This logic ensures that the first version of the component is the one // whose update function and therefore block effect is preserved across updates. - - // We do that by first updating the component instance with the latest one - // (c is the HMR-updated version of the component, already wrapped, so we use c[HMR].original)... - original_component = wrapper[HMR].original = c[HMR].original; - // ...then go the other way to tell the latest version to always - // use the source of the very first version... - c[HMR].source = wrapper[HMR].source; - // ...and finally trigger the block effect update - set(wrapper[HMR].source, original_component); - // If we don't do this dance and instead just use c as the new component + // If we don't do this dance and instead just use `incoming` as the new component // and then update, we'll create an ever-growing stack of block effects. + + // Trigger the original block effect + set(wrapper[HMR].current, incoming[HMR].fn); + + // Replace the incoming source with the original one + incoming[HMR].current = wrapper[HMR].current; } }; diff --git a/playgrounds/sandbox/svelte.config.js b/playgrounds/sandbox/svelte.config.js index 68ac605385aa..e1a6f1385b28 100644 --- a/playgrounds/sandbox/svelte.config.js +++ b/playgrounds/sandbox/svelte.config.js @@ -1,6 +1,6 @@ export default { compilerOptions: { - hmr: false, + hmr: true, experimental: { async: true