From deabc374dc98c4247009d763aa72fc08a6b6cef8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 8 Apr 2025 10:08:21 -0400 Subject: [PATCH 1/5] failing test for #15704 --- .../await-hydrate-maybe-promise/_config.js | 23 +++++++++++++++++++ .../await-hydrate-maybe-promise/main.svelte | 16 +++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/await-hydrate-maybe-promise/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/await-hydrate-maybe-promise/main.svelte 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 000000000000..bcc806227595 --- /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

', + html: '

loading...

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

'); + } +}); 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 000000000000..1efa2a60a974 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/await-hydrate-maybe-promise/main.svelte @@ -0,0 +1,16 @@ + + + + +{#await num} + {#if true}

loading...

{/if} +{:then num} +

{num}

+{/await} From 954ea8be1a8046a7078774f399d8d2a81ece359e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 8 Apr 2025 10:47:36 -0400 Subject: [PATCH 2/5] handle hydration mismatches in await blocks --- .../3-transform/server/visitors/AwaitBlock.js | 10 ++-- packages/svelte/src/constants.js | 1 + .../src/internal/client/dom/blocks/await.js | 50 ++++++++++++++++++- packages/svelte/src/internal/server/index.js | 7 ++- .../await-hydrate-maybe-promise/_config.js | 6 +-- .../await-hydrate-maybe-promise/main.svelte | 17 +++++-- 6 files changed, 73 insertions(+), 18 deletions(-) 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 8fc82b89051c..2aa534d25767 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 8861e440fc30..6ea407d44823 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 2e3d22977914..f6127fbef806 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,20 @@ 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 */ + let mismatch = false; + + if (hydrating) { + if (/** @type {Comment} */ (anchor).data !== HYDRATION_START_ELSE) { + // 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; @@ -140,6 +161,15 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { ); if (hydrating) { + if (/** @type {Comment} */ (anchor).data === HYDRATION_START_ELSE) { + // Hydration mismatch: remove everything inside the anchor and start fresh + anchor = remove_nodes(); + + set_hydrate_node(anchor); + set_hydrating(false); + mismatch = true; + } + if (pending_fn) { pending_effect = branch(() => pending_fn(anchor)); } @@ -151,10 +181,26 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { }); } } else { + if (hydrating) { + if (/** @type {Comment} */ (anchor).data !== HYDRATION_START_ELSE) { + // Hydration mismatch: remove everything inside the anchor and start fresh + anchor = remove_nodes(); + + set_hydrate_node(anchor); + set_hydrating(false); + mismatch = true; + } + } + internal_set(input_source, input); 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 bf36a595d8a9..ff34c0713263 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 index bcc806227595..f81b41d41ae9 100644 --- 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 @@ -2,8 +2,8 @@ import { flushSync } from 'svelte'; import { test } from '../../test'; export default test({ - ssrHtml: '

42

', - html: '

loading...

', + ssrHtml: '

42


loading...

', + html: '

loading...


42

', props: { browser: true @@ -18,6 +18,6 @@ export default test({ flushSync(() => button?.click()); await Promise.resolve(); - assert.htmlEqual(target.innerHTML, '

42

'); + 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 index 1efa2a60a974..d8d0cd402750 100644 --- 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 @@ -4,13 +4,22 @@ let fulfil; let promise = new Promise((f) => (fulfil = f)); - let num = browser ? promise : 42; + let a = browser ? promise : 42; + let b = browser ? 42 : promise; -{#await num} +{#await a} {#if true}

loading...

{/if} -{:then num} -

{num}

+{:then a} +

{a}

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

loading...

{/if} +{:then b} +

{b}

{/await} From 81727d88d4c44fe94938cfde7d3f87b094547694 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 8 Apr 2025 10:52:31 -0400 Subject: [PATCH 3/5] DRY out --- .../src/internal/client/dom/blocks/await.js | 37 ++++--------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/await.js b/packages/svelte/src/internal/client/dom/blocks/await.js index f6127fbef806..99bdc0000cc7 100644 --- a/packages/svelte/src/internal/client/dom/blocks/await.js +++ b/packages/svelte/src/internal/client/dom/blocks/await.js @@ -121,17 +121,16 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { 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 */ - let mismatch = false; + // @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 (hydrating) { - if (/** @type {Comment} */ (anchor).data !== HYDRATION_START_ELSE) { - // Hydration mismatch: remove everything inside the anchor and start fresh - anchor = remove_nodes(); + if (mismatch) { + // Hydration mismatch: remove everything inside the anchor and start fresh + anchor = remove_nodes(); - set_hydrate_node(anchor); - set_hydrating(false); - mismatch = true; - } + set_hydrate_node(anchor); + set_hydrating(false); + mismatch = true; } if (is_promise(input)) { @@ -161,15 +160,6 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { ); if (hydrating) { - if (/** @type {Comment} */ (anchor).data === HYDRATION_START_ELSE) { - // Hydration mismatch: remove everything inside the anchor and start fresh - anchor = remove_nodes(); - - set_hydrate_node(anchor); - set_hydrating(false); - mismatch = true; - } - if (pending_fn) { pending_effect = branch(() => pending_fn(anchor)); } @@ -181,17 +171,6 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { }); } } else { - if (hydrating) { - if (/** @type {Comment} */ (anchor).data !== HYDRATION_START_ELSE) { - // Hydration mismatch: remove everything inside the anchor and start fresh - anchor = remove_nodes(); - - set_hydrate_node(anchor); - set_hydrating(false); - mismatch = true; - } - } - internal_set(input_source, input); update(THEN, false); } From 7b7cd29df31e888fe030420d34a9c3556083c406 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 8 Apr 2025 10:52:58 -0400 Subject: [PATCH 4/5] changeset --- .changeset/wild-carrots-eat.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wild-carrots-eat.md diff --git a/.changeset/wild-carrots-eat.md b/.changeset/wild-carrots-eat.md new file mode 100644 index 000000000000..23b55f945c75 --- /dev/null +++ b/.changeset/wild-carrots-eat.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: handle hydration mismatches in await blocks From dfd0c11fd0d283ddd2f37dc317ef04c0c1ff28e5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 8 Apr 2025 11:01:48 -0400 Subject: [PATCH 5/5] update test --- .../await-block-scope/_expected/server/index.svelte.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 012789a5509b..4b6e32d58e0a 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