fix: handle hydration mismatches in await blocks (#15708)

* failing test for #15704

* handle hydration mismatches in await blocks

* DRY out

* changeset

* update test
pull/15726/head
Rich Harris 5 months ago committed by GitHub
parent 6c97a78049
commit 6d195f0350
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: handle hydration mismatches in await blocks

@ -2,7 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import * as b from '../../../../utils/builders.js';
import { empty_comment } from './shared/utils.js';
import { block_close } from './shared/utils.js';
/**
* @param {AST.AwaitBlock} node
@ -10,10 +10,10 @@ import { empty_comment } from './shared/utils.js';
*/
export function AwaitBlock(node, context) {
context.state.template.push(
empty_comment,
b.stmt(
b.call(
'$.await',
b.id('$$payload'),
/** @type {Expression} */ (context.visit(node.expression)),
b.thunk(
node.pending ? /** @type {BlockStatement} */ (context.visit(node.pending)) : b.block([])
@ -21,13 +21,9 @@ export function AwaitBlock(node, context) {
b.arrow(
node.value ? [/** @type {Pattern} */ (context.visit(node.value))] : [],
node.then ? /** @type {BlockStatement} */ (context.visit(node.then)) : b.block([])
),
b.arrow(
node.error ? [/** @type {Pattern} */ (context.visit(node.error))] : [],
node.catch ? /** @type {BlockStatement} */ (context.visit(node.catch)) : b.block([])
)
)
),
empty_comment
block_close
);
}

@ -22,6 +22,7 @@ export const HYDRATION_START = '[';
/** used to indicate that an `{:else}...` block was rendered */
export const HYDRATION_START_ELSE = '[!';
export const HYDRATION_END = ']';
export const HYDRATION_AWAIT_THEN = '!';
export const HYDRATION_ERROR = {};
export const ELEMENT_IS_NAMESPACED = 1;

@ -4,9 +4,16 @@ import { is_promise } from '../../../shared/utils.js';
import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js';
import { internal_set, mutable_source, source } from '../../reactivity/sources.js';
import { flushSync, set_active_effect, set_active_reaction } from '../../runtime.js';
import { hydrate_next, hydrate_node, hydrating } from '../hydration.js';
import {
hydrate_next,
hydrate_node,
hydrating,
remove_nodes,
set_hydrate_node,
set_hydrating
} from '../hydration.js';
import { queue_micro_task } from '../task.js';
import { UNINITIALIZED } from '../../../../constants.js';
import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js';
import {
component_context,
is_runes,
@ -113,6 +120,19 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
var effect = block(() => {
if (input === (input = get_input())) return;
/** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */
// @ts-ignore coercing `anchor` to a `Comment` causes TypeScript and Prettier to fight
let mismatch = hydrating && is_promise(input) === (anchor.data === HYDRATION_START_ELSE);
if (mismatch) {
// Hydration mismatch: remove everything inside the anchor and start fresh
anchor = remove_nodes();
set_hydrate_node(anchor);
set_hydrating(false);
mismatch = true;
}
if (is_promise(input)) {
var promise = input;
@ -155,6 +175,11 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
update(THEN, false);
}
if (mismatch) {
// continue in hydration mode
set_hydrating(true);
}
// Set the input to something else, in order to disable the promise callbacks
return () => (input = UNINITIALIZED);
});

@ -13,7 +13,7 @@ import {
import { escape_html } from '../../escaping.js';
import { DEV } from 'esm-env';
import { current_component, pop, push } from './context.js';
import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydration.js';
import { validate_store } from '../shared/validate.js';
import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js';
import { reset_elements } from './dev.js';
@ -474,18 +474,21 @@ export function bind_props(props_parent, props_now) {
/**
* @template V
* @param {Payload} payload
* @param {Promise<V>} promise
* @param {null | (() => void)} pending_fn
* @param {(value: V) => void} then_fn
* @returns {void}
*/
function await_block(promise, pending_fn, then_fn) {
function await_block(payload, promise, pending_fn, then_fn) {
if (is_promise(promise)) {
payload.out += BLOCK_OPEN;
promise.then(null, noop);
if (pending_fn !== null) {
pending_fn();
}
} else if (then_fn !== null) {
payload.out += BLOCK_OPEN_ELSE;
then_fn(promise);
}
}

@ -0,0 +1,23 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
ssrHtml: '<button>fulfil</button><p>42</p><hr><p>loading...</p>',
html: '<button>fulfil</button><p>loading...</p><hr><p>42</p>',
props: {
browser: true
},
server_props: {
browser: false
},
async test({ assert, target }) {
const button = target.querySelector('button');
flushSync(() => button?.click());
await Promise.resolve();
assert.htmlEqual(target.innerHTML, '<button>fulfil</button><p>42</p><hr><p>42</p>');
}
});

@ -0,0 +1,25 @@
<script>
let { browser } = $props();
let fulfil;
let promise = new Promise((f) => (fulfil = f));
let a = browser ? promise : 42;
let b = browser ? 42 : promise;
</script>
<button onclick={() => fulfil(42)}>fulfil</button>
{#await a}
{#if true}<p>loading...</p>{/if}
{:then a}
<p>{a}</p>
{/await}
<hr>
{#await b}
{#if true}<p>loading...</p>{/if}
{:then b}
<p>{b}</p>
{/await}

@ -8,7 +8,7 @@ export default function Await_block_scope($$payload) {
counter.count += 1;
}
$$payload.out += `<button>clicks: ${$.escape(counter.count)}</button> <!---->`;
$.await(promise, () => {}, (counter) => {}, () => {});
$$payload.out += `<!----> ${$.escape(counter.count)}`;
$$payload.out += `<button>clicks: ${$.escape(counter.count)}</button> `;
$.await($$payload, promise, () => {}, (counter) => {});
$$payload.out += `<!--]--> ${$.escape(counter.count)}`;
}
Loading…
Cancel
Save