mirror of https://github.com/sveltejs/svelte
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
parent
636eaaaa6f
commit
803f59b171
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
fix: preserve dynamic element connections during hydration
|
||||
@ -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,2 @@
|
||||
<div><p>client</p></div>
|
||||
<p>after</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>
|
||||
@ -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…
Reference in new issue