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