From 6b653b8d17c80b16659c5238875977f0941490c2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 7 Apr 2026 15:06:21 -0400 Subject: [PATCH 1/7] chore: simplify parser (#18077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit small tidy-up spurred by #17954 — rather than try-catching every `parse_expression_at` call and passing the error to `parser.acorn_error`, we can handle the error locally and get rid of that method ### Before submitting the PR, please make sure you do the following - [ ] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [ ] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- .../src/compiler/phases/1-parse/acorn.js | 66 ++++++++++++------- .../src/compiler/phases/1-parse/index.js | 10 --- .../compiler/phases/1-parse/read/context.js | 54 +++++++-------- .../phases/1-parse/read/expression.js | 2 +- .../compiler/phases/1-parse/read/script.js | 9 +-- 5 files changed, 69 insertions(+), 72 deletions(-) diff --git a/packages/svelte/src/compiler/phases/1-parse/acorn.js b/packages/svelte/src/compiler/phases/1-parse/acorn.js index 797ab4cea5..45a7c2a58c 100644 --- a/packages/svelte/src/compiler/phases/1-parse/acorn.js +++ b/packages/svelte/src/compiler/phases/1-parse/acorn.js @@ -4,8 +4,10 @@ import * as acorn from 'acorn'; import { walk } from 'zimmerframe'; import { tsPlugin } from '@sveltejs/acorn-typescript'; +import * as e from '../../errors.js'; -const ParserWithTS = acorn.Parser.extend(tsPlugin()); +const JSParser = acorn.Parser; +const TSParser = JSParser.extend(tsPlugin()); /** * @typedef {Comment & { @@ -21,15 +23,15 @@ const ParserWithTS = acorn.Parser.extend(tsPlugin()); * @param {boolean} [is_script] */ export function parse(source, comments, typescript, is_script) { - const parser = typescript ? ParserWithTS : acorn.Parser; + const acorn = typescript ? TSParser : JSParser; const { onComment, add_comments } = get_comment_handlers( source, /** @type {CommentWithLocation[]} */ (comments) ); - // @ts-ignore - const parse_statement = parser.prototype.parseStatement; + // @ts-expect-error + const parse_statement = acorn.prototype.parseStatement; // If we're dealing with a + + + + + + + {#if show} + {await Promise.reject('boom')} + {/if} + {#snippet failed()} + failed + {/snippet} + \ No newline at end of file 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 7/7] 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 @@ + + + + +