Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wild-carrots-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: handle hydration mismatches in await blocks
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,28 @@
/** @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
* @param {ComponentContext} context
*/
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([])
),
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
);
}
1 change: 1 addition & 0 deletions packages/svelte/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 27 additions & 2 deletions packages/svelte/src/internal/client/dom/blocks/await.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
});
Expand Down
7 changes: 5 additions & 2 deletions packages/svelte/src/internal/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>');
}
});
Original file line number Diff line number Diff line change
@@ -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}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting. I was actually finding that the runtime was only having issue when I used a Component and an if (browser) conditional in the await block, rather than "normal" HTML. I have no idea why, though, and that may have just been due to my surrounding structure.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the component is sufficient but not necessary — basically anything that adds 'structure' will confuse the hydration algorithm if there's a mismatch, and the if block does the same thing. For test cases it's handy to keep things in a single component where possible, so I always try to whittle down

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, that makes total sense. I'm just surprised this recreated the issue. Thanks for the fix!

Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;
}