fix: repair dynamic component truthy/falsy hydration mismatches (#17737)

Fixes #17735

Use the if/else hydration markers to know what "branch" (component or no
component) was rendered, and repair if differing.

### 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`
pull/17743/head
Simon H 7 months ago committed by GitHub
parent 8ea33bf7fe
commit 9f48e7620f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: repair dynamic component truthy/falsy hydration mismatches

@ -1,7 +1,14 @@
/** @import { BlockStatement, Expression, Pattern, Property, SequenceExpression, Statement } from 'estree' */ /** @import { BlockStatement, Expression, Pattern, Property, SequenceExpression, Statement } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../../types.js' */ /** @import { ComponentContext } from '../../types.js' */
import { empty_comment, build_attribute_value, PromiseOptimiser } from './utils.js'; import {
empty_comment,
build_attribute_value,
PromiseOptimiser,
block_open_else,
block_open,
block_close
} from './utils.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { is_element_node } from '../../../../nodes.js'; import { is_element_node } from '../../../../nodes.js';
import { dev } from '../../../../../state.js'; import { dev } from '../../../../../state.js';
@ -300,9 +307,22 @@ export function build_inline_component(node, expression, context) {
node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic); node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic);
/** @type {Statement} */ /** @type {Statement} */
let statement = b.stmt( let statement = b.stmt(b.call(expression, b.id('$$renderer'), props_expression));
(dynamic ? b.maybe_call : b.call)(expression, b.id('$$renderer'), props_expression)
); if (dynamic) {
statement = b.if(
expression,
b.block([
b.stmt(b.call('$$renderer.push', block_open)),
statement,
b.stmt(b.call('$$renderer.push', block_close))
]),
b.block([
b.stmt(b.call('$$renderer.push', block_open_else)),
b.stmt(b.call('$$renderer.push', block_close))
])
);
}
if (snippet_declarations.length > 0) { if (snippet_declarations.length > 0) {
statement = b.block([...snippet_declarations, statement]); statement = b.block([...snippet_declarations, statement]);
@ -326,16 +346,14 @@ export function build_inline_component(node, expression, context) {
optimiser.check_blockers(node.metadata.expression); optimiser.check_blockers(node.metadata.expression);
} }
context.state.template.push( context.state.template.push(...optimiser.render_block([statement]));
...optimiser.render_block([
dynamic && custom_css_props.length === 0
? b.stmt(b.call('$$renderer.push', empty_comment))
: b.empty,
statement
])
);
if (!optimiser.is_async() && !context.state.is_standalone && custom_css_props.length === 0) { if (
!dynamic &&
!optimiser.is_async() &&
!context.state.is_standalone &&
custom_css_props.length === 0
) {
context.state.template.push(empty_comment); context.state.template.push(empty_comment);
} }
} }

@ -1,8 +1,17 @@
/** @import { TemplateNode, Dom } from '#client' */ /** @import { TemplateNode, Dom } from '#client' */
import { EFFECT_TRANSPARENT } from '#client/constants'; import { EFFECT_TRANSPARENT } from '#client/constants';
import { block } from '../../reactivity/effects.js'; import { block } from '../../reactivity/effects.js';
import { hydrate_next, hydrating } from '../hydration.js'; import {
hydrate_next,
hydrate_node,
hydrating,
read_hydration_instruction,
set_hydrate_node,
set_hydrating,
skip_nodes
} from '../hydration.js';
import { BranchManager } from './branches.js'; import { BranchManager } from './branches.js';
import { HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js';
/** /**
* @template P * @template P
@ -13,7 +22,11 @@ import { BranchManager } from './branches.js';
* @returns {void} * @returns {void}
*/ */
export function component(node, get_component, render_fn) { export function component(node, get_component, render_fn) {
/** @type {TemplateNode | undefined} */
var hydration_start_node;
if (hydrating) { if (hydrating) {
hydration_start_node = hydrate_node;
hydrate_next(); hydrate_next();
} }
@ -21,6 +34,28 @@ export function component(node, get_component, render_fn) {
block(() => { block(() => {
var component = get_component() ?? null; var component = get_component() ?? null;
if (hydrating) {
var data = read_hydration_instruction(/** @type {TemplateNode} */ (hydration_start_node));
var server_had_component = data === HYDRATION_START;
var client_has_component = component !== null;
if (server_had_component !== client_has_component) {
// Hydration mismatch: skip the server-rendered nodes and render fresh
var anchor = skip_nodes();
set_hydrate_node(anchor);
branches.anchor = anchor;
set_hydrating(false);
branches.ensure(component, component && ((target) => render_fn(target, component)));
set_hydrating(true);
return;
}
}
branches.ensure(component, component && ((target) => render_fn(target, component))); branches.ensure(component, component && ((target) => render_fn(target, component)));
}, EFFECT_TRANSPARENT); }, EFFECT_TRANSPARENT);
} }

@ -105,16 +105,16 @@ export function css_props(renderer, is_html, props, component, dynamic = false)
renderer.push(`<g style="${styles}">`); renderer.push(`<g style="${styles}">`);
} }
if (dynamic) { component();
if (!dynamic) {
renderer.push('<!---->'); renderer.push('<!---->');
} }
component();
if (is_html) { if (is_html) {
renderer.push(`<!----></svelte-css-wrapper>`); renderer.push('</svelte-css-wrapper>');
} else { } else {
renderer.push(`<!----></g>`); renderer.push('</g>');
} }
} }

@ -0,0 +1,7 @@
<div>Hello</div>
<style>
div {
color: var(--color);
}
</style>

@ -0,0 +1,10 @@
import { test } from '../../test';
export default test({
async test({ assert, target }) {
assert.htmlEqual(
target.innerHTML,
`<svelte-css-wrapper style="display: contents; --color: red;"><div class="svelte-lsmn3l">Hello</div></svelte-css-wrapper>`
);
}
});

@ -0,0 +1,6 @@
<script>
import Component from './Component.svelte';
let Comp = Component;
</script>
<svelte:component this={Comp} --color="red" />

@ -0,0 +1,9 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
assert.htmlEqual(target.innerHTML, `<h1>Test</h1> <div>Hello world</div>`);
}
});

@ -0,0 +1,13 @@
<script>
import HelloWorld from './HelloWorld.svelte';
let Component = $state();
$effect.pre(() => {
Component = HelloWorld;
});
</script>
<h1>Test</h1>
<Component />
Loading…
Cancel
Save