diff --git a/.changeset/wild-carrots-eat.md b/.changeset/wild-carrots-eat.md new file mode 100644 index 0000000000..23b55f945c --- /dev/null +++ b/.changeset/wild-carrots-eat.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: handle hydration mismatches in await blocks diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js index 8fc82b8905..2aa534d257 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitBlock.js @@ -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 ); } diff --git a/packages/svelte/src/constants.js b/packages/svelte/src/constants.js index 8861e440fc..6ea407d448 100644 --- a/packages/svelte/src/constants.js +++ b/packages/svelte/src/constants.js @@ -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; diff --git a/packages/svelte/src/internal/client/dom/blocks/await.js b/packages/svelte/src/internal/client/dom/blocks/await.js index 2e3d229779..99bdc0000c 100644 --- a/packages/svelte/src/internal/client/dom/blocks/await.js +++ b/packages/svelte/src/internal/client/dom/blocks/await.js @@ -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); }); diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index bf36a595d8..ff34c07132 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -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} 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); } } diff --git a/packages/svelte/tests/runtime-runes/samples/await-hydrate-maybe-promise/_config.js b/packages/svelte/tests/runtime-runes/samples/await-hydrate-maybe-promise/_config.js new file mode 100644 index 0000000000..f81b41d41a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/await-hydrate-maybe-promise/_config.js @@ -0,0 +1,23 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + ssrHtml: '

42


loading...

', + html: '

loading...


42

', + + 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, '

42


42

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/await-hydrate-maybe-promise/main.svelte b/packages/svelte/tests/runtime-runes/samples/await-hydrate-maybe-promise/main.svelte new file mode 100644 index 0000000000..d8d0cd4027 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/await-hydrate-maybe-promise/main.svelte @@ -0,0 +1,25 @@ + + + + +{#await a} + {#if true}

loading...

{/if} +{:then a} +

{a}

+{/await} + +
+ +{#await b} + {#if true}

loading...

{/if} +{:then b} +

{b}

+{/await} diff --git a/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/server/index.svelte.js index 012789a550..4b6e32d58e 100644 --- a/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/server/index.svelte.js @@ -8,7 +8,7 @@ export default function Await_block_scope($$payload) { counter.count += 1; } - $$payload.out += ` `; - $.await(promise, () => {}, (counter) => {}, () => {}); - $$payload.out += ` ${$.escape(counter.count)}`; + $$payload.out += ` `; + $.await($$payload, promise, () => {}, (counter) => {}); + $$payload.out += ` ${$.escape(counter.count)}`; } \ No newline at end of file