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/olive-socks-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

fix: repair each block length mismatches during hydration
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!--ssr:each_else-->')))
);
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)
)
)
);
Expand Down
161 changes: 118 additions & 43 deletions packages/svelte/src/internal/client/each.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand All @@ -170,6 +192,7 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re
}
}
}

if (render !== null) {
execute_effect(render);
}
Expand All @@ -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;
Expand Down Expand Up @@ -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<Text | Comment | Element>} */ (
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<V>} array
* @param {import('./types.js').EachBlock} each_block
Expand Down Expand Up @@ -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<Text | Comment | Element>} */ (
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 <!--ssr:..--> 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;
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions packages/svelte/src/internal/client/hydration.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

import { schedule_task } from './runtime.js';

/** @type {null | Array<Text | Comment | Element>} */
/** @type {null | Array<import('./types.js').TemplateNode>} */
export let current_hydration_fragment = null;

/**
* @param {null | Array<Text | Comment | Element>} fragment
* @param {null | Array<import('./types.js').TemplateNode>} fragment
* @returns {void}
*/
export function set_current_hydration_fragment(fragment) {
Expand All @@ -16,10 +16,10 @@ export function set_current_hydration_fragment(fragment) {
/**
* Returns all nodes between the first `<!--ssr:...-->` comment tag pair encountered.
* @param {Node | null} node
* @returns {Array<Text | Comment | Element> | null}
* @returns {Array<import('./types.js').TemplateNode> | null}
*/
export function get_hydration_fragment(node) {
/** @type {Array<Text | Comment | Element>} */
/** @type {Array<import('./types.js').TemplateNode>} */
const fragment = [];

/** @type {null | Node} */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!--ssr:0--><!--ssr:1--><p>a</p><!--ssr:1-->
<!--ssr:2--><p>empty</p><!--ssr:2--><!--ssr:0-->
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!--ssr:0--><!--ssr:1--><!--ssr:each_else--><p>empty</p><!--ssr:1-->
<!--ssr:2--><!--ssr:3--><p>a</p><!--ssr:3--><!--ssr:2--><!--ssr:0-->
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { test } from '../../test';

export default test({});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script>
let items1 = $state(typeof window !== 'undefined' ? [{name: 'a'}]: []);
let items2 = $state(typeof window === 'undefined' ? [{name: 'a'}]: []);
</script>

{#each items1 as item}
<p>{item.name}</p>
{:else}
<p>empty</p>
{/each}

{#each items2 as item}
<p>{item.name}</p>
{:else}
<p>empty</p>
{/each}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!--ssr:0--><ul><!--ssr:1--><!--ssr:7--><li>a</li><!--ssr:7--><!--ssr:1--></ul>
<ul><!--ssr:2--><!--ssr:9--><li>a</li><!--ssr:9--><!--ssr:2--></ul>
<ul><!--ssr:3--><!--ssr:11--><li>a</li><!--ssr:11--><!--ssr:3--></ul>
<!--ssr:4--><!--ssr:13--><li>a</li>
<li>a</li><!--ssr:13--><!--ssr:4-->
<!--ssr:5--><!--ssr:15--><li>a</li>
<li>a</li><!--ssr:15--><!--ssr:5-->
<!--ssr:6--><!--ssr:17--><li>a</li>
<li>a</li><!--ssr:17--><!--ssr:6--><!--ssr:0--></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!--ssr:0--><ul><!--ssr:1--><!--ssr:7--><li>a</li><!--ssr:7--><!--ssr:8--><li>b</li><!--ssr:8--><!--ssr:1--></ul>
<ul><!--ssr:2--><!--ssr:9--><li>a</li><!--ssr:9--><!--ssr:10--><li>b</li><!--ssr:10--><!--ssr:2--></ul>
<ul><!--ssr:3--><!--ssr:11--><li>a</li><!--ssr:11--><!--ssr:12--><li>b</li><!--ssr:12--><!--ssr:3--></ul>
<!--ssr:4--><!--ssr:13--><li>a</li>
<li>a</li><!--ssr:13--><!--ssr:14--><li>b</li>
<li>b</li><!--ssr:14--><!--ssr:4-->
<!--ssr:5--><!--ssr:15--><li>a</li>
<li>a</li><!--ssr:15--><!--ssr:16--><li>b</li>
<li>b</li><!--ssr:16--><!--ssr:5-->
<!--ssr:6--><!--ssr:17--><li>a</li>
<li>a</li><!--ssr:17--><!--ssr:18--><li>b</li>
<li>b</li><!--ssr:18--><!--ssr:6--><!--ssr:0--></div>
Original file line number Diff line number Diff line change
@@ -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
};
}
});
Loading