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
pull/17666/merge
Simon H 3 months ago committed by GitHub
parent a4ce776070
commit 7be1a0247f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: ensure proper HMR updates for dynamic components

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

@ -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);

@ -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();

@ -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, `<button>show</button> 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, `<button>show</button> component`);
}
});

@ -0,0 +1,9 @@
<script>
import Component from "./Component.svelte";
let C = $state(null);
</script>
<button onclick={() => C = Component}>show</button>
<C />
Loading…
Cancel
Save