Skip to content

Commit b6fcc14

Browse files
authored
fix: repair each block length mismatches during hydration (#10398)
fixes #10332
1 parent d23805a commit b6fcc14

File tree

16 files changed

+288
-48
lines changed

16 files changed

+288
-48
lines changed

.changeset/olive-socks-kick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
fix: repair each block length mismatches during hydration

packages/svelte/src/compiler/phases/3-transform/server/transform-server.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1340,12 +1340,16 @@ const template_visitors = {
13401340
b.block(each)
13411341
);
13421342
if (node.fallback) {
1343+
const fallback_stmts = create_block(node, node.fallback.nodes, context);
1344+
fallback_stmts.unshift(
1345+
b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal('<!--ssr:each_else-->')))
1346+
);
13431347
state.template.push(
13441348
t_statement(
13451349
b.if(
13461350
b.binary('!==', b.member(array_id, b.id('length')), b.literal(0)),
13471351
for_loop,
1348-
b.block(create_block(node, node.fallback.nodes, context))
1352+
b.block(fallback_stmts)
13491353
)
13501354
)
13511355
);

packages/svelte/src/internal/client/each.js

Lines changed: 118 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re
6464

6565
/** @type {null | import('./types.js').EffectSignal} */
6666
let render = null;
67+
68+
/** Whether or not there was a "rendered fallback but want to render items" (or vice versa) hydration mismatch */
69+
let mismatch = false;
70+
6771
block.r =
6872
/** @param {import('./types.js').Transition} transition */
6973
(transition) => {
@@ -144,12 +148,30 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re
144148
: maybe_array == null
145149
? []
146150
: Array.from(maybe_array);
151+
147152
if (key_fn !== null) {
148153
keys = array.map(key_fn);
149154
} else if ((flags & EACH_KEYED) === 0) {
150155
array.map(no_op);
151156
}
157+
152158
const length = array.length;
159+
160+
if (current_hydration_fragment !== null) {
161+
const is_each_else_comment =
162+
/** @type {Comment} */ (current_hydration_fragment?.[0])?.data === 'ssr:each_else';
163+
// Check for hydration mismatch which can happen if the server renders the each fallback
164+
// but the client has items, or vice versa. If so, remove everything inside the anchor and start fresh.
165+
if ((is_each_else_comment && length) || (!is_each_else_comment && !length)) {
166+
remove(/** @type {import('./types.js').TemplateNode[]} */ (current_hydration_fragment));
167+
set_current_hydration_fragment(null);
168+
mismatch = true;
169+
} else if (is_each_else_comment) {
170+
// Remove the each_else comment node or else it will confuse the subsequent hydration algorithm
171+
/** @type {import('./types.js').TemplateNode[]} */ (current_hydration_fragment).shift();
172+
}
173+
}
174+
153175
if (fallback_fn !== null) {
154176
if (length === 0) {
155177
if (block.v.length !== 0 || render === null) {
@@ -170,6 +192,7 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re
170192
}
171193
}
172194
}
195+
173196
if (render !== null) {
174197
execute_effect(render);
175198
}
@@ -180,6 +203,11 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re
180203

181204
render = render_effect(clear_each, block, true);
182205

206+
if (mismatch) {
207+
// Set a fragment so that Svelte continues to operate in hydration mode
208+
set_current_hydration_fragment([]);
209+
}
210+
183211
push_destroy_fn(each, () => {
184212
const flags = block.f;
185213
const anchor_node = block.a;
@@ -287,55 +315,70 @@ function reconcile_indexed_array(
287315
}
288316
} else {
289317
var item;
318+
var is_hydrating = current_hydration_fragment !== null;
290319
b_blocks = Array(b);
291-
if (current_hydration_fragment !== null) {
292-
/** @type {Node} */
293-
var hydrating_node = current_hydration_fragment[0];
320+
if (is_hydrating) {
321+
// Hydrate block
322+
var hydration_list = /** @type {import('./types.js').TemplateNode[]} */ (
323+
current_hydration_fragment
324+
);
325+
var hydrating_node = hydration_list[0];
294326
for (; index < length; index++) {
295-
// Hydrate block
296-
item = is_proxied_array ? lazy_property(array, index) : array[index];
297327
var fragment = /** @type {Array<Text | Comment | Element>} */ (
298328
get_hydration_fragment(hydrating_node)
299329
);
300330
set_current_hydration_fragment(fragment);
301-
hydrating_node = /** @type {Node} */ (
331+
if (!fragment) {
332+
// If fragment is null, then that means that the server rendered less items than what
333+
// the client code specifies -> break out and continue with client-side node creation
334+
break;
335+
}
336+
337+
item = is_proxied_array ? lazy_property(array, index) : array[index];
338+
block = each_item_block(item, null, index, render_fn, flags);
339+
b_blocks[index] = block;
340+
341+
hydrating_node = /** @type {import('./types.js').TemplateNode} */ (
302342
/** @type {Node} */ (/** @type {Node} */ (fragment.at(-1)).nextSibling).nextSibling
303343
);
344+
}
345+
346+
remove_excess_hydration_nodes(hydration_list, hydrating_node);
347+
}
348+
349+
for (; index < length; index++) {
350+
if (index >= a) {
351+
// Add block
352+
item = is_proxied_array ? lazy_property(array, index) : array[index];
304353
block = each_item_block(item, null, index, render_fn, flags);
305354
b_blocks[index] = block;
355+
insert_each_item_block(block, dom, is_controlled, null);
356+
} else if (index >= b) {
357+
// Remove block
358+
block = a_blocks[index];
359+
destroy_each_item_block(block, active_transitions, apply_transitions);
360+
} else {
361+
// Update block
362+
item = array[index];
363+
block = a_blocks[index];
364+
b_blocks[index] = block;
365+
update_each_item_block(block, item, index, flags);
306366
}
307-
} else {
308-
for (; index < length; index++) {
309-
if (index >= a) {
310-
// Add block
311-
item = is_proxied_array ? lazy_property(array, index) : array[index];
312-
block = each_item_block(item, null, index, render_fn, flags);
313-
b_blocks[index] = block;
314-
insert_each_item_block(block, dom, is_controlled, null);
315-
} else if (index >= b) {
316-
// Remove block
317-
block = a_blocks[index];
318-
destroy_each_item_block(block, active_transitions, apply_transitions);
319-
} else {
320-
// Update block
321-
item = array[index];
322-
block = a_blocks[index];
323-
b_blocks[index] = block;
324-
update_each_item_block(block, item, index, flags);
325-
}
326-
}
367+
}
368+
369+
if (is_hydrating && current_hydration_fragment === null) {
370+
// Server rendered less nodes than the client -> set empty array so that Svelte continues to operate in hydration mode
371+
set_current_hydration_fragment([]);
327372
}
328373
}
329374

330375
each_block.v = b_blocks;
331376
}
332-
// Reconcile arrays by the equality of the elements in the array. This algorithm
333-
// is based on Ivi's reconcilation logic:
334-
//
335-
// https://github.com/localvoid/ivi/blob/9f1bd0918f487da5b131941228604763c5d8ef56/packages/ivi/src/client/core.ts#L968
336-
//
337377

338378
/**
379+
* Reconcile arrays by the equality of the elements in the array. This algorithm
380+
* is based on Ivi's reconcilation logic:
381+
* https://github.com/localvoid/ivi/blob/9f1bd0918f487da5b131941228604763c5d8ef56/packages/ivi/src/client/core.ts#L968
339382
* @template V
340383
* @param {Array<V>} array
341384
* @param {import('./types.js').EachBlock} each_block
@@ -391,30 +434,43 @@ function reconcile_tracked_array(
391434
var key;
392435
var item;
393436
var idx;
437+
var is_hydrating = current_hydration_fragment !== null;
394438
b_blocks = Array(b);
395-
if (current_hydration_fragment !== null) {
439+
if (is_hydrating) {
440+
// Hydrate block
396441
var fragment;
397-
398-
/** @type {Node} */
399-
var hydrating_node = current_hydration_fragment[0];
442+
var hydration_list = /** @type {import('./types.js').TemplateNode[]} */ (
443+
current_hydration_fragment
444+
);
445+
var hydrating_node = hydration_list[0];
400446
while (b > 0) {
401-
// Hydrate block
402-
idx = b_end - --b;
403-
item = array[idx];
404-
key = is_computed_key ? keys[idx] : item;
405447
fragment = /** @type {Array<Text | Comment | Element>} */ (
406448
get_hydration_fragment(hydrating_node)
407449
);
408450
set_current_hydration_fragment(fragment);
451+
if (!fragment) {
452+
// If fragment is null, then that means that the server rendered less items than what
453+
// the client code specifies -> break out and continue with client-side node creation
454+
break;
455+
}
456+
457+
idx = b_end - --b;
458+
item = array[idx];
459+
key = is_computed_key ? keys[idx] : item;
460+
block = each_item_block(item, key, idx, render_fn, flags);
461+
b_blocks[idx] = block;
462+
409463
// Get the <!--ssr:..--> tag of the next item in the list
410464
// The fragment array can be empty if each block has no content
411-
hydrating_node = /** @type {Node} */ (
465+
hydrating_node = /** @type {import('./types.js').TemplateNode} */ (
412466
/** @type {Node} */ ((fragment.at(-1) || hydrating_node).nextSibling).nextSibling
413467
);
414-
block = each_item_block(item, key, idx, render_fn, flags);
415-
b_blocks[idx] = block;
416468
}
417-
} else if (a === 0) {
469+
470+
remove_excess_hydration_nodes(hydration_list, hydrating_node);
471+
}
472+
473+
if (a === 0) {
418474
// Create new blocks
419475
while (b > 0) {
420476
idx = b_end - --b;
@@ -546,11 +602,30 @@ function reconcile_tracked_array(
546602
}
547603
}
548604
}
605+
606+
if (is_hydrating && current_hydration_fragment === null) {
607+
// Server rendered less nodes than the client -> set empty array so that Svelte continues to operate in hydration mode
608+
set_current_hydration_fragment([]);
609+
}
549610
}
550611

551612
each_block.v = b_blocks;
552613
}
553614

615+
/**
616+
* The server could have rendered more list items than the client specifies.
617+
* In that case, we need to remove the remaining server-rendered nodes.
618+
* @param {import('./types.js').TemplateNode[]} hydration_list
619+
* @param {import('./types.js').TemplateNode | null} next_node
620+
*/
621+
function remove_excess_hydration_nodes(hydration_list, next_node) {
622+
if (next_node === null) return;
623+
var idx = hydration_list.indexOf(next_node);
624+
if (idx !== -1 && hydration_list.length > idx + 1) {
625+
remove(hydration_list.slice(idx));
626+
}
627+
}
628+
554629
/**
555630
* Longest Increased Subsequence algorithm
556631
* @param {Int32Array} a

packages/svelte/src/internal/client/hydration.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

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

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

88
/**
9-
* @param {null | Array<Text | Comment | Element>} fragment
9+
* @param {null | Array<import('./types.js').TemplateNode>} fragment
1010
* @returns {void}
1111
*/
1212
export function set_current_hydration_fragment(fragment) {
@@ -16,10 +16,10 @@ export function set_current_hydration_fragment(fragment) {
1616
/**
1717
* Returns all nodes between the first `<!--ssr:...-->` comment tag pair encountered.
1818
* @param {Node | null} node
19-
* @returns {Array<Text | Comment | Element> | null}
19+
* @returns {Array<import('./types.js').TemplateNode> | null}
2020
*/
2121
export function get_hydration_fragment(node) {
22-
/** @type {Array<Text | Comment | Element>} */
22+
/** @type {Array<import('./types.js').TemplateNode>} */
2323
const fragment = [];
2424

2525
/** @type {null | Node} */
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<!--ssr:0--><!--ssr:1--><p>a</p><!--ssr:1-->
2+
<!--ssr:2--><p>empty</p><!--ssr:2--><!--ssr:0-->
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<!--ssr:0--><!--ssr:1--><!--ssr:each_else--><p>empty</p><!--ssr:1-->
2+
<!--ssr:2--><!--ssr:3--><p>a</p><!--ssr:3--><!--ssr:2--><!--ssr:0-->
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { test } from '../../test';
2+
3+
export default test({});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script>
2+
let items1 = $state(typeof window !== 'undefined' ? [{name: 'a'}]: []);
3+
let items2 = $state(typeof window === 'undefined' ? [{name: 'a'}]: []);
4+
</script>
5+
6+
{#each items1 as item}
7+
<p>{item.name}</p>
8+
{:else}
9+
<p>empty</p>
10+
{/each}
11+
12+
{#each items2 as item}
13+
<p>{item.name}</p>
14+
{:else}
15+
<p>empty</p>
16+
{/each}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!--ssr:0--><ul><!--ssr:1--><!--ssr:7--><li>a</li><!--ssr:7--><!--ssr:1--></ul>
2+
<ul><!--ssr:2--><!--ssr:9--><li>a</li><!--ssr:9--><!--ssr:2--></ul>
3+
<ul><!--ssr:3--><!--ssr:11--><li>a</li><!--ssr:11--><!--ssr:3--></ul>
4+
<!--ssr:4--><!--ssr:13--><li>a</li>
5+
<li>a</li><!--ssr:13--><!--ssr:4-->
6+
<!--ssr:5--><!--ssr:15--><li>a</li>
7+
<li>a</li><!--ssr:15--><!--ssr:5-->
8+
<!--ssr:6--><!--ssr:17--><li>a</li>
9+
<li>a</li><!--ssr:17--><!--ssr:6--><!--ssr:0--></div>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!--ssr:0--><ul><!--ssr:1--><!--ssr:7--><li>a</li><!--ssr:7--><!--ssr:8--><li>b</li><!--ssr:8--><!--ssr:1--></ul>
2+
<ul><!--ssr:2--><!--ssr:9--><li>a</li><!--ssr:9--><!--ssr:10--><li>b</li><!--ssr:10--><!--ssr:2--></ul>
3+
<ul><!--ssr:3--><!--ssr:11--><li>a</li><!--ssr:11--><!--ssr:12--><li>b</li><!--ssr:12--><!--ssr:3--></ul>
4+
<!--ssr:4--><!--ssr:13--><li>a</li>
5+
<li>a</li><!--ssr:13--><!--ssr:14--><li>b</li>
6+
<li>b</li><!--ssr:14--><!--ssr:4-->
7+
<!--ssr:5--><!--ssr:15--><li>a</li>
8+
<li>a</li><!--ssr:15--><!--ssr:16--><li>b</li>
9+
<li>b</li><!--ssr:16--><!--ssr:5-->
10+
<!--ssr:6--><!--ssr:17--><li>a</li>
11+
<li>a</li><!--ssr:17--><!--ssr:18--><li>b</li>
12+
<li>b</li><!--ssr:18--><!--ssr:6--><!--ssr:0--></div>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { assert_ok, test } from '../../test';
2+
3+
export default test({
4+
snapshot(target) {
5+
const ul = target.querySelector('ul');
6+
assert_ok(ul);
7+
const lis = ul.querySelector('li');
8+
assert_ok(lis);
9+
10+
return {
11+
ul,
12+
lis
13+
};
14+
}
15+
});

0 commit comments

Comments
 (0)