From b6fcc149b87720a431eb636ce291a0c0125b0bae Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 5 Feb 2024 14:04:38 +0100 Subject: [PATCH] fix: repair each block length mismatches during hydration (#10398) fixes #10332 --- .changeset/olive-socks-kick.md | 5 + .../3-transform/server/transform-server.js | 6 +- packages/svelte/src/internal/client/each.js | 161 +++++++++++++----- .../svelte/src/internal/client/hydration.js | 8 +- .../each-block-fallback-mismatch/_after.html | 2 + .../each-block-fallback-mismatch/_before.html | 2 + .../each-block-fallback-mismatch/_config.js | 3 + .../each-block-fallback-mismatch/main.svelte | 16 ++ .../_after.html | 9 + .../_before.html | 12 ++ .../_config.js | 15 ++ .../main.svelte | 32 ++++ .../_after.html | 9 + .../_before.html | 9 + .../_config.js | 15 ++ .../main.svelte | 32 ++++ 16 files changed, 288 insertions(+), 48 deletions(-) create mode 100644 .changeset/olive-socks-kick.md create mode 100644 packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_after.html create mode 100644 packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_before.html create mode 100644 packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_config.js create mode 100644 packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/main.svelte create mode 100644 packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_after.html create mode 100644 packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_before.html create mode 100644 packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_config.js create mode 100644 packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/main.svelte create mode 100644 packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_after.html create mode 100644 packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_before.html create mode 100644 packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_config.js create mode 100644 packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/main.svelte diff --git a/.changeset/olive-socks-kick.md b/.changeset/olive-socks-kick.md new file mode 100644 index 0000000000..a45b35292e --- /dev/null +++ b/.changeset/olive-socks-kick.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: repair each block length mismatches during hydration diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 0d615d6693..accb6103a7 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -1340,12 +1340,16 @@ const template_visitors = { b.block(each) ); if (node.fallback) { + const fallback_stmts = create_block(node, node.fallback.nodes, context); + fallback_stmts.unshift( + b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(''))) + ); state.template.push( t_statement( b.if( b.binary('!==', b.member(array_id, b.id('length')), b.literal(0)), for_loop, - b.block(create_block(node, node.fallback.nodes, context)) + b.block(fallback_stmts) ) ) ); diff --git a/packages/svelte/src/internal/client/each.js b/packages/svelte/src/internal/client/each.js index 3974557ba7..032edad201 100644 --- a/packages/svelte/src/internal/client/each.js +++ b/packages/svelte/src/internal/client/each.js @@ -64,6 +64,10 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re /** @type {null | import('./types.js').EffectSignal} */ let render = null; + + /** Whether or not there was a "rendered fallback but want to render items" (or vice versa) hydration mismatch */ + let mismatch = false; + block.r = /** @param {import('./types.js').Transition} transition */ (transition) => { @@ -144,12 +148,30 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re : maybe_array == null ? [] : Array.from(maybe_array); + if (key_fn !== null) { keys = array.map(key_fn); } else if ((flags & EACH_KEYED) === 0) { array.map(no_op); } + const length = array.length; + + if (current_hydration_fragment !== null) { + const is_each_else_comment = + /** @type {Comment} */ (current_hydration_fragment?.[0])?.data === 'ssr:each_else'; + // Check for hydration mismatch which can happen if the server renders the each fallback + // but the client has items, or vice versa. If so, remove everything inside the anchor and start fresh. + if ((is_each_else_comment && length) || (!is_each_else_comment && !length)) { + remove(/** @type {import('./types.js').TemplateNode[]} */ (current_hydration_fragment)); + set_current_hydration_fragment(null); + mismatch = true; + } else if (is_each_else_comment) { + // Remove the each_else comment node or else it will confuse the subsequent hydration algorithm + /** @type {import('./types.js').TemplateNode[]} */ (current_hydration_fragment).shift(); + } + } + if (fallback_fn !== null) { if (length === 0) { if (block.v.length !== 0 || render === null) { @@ -170,6 +192,7 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re } } } + if (render !== null) { execute_effect(render); } @@ -180,6 +203,11 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re render = render_effect(clear_each, block, true); + if (mismatch) { + // Set a fragment so that Svelte continues to operate in hydration mode + set_current_hydration_fragment([]); + } + push_destroy_fn(each, () => { const flags = block.f; const anchor_node = block.a; @@ -287,55 +315,70 @@ function reconcile_indexed_array( } } else { var item; + var is_hydrating = current_hydration_fragment !== null; b_blocks = Array(b); - if (current_hydration_fragment !== null) { - /** @type {Node} */ - var hydrating_node = current_hydration_fragment[0]; + if (is_hydrating) { + // Hydrate block + var hydration_list = /** @type {import('./types.js').TemplateNode[]} */ ( + current_hydration_fragment + ); + var hydrating_node = hydration_list[0]; for (; index < length; index++) { - // Hydrate block - item = is_proxied_array ? lazy_property(array, index) : array[index]; var fragment = /** @type {Array} */ ( get_hydration_fragment(hydrating_node) ); set_current_hydration_fragment(fragment); - hydrating_node = /** @type {Node} */ ( + if (!fragment) { + // If fragment is null, then that means that the server rendered less items than what + // the client code specifies -> break out and continue with client-side node creation + break; + } + + item = is_proxied_array ? lazy_property(array, index) : array[index]; + block = each_item_block(item, null, index, render_fn, flags); + b_blocks[index] = block; + + hydrating_node = /** @type {import('./types.js').TemplateNode} */ ( /** @type {Node} */ (/** @type {Node} */ (fragment.at(-1)).nextSibling).nextSibling ); + } + + remove_excess_hydration_nodes(hydration_list, hydrating_node); + } + + for (; index < length; index++) { + if (index >= a) { + // Add block + item = is_proxied_array ? lazy_property(array, index) : array[index]; block = each_item_block(item, null, index, render_fn, flags); b_blocks[index] = block; + insert_each_item_block(block, dom, is_controlled, null); + } else if (index >= b) { + // Remove block + block = a_blocks[index]; + destroy_each_item_block(block, active_transitions, apply_transitions); + } else { + // Update block + item = array[index]; + block = a_blocks[index]; + b_blocks[index] = block; + update_each_item_block(block, item, index, flags); } - } else { - for (; index < length; index++) { - if (index >= a) { - // Add block - item = is_proxied_array ? lazy_property(array, index) : array[index]; - block = each_item_block(item, null, index, render_fn, flags); - b_blocks[index] = block; - insert_each_item_block(block, dom, is_controlled, null); - } else if (index >= b) { - // Remove block - block = a_blocks[index]; - destroy_each_item_block(block, active_transitions, apply_transitions); - } else { - // Update block - item = array[index]; - block = a_blocks[index]; - b_blocks[index] = block; - update_each_item_block(block, item, index, flags); - } - } + } + + if (is_hydrating && current_hydration_fragment === null) { + // Server rendered less nodes than the client -> set empty array so that Svelte continues to operate in hydration mode + set_current_hydration_fragment([]); } } each_block.v = b_blocks; } -// Reconcile arrays by the equality of the elements in the array. This algorithm -// is based on Ivi's reconcilation logic: -// -// https://github.com/localvoid/ivi/blob/9f1bd0918f487da5b131941228604763c5d8ef56/packages/ivi/src/client/core.ts#L968 -// /** + * Reconcile arrays by the equality of the elements in the array. This algorithm + * is based on Ivi's reconcilation logic: + * https://github.com/localvoid/ivi/blob/9f1bd0918f487da5b131941228604763c5d8ef56/packages/ivi/src/client/core.ts#L968 * @template V * @param {Array} array * @param {import('./types.js').EachBlock} each_block @@ -391,30 +434,43 @@ function reconcile_tracked_array( var key; var item; var idx; + var is_hydrating = current_hydration_fragment !== null; b_blocks = Array(b); - if (current_hydration_fragment !== null) { + if (is_hydrating) { + // Hydrate block var fragment; - - /** @type {Node} */ - var hydrating_node = current_hydration_fragment[0]; + var hydration_list = /** @type {import('./types.js').TemplateNode[]} */ ( + current_hydration_fragment + ); + var hydrating_node = hydration_list[0]; while (b > 0) { - // Hydrate block - idx = b_end - --b; - item = array[idx]; - key = is_computed_key ? keys[idx] : item; fragment = /** @type {Array} */ ( get_hydration_fragment(hydrating_node) ); set_current_hydration_fragment(fragment); + if (!fragment) { + // If fragment is null, then that means that the server rendered less items than what + // the client code specifies -> break out and continue with client-side node creation + break; + } + + idx = b_end - --b; + item = array[idx]; + key = is_computed_key ? keys[idx] : item; + block = each_item_block(item, key, idx, render_fn, flags); + b_blocks[idx] = block; + // Get the tag of the next item in the list // The fragment array can be empty if each block has no content - hydrating_node = /** @type {Node} */ ( + hydrating_node = /** @type {import('./types.js').TemplateNode} */ ( /** @type {Node} */ ((fragment.at(-1) || hydrating_node).nextSibling).nextSibling ); - block = each_item_block(item, key, idx, render_fn, flags); - b_blocks[idx] = block; } - } else if (a === 0) { + + remove_excess_hydration_nodes(hydration_list, hydrating_node); + } + + if (a === 0) { // Create new blocks while (b > 0) { idx = b_end - --b; @@ -546,11 +602,30 @@ function reconcile_tracked_array( } } } + + if (is_hydrating && current_hydration_fragment === null) { + // Server rendered less nodes than the client -> set empty array so that Svelte continues to operate in hydration mode + set_current_hydration_fragment([]); + } } each_block.v = b_blocks; } +/** + * The server could have rendered more list items than the client specifies. + * In that case, we need to remove the remaining server-rendered nodes. + * @param {import('./types.js').TemplateNode[]} hydration_list + * @param {import('./types.js').TemplateNode | null} next_node + */ +function remove_excess_hydration_nodes(hydration_list, next_node) { + if (next_node === null) return; + var idx = hydration_list.indexOf(next_node); + if (idx !== -1 && hydration_list.length > idx + 1) { + remove(hydration_list.slice(idx)); + } +} + /** * Longest Increased Subsequence algorithm * @param {Int32Array} a diff --git a/packages/svelte/src/internal/client/hydration.js b/packages/svelte/src/internal/client/hydration.js index e7643a57cc..cd2e84e4a0 100644 --- a/packages/svelte/src/internal/client/hydration.js +++ b/packages/svelte/src/internal/client/hydration.js @@ -2,11 +2,11 @@ import { schedule_task } from './runtime.js'; -/** @type {null | Array} */ +/** @type {null | Array} */ export let current_hydration_fragment = null; /** - * @param {null | Array} fragment + * @param {null | Array} fragment * @returns {void} */ export function set_current_hydration_fragment(fragment) { @@ -16,10 +16,10 @@ export function set_current_hydration_fragment(fragment) { /** * Returns all nodes between the first `` comment tag pair encountered. * @param {Node | null} node - * @returns {Array | null} + * @returns {Array | null} */ export function get_hydration_fragment(node) { - /** @type {Array} */ + /** @type {Array} */ const fragment = []; /** @type {null | Node} */ diff --git a/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_after.html b/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_after.html new file mode 100644 index 0000000000..fcfd9fe9ef --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_after.html @@ -0,0 +1,2 @@ +

a

+

empty

diff --git a/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_before.html b/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_before.html new file mode 100644 index 0000000000..215e1e3c8e --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_before.html @@ -0,0 +1,2 @@ +

empty

+

a

diff --git a/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_config.js b/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_config.js new file mode 100644 index 0000000000..f47bee71df --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/main.svelte b/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/main.svelte new file mode 100644 index 0000000000..835f726435 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-fallback-mismatch/main.svelte @@ -0,0 +1,16 @@ + + +{#each items1 as item} +

{item.name}

+{:else} +

empty

+{/each} + +{#each items2 as item} +

{item.name}

+{:else} +

empty

+{/each} diff --git a/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_after.html b/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_after.html new file mode 100644 index 0000000000..5de1a42d70 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_after.html @@ -0,0 +1,9 @@ +
  • a
+
  • a
+
  • a
+
  • a
  • +
  • a
  • +
  • a
  • +
  • a
  • +
  • a
  • +
  • a
  • diff --git a/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_before.html b/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_before.html new file mode 100644 index 0000000000..f480ea096d --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_before.html @@ -0,0 +1,12 @@ +
    • a
    • b
    +
    • a
    • b
    +
    • a
    • b
    +
  • a
  • +
  • a
  • b
  • +
  • b
  • +
  • a
  • +
  • a
  • b
  • +
  • b
  • +
  • a
  • +
  • a
  • b
  • +
  • b
  • diff --git a/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_config.js b/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_config.js new file mode 100644 index 0000000000..5b0adcfd86 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/_config.js @@ -0,0 +1,15 @@ +import { assert_ok, test } from '../../test'; + +export default test({ + snapshot(target) { + const ul = target.querySelector('ul'); + assert_ok(ul); + const lis = ul.querySelector('li'); + assert_ok(lis); + + return { + ul, + lis + }; + } +}); diff --git a/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/main.svelte b/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/main.svelte new file mode 100644 index 0000000000..e2ff0750e3 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-less-nodes-on-client/main.svelte @@ -0,0 +1,32 @@ + + +
      + {#each items as item} +
    • {item.name}
    • + {/each} +
    +
      + {#each items as item (item)} +
    • {item.name}
    • + {/each} +
    +
      + {#each items as item (item.name)} +
    • {item.name}
    • + {/each} +
    + +{#each items as item} +
  • {item.name}
  • +
  • {item.name}
  • +{/each} +{#each items as item (item)} +
  • {item.name}
  • +
  • {item.name}
  • +{/each} +{#each items as item (item.name)} +
  • {item.name}
  • +
  • {item.name}
  • +{/each} diff --git a/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_after.html b/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_after.html new file mode 100644 index 0000000000..453cee352b --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_after.html @@ -0,0 +1,9 @@ +
    • a
    • b
    +
    • a
    • b
    +
    • a
    • b
    +
  • a
  • +
  • a
  • b
  • b
  • +
  • a
  • +
  • a
  • b
  • b
  • +
  • a
  • +
  • a
  • b
  • b
  • diff --git a/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_before.html b/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_before.html new file mode 100644 index 0000000000..7c3233df17 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_before.html @@ -0,0 +1,9 @@ +
    • a
    +
    • a
    +
    • a
    +
  • a
  • +
  • a
  • +
  • a
  • +
  • a
  • +
  • a
  • +
  • a
  • diff --git a/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_config.js b/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_config.js new file mode 100644 index 0000000000..5b0adcfd86 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/_config.js @@ -0,0 +1,15 @@ +import { assert_ok, test } from '../../test'; + +export default test({ + snapshot(target) { + const ul = target.querySelector('ul'); + assert_ok(ul); + const lis = ul.querySelector('li'); + assert_ok(lis); + + return { + ul, + lis + }; + } +}); diff --git a/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/main.svelte b/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/main.svelte new file mode 100644 index 0000000000..7df2b52e2e --- /dev/null +++ b/packages/svelte/tests/hydration/samples/each-block-more-nodes-on-client/main.svelte @@ -0,0 +1,32 @@ + + +
      + {#each items as item} +
    • {item.name}
    • + {/each} +
    +
      + {#each items as item (item)} +
    • {item.name}
    • + {/each} +
    +
      + {#each items as item (item.name)} +
    • {item.name}
    • + {/each} +
    + +{#each items as item} +
  • {item.name}
  • +
  • {item.name}
  • +{/each} +{#each items as item (item)} +
  • {item.name}
  • +
  • {item.name}
  • +{/each} +{#each items as item (item.name)} +
  • {item.name}
  • +
  • {item.name}
  • +{/each}