fix: avoid reinserting dynamic elements during hydration (#18855)

Hydrating a `<svelte:element>` currently removes and reinserts the DOM
node that was already claimed from the server-rendered HTML. This can
restart CSS animations and disconnect/reconnect custom elements inside
it.

Keep claimed elements in place while preserving insertion for
client-created elements and later tag changes. Capture the hydration
state before rendering children, since empty or void elements can
temporarily turn hydration off.

Fixes #18852.

### Before submitting the PR, please make sure you do the following

- [x] 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.
- [x] Ideally, include a test that fails without this PR but passes with
it.
- [x] 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`

Validation:

- The real Chromium regression fails on unchanged `636eaaaa`: hydration
removes all four server-rendered dynamic elements. Client mounting and
server rendering pass.
- The regression checks node removals and custom-element connection
callbacks for ordinary, empty, void, and custom dynamic elements, then
changes the tags and removes/recreates an element.
- A hydration mismatch regression verifies that different client/server
child branches recover while retaining the outer element and its
following sibling.
- `pnpm test hydration runtime-browser`: 219 passed.
- Full `CI=true pnpm test`: 34 test files passed, 7,790 tests passed, 55
existing skips. The real-browser suite ran with Chromium.
- `pnpm check`: passed, including build, generated type checks, and
treeshakeability checks.
- `pnpm lint`: passed.

AI assistance: This change and its tests were prepared with OpenAI
Codex. The PR description is also AI-assisted.

---------

Co-authored-by: paoloricciuti <ricciutipaolo@gmail.com>
await-sourcemaps
Kunpeng Xie 4 days ago committed by GitHub
parent 636eaaaa6f
commit 803f59b171
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: preserve dynamic element connections during hydration

@ -71,6 +71,7 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio
branches.ensure(next_tag, (anchor) => { branches.ensure(next_tag, (anchor) => {
if (next_tag) { if (next_tag) {
var is_hydrating = hydrating;
element = hydrating ? /** @type {Element} */ (element) : create_element(next_tag, ns); element = hydrating ? /** @type {Element} */ (element) : create_element(next_tag, ns);
if (DEV && location) { if (DEV && location) {
@ -123,7 +124,8 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio
// we do this after calling `render_fn` so that child effects don't override `nodes.end` // we do this after calling `render_fn` so that child effects don't override `nodes.end`
/** @type {Effect & { nodes: EffectNodes }} */ (active_effect).nodes.end = element; /** @type {Effect & { nodes: EffectNodes }} */ (active_effect).nodes.end = element;
anchor.before(element); // we only move the node if we are not hydrating since a claimed element is already in place
if (!is_hydrating) anchor.before(element);
} }
if (hydrating) { if (hydrating) {

@ -0,0 +1,10 @@
import { test } from '../../test';
export default test({
server_props: { condition: false },
props: { condition: true },
snapshot(target) {
return { element: target.querySelector('div'), sibling: target.querySelector(':scope > p') };
}
});

@ -0,0 +1,9 @@
<script>
export let condition;
export let tag = 'div';
</script>
<svelte:element this={tag}>
{#if condition}<p>client</p>{:else}<span>server</span>{/if}
</svelte:element>
<p>after</p>

@ -128,6 +128,7 @@ function normalize_children(node) {
* id_prefix?: string; * id_prefix?: string;
* props?: Props; * props?: Props;
* compileOptions?: Partial<CompileOptions>; * compileOptions?: Partial<CompileOptions>;
* before_test?: () => void;
* test?: (args: { * test?: (args: {
* assert: typeof assert & { * assert: typeof assert & {
* htmlEqual(a: string, b: string, description?: string): void; * htmlEqual(a: string, b: string, description?: string): void;

@ -0,0 +1,71 @@
import { flushSync } from 'svelte';
import { assert_ok, test } from '../../assert';
/** @type {Record<string, number>} */
const connections = {};
/** @type {string[]} */
const disconnections = [];
/** @type {Element[]} */
let claimed;
/** @type {MutationObserver} */
let observer;
export default test({
before_test() {
const target = document.querySelector('main');
assert_ok(target);
claimed = Array.from(target.children);
customElements.define(
'connection-probe',
class extends HTMLElement {
connectedCallback() {
connections[this.id] = (connections[this.id] || 0) + 1;
}
disconnectedCallback() {
disconnections.push(this.id);
}
}
);
observer = new MutationObserver(() => {});
observer.observe(target, { childList: true });
},
test({ assert, component, target }) {
const removed = observer.takeRecords().flatMap((record) => Array.from(record.removedNodes));
observer.disconnect();
assert.deepEqual(
removed
.filter((node) => node instanceof Element)
.filter((node) => claimed.includes(node))
.map((node) => node.id),
[]
);
assert.deepEqual(connections, { child: 1, custom: 1 });
assert.deepEqual(disconnections, []);
flushSync(() => {
component.tag = 'section';
component.empty_tag = 'span';
component.void_tag = 'hr';
component.custom_tag = 'aside';
});
assert.equal(target.querySelector('#parent')?.tagName, 'SECTION');
assert.equal(target.querySelector('#empty')?.tagName, 'SPAN');
assert.equal(target.querySelector('#void')?.tagName, 'HR');
assert.equal(target.querySelector('#custom')?.tagName, 'ASIDE');
assert.deepEqual(connections, { child: 2, custom: 1 });
assert.deepEqual(disconnections, ['child', 'custom']);
flushSync(() => {
component.tag = null;
});
assert.equal(target.querySelector('#parent'), null);
assert.deepEqual(disconnections, ['child', 'custom', 'child']);
flushSync(() => {
component.tag = 'div';
});
assert.equal(target.querySelector('#parent')?.tagName, 'DIV');
assert.deepEqual(connections, { child: 3, custom: 1 });
}
});

@ -0,0 +1,11 @@
<script>
export let tag = 'div';
export let empty_tag = 'div';
export let void_tag = 'input';
export let custom_tag = 'connection-probe';
</script>
<svelte:element this={tag} id="parent"><connection-probe id="child"></connection-probe></svelte:element>
<svelte:element this={empty_tag} id="empty" />
<svelte:element this={void_tag} id="void" />
<svelte:element this={custom_tag} id="custom" />
Loading…
Cancel
Save