Skip to content

Commit 6d195f0

Browse files
authored
fix: handle hydration mismatches in await blocks (#15708)
* failing test for #15704 * handle hydration mismatches in await blocks * DRY out * changeset * update test
1 parent 6c97a78 commit 6d195f0

File tree

8 files changed

+92
-14
lines changed

8 files changed

+92
-14
lines changed

.changeset/wild-carrots-eat.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: handle hydration mismatches in await blocks

packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js

+3-7
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,28 @@
22
/** @import { AST } from '#compiler' */
33
/** @import { ComponentContext } from '../types.js' */
44
import * as b from '../../../../utils/builders.js';
5-
import { empty_comment } from './shared/utils.js';
5+
import { block_close } from './shared/utils.js';
66

77
/**
88
* @param {AST.AwaitBlock} node
99
* @param {ComponentContext} context
1010
*/
1111
export function AwaitBlock(node, context) {
1212
context.state.template.push(
13-
empty_comment,
1413
b.stmt(
1514
b.call(
1615
'$.await',
16+
b.id('$$payload'),
1717
/** @type {Expression} */ (context.visit(node.expression)),
1818
b.thunk(
1919
node.pending ? /** @type {BlockStatement} */ (context.visit(node.pending)) : b.block([])
2020
),
2121
b.arrow(
2222
node.value ? [/** @type {Pattern} */ (context.visit(node.value))] : [],
2323
node.then ? /** @type {BlockStatement} */ (context.visit(node.then)) : b.block([])
24-
),
25-
b.arrow(
26-
node.error ? [/** @type {Pattern} */ (context.visit(node.error))] : [],
27-
node.catch ? /** @type {BlockStatement} */ (context.visit(node.catch)) : b.block([])
2824
)
2925
)
3026
),
31-
empty_comment
27+
block_close
3228
);
3329
}

packages/svelte/src/constants.js

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const HYDRATION_START = '[';
2222
/** used to indicate that an `{:else}...` block was rendered */
2323
export const HYDRATION_START_ELSE = '[!';
2424
export const HYDRATION_END = ']';
25+
export const HYDRATION_AWAIT_THEN = '!';
2526
export const HYDRATION_ERROR = {};
2627

2728
export const ELEMENT_IS_NAMESPACED = 1;

packages/svelte/src/internal/client/dom/blocks/await.js

+27-2
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@ import { is_promise } from '../../../shared/utils.js';
44
import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js';
55
import { internal_set, mutable_source, source } from '../../reactivity/sources.js';
66
import { flushSync, set_active_effect, set_active_reaction } from '../../runtime.js';
7-
import { hydrate_next, hydrate_node, hydrating } from '../hydration.js';
7+
import {
8+
hydrate_next,
9+
hydrate_node,
10+
hydrating,
11+
remove_nodes,
12+
set_hydrate_node,
13+
set_hydrating
14+
} from '../hydration.js';
815
import { queue_micro_task } from '../task.js';
9-
import { UNINITIALIZED } from '../../../../constants.js';
16+
import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js';
1017
import {
1118
component_context,
1219
is_runes,
@@ -113,6 +120,19 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
113120
var effect = block(() => {
114121
if (input === (input = get_input())) return;
115122

123+
/** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */
124+
// @ts-ignore coercing `anchor` to a `Comment` causes TypeScript and Prettier to fight
125+
let mismatch = hydrating && is_promise(input) === (anchor.data === HYDRATION_START_ELSE);
126+
127+
if (mismatch) {
128+
// Hydration mismatch: remove everything inside the anchor and start fresh
129+
anchor = remove_nodes();
130+
131+
set_hydrate_node(anchor);
132+
set_hydrating(false);
133+
mismatch = true;
134+
}
135+
116136
if (is_promise(input)) {
117137
var promise = input;
118138

@@ -155,6 +175,11 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
155175
update(THEN, false);
156176
}
157177

178+
if (mismatch) {
179+
// continue in hydration mode
180+
set_hydrating(true);
181+
}
182+
158183
// Set the input to something else, in order to disable the promise callbacks
159184
return () => (input = UNINITIALIZED);
160185
});

packages/svelte/src/internal/server/index.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { escape_html } from '../../escaping.js';
1414
import { DEV } from 'esm-env';
1515
import { current_component, pop, push } from './context.js';
16-
import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
16+
import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydration.js';
1717
import { validate_store } from '../shared/validate.js';
1818
import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js';
1919
import { reset_elements } from './dev.js';
@@ -474,18 +474,21 @@ export function bind_props(props_parent, props_now) {
474474

475475
/**
476476
* @template V
477+
* @param {Payload} payload
477478
* @param {Promise<V>} promise
478479
* @param {null | (() => void)} pending_fn
479480
* @param {(value: V) => void} then_fn
480481
* @returns {void}
481482
*/
482-
function await_block(promise, pending_fn, then_fn) {
483+
function await_block(payload, promise, pending_fn, then_fn) {
483484
if (is_promise(promise)) {
485+
payload.out += BLOCK_OPEN;
484486
promise.then(null, noop);
485487
if (pending_fn !== null) {
486488
pending_fn();
487489
}
488490
} else if (then_fn !== null) {
491+
payload.out += BLOCK_OPEN_ELSE;
489492
then_fn(promise);
490493
}
491494
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
ssrHtml: '<button>fulfil</button><p>42</p><hr><p>loading...</p>',
6+
html: '<button>fulfil</button><p>loading...</p><hr><p>42</p>',
7+
8+
props: {
9+
browser: true
10+
},
11+
12+
server_props: {
13+
browser: false
14+
},
15+
16+
async test({ assert, target }) {
17+
const button = target.querySelector('button');
18+
19+
flushSync(() => button?.click());
20+
await Promise.resolve();
21+
assert.htmlEqual(target.innerHTML, '<button>fulfil</button><p>42</p><hr><p>42</p>');
22+
}
23+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script>
2+
let { browser } = $props();
3+
4+
let fulfil;
5+
let promise = new Promise((f) => (fulfil = f));
6+
7+
let a = browser ? promise : 42;
8+
let b = browser ? 42 : promise;
9+
</script>
10+
11+
<button onclick={() => fulfil(42)}>fulfil</button>
12+
13+
{#await a}
14+
{#if true}<p>loading...</p>{/if}
15+
{:then a}
16+
<p>{a}</p>
17+
{/await}
18+
19+
<hr>
20+
21+
{#await b}
22+
{#if true}<p>loading...</p>{/if}
23+
{:then b}
24+
<p>{b}</p>
25+
{/await}

packages/svelte/tests/snapshot/samples/await-block-scope/_expected/server/index.svelte.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default function Await_block_scope($$payload) {
88
counter.count += 1;
99
}
1010

11-
$$payload.out += `<button>clicks: ${$.escape(counter.count)}</button> <!---->`;
12-
$.await(promise, () => {}, (counter) => {}, () => {});
13-
$$payload.out += `<!----> ${$.escape(counter.count)}`;
11+
$$payload.out += `<button>clicks: ${$.escape(counter.count)}</button> `;
12+
$.await($$payload, promise, () => {}, (counter) => {});
13+
$$payload.out += `<!--]--> ${$.escape(counter.count)}`;
1414
}

0 commit comments

Comments
 (0)