fix: repair each block length mismatches during hydration (#10398)

fixes #10332
pull/10399/head
Simon H 2 years ago committed by GitHub
parent d23805a6f0
commit b6fcc149b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: repair each block length mismatches during hydration

@ -1340,12 +1340,16 @@ const template_visitors = {
b.block(each) b.block(each)
); );
if (node.fallback) { 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( state.template.push(
t_statement( t_statement(
b.if( b.if(
b.binary('!==', b.member(array_id, b.id('length')), b.literal(0)), b.binary('!==', b.member(array_id, b.id('length')), b.literal(0)),
for_loop, for_loop,
b.block(create_block(node, node.fallback.nodes, context)) b.block(fallback_stmts)
) )
) )
); );

@ -64,6 +64,10 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re
/** @type {null | import('./types.js').EffectSignal} */ /** @type {null | import('./types.js').EffectSignal} */
let render = null; 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 = block.r =
/** @param {import('./types.js').Transition} transition */ /** @param {import('./types.js').Transition} transition */
(transition) => { (transition) => {
@ -144,12 +148,30 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re
: maybe_array == null : maybe_array == null
? [] ? []
: Array.from(maybe_array); : Array.from(maybe_array);
if (key_fn !== null) { if (key_fn !== null) {
keys = array.map(key_fn); keys = array.map(key_fn);
} else if ((flags & EACH_KEYED) === 0) { } else if ((flags & EACH_KEYED) === 0) {
array.map(no_op); array.map(no_op);
} }
const length = array.length; 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 (fallback_fn !== null) {
if (length === 0) { if (length === 0) {
if (block.v.length !== 0 || render === null) { 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) { if (render !== null) {
execute_effect(render); 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); 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, () => { push_destroy_fn(each, () => {
const flags = block.f; const flags = block.f;
const anchor_node = block.a; const anchor_node = block.a;
@ -287,55 +315,70 @@ function reconcile_indexed_array(
} }
} else { } else {
var item; var item;
var is_hydrating = current_hydration_fragment !== null;
b_blocks = Array(b); b_blocks = Array(b);
if (current_hydration_fragment !== null) { if (is_hydrating) {
/** @type {Node} */ // Hydrate block
var hydrating_node = current_hydration_fragment[0]; var hydration_list = /** @type {import('./types.js').TemplateNode[]} */ (
current_hydration_fragment
);
var hydrating_node = hydration_list[0];
for (; index < length; index++) { for (; index < length; index++) {
// Hydrate block
item = is_proxied_array ? lazy_property(array, index) : array[index];
var fragment = /** @type {Array<Text | Comment | Element>} */ ( var fragment = /** @type {Array<Text | Comment | Element>} */ (
get_hydration_fragment(hydrating_node) get_hydration_fragment(hydrating_node)
); );
set_current_hydration_fragment(fragment); 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 /** @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); block = each_item_block(item, null, index, render_fn, flags);
b_blocks[index] = block; 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) { if (is_hydrating && current_hydration_fragment === null) {
// Add block // Server rendered less nodes than the client -> set empty array so that Svelte continues to operate in hydration mode
item = is_proxied_array ? lazy_property(array, index) : array[index]; set_current_hydration_fragment([]);
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);
}
}
} }
} }
each_block.v = b_blocks; 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 * @template V
* @param {Array<V>} array * @param {Array<V>} array
* @param {import('./types.js').EachBlock} each_block * @param {import('./types.js').EachBlock} each_block
@ -391,30 +434,43 @@ function reconcile_tracked_array(
var key; var key;
var item; var item;
var idx; var idx;
var is_hydrating = current_hydration_fragment !== null;
b_blocks = Array(b); b_blocks = Array(b);
if (current_hydration_fragment !== null) { if (is_hydrating) {
// Hydrate block
var fragment; var fragment;
var hydration_list = /** @type {import('./types.js').TemplateNode[]} */ (
/** @type {Node} */ current_hydration_fragment
var hydrating_node = current_hydration_fragment[0]; );
var hydrating_node = hydration_list[0];
while (b > 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>} */ ( fragment = /** @type {Array<Text | Comment | Element>} */ (
get_hydration_fragment(hydrating_node) get_hydration_fragment(hydrating_node)
); );
set_current_hydration_fragment(fragment); 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 // Get the <!--ssr:..--> tag of the next item in the list
// The fragment array can be empty if each block has no content // 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 /** @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 // Create new blocks
while (b > 0) { while (b > 0) {
idx = b_end - --b; 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; 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 * Longest Increased Subsequence algorithm
* @param {Int32Array} a * @param {Int32Array} a

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

@ -0,0 +1,2 @@
<!--ssr:0--><!--ssr:1--><p>a</p><!--ssr:1-->
<!--ssr:2--><p>empty</p><!--ssr:2--><!--ssr:0-->

@ -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-->

@ -0,0 +1,3 @@
import { test } from '../../test';
export default test({});

@ -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}

@ -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>

@ -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>

@ -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
};
}
});

@ -0,0 +1,32 @@
<script>
let items = $state(typeof window !== 'undefined' ? [{name: 'a'}]: [{name: 'a'}, {name: 'b'}]);
</script>
<ul>
{#each items as item}
<li>{item.name}</li>
{/each}
</ul>
<ul>
{#each items as item (item)}
<li>{item.name}</li>
{/each}
</ul>
<ul>
{#each items as item (item.name)}
<li>{item.name}</li>
{/each}
</ul>
{#each items as item}
<li>{item.name}</li>
<li>{item.name}</li>
{/each}
{#each items as item (item)}
<li>{item.name}</li>
<li>{item.name}</li>
{/each}
{#each items as item (item.name)}
<li>{item.name}</li>
<li>{item.name}</li>
{/each}

@ -0,0 +1,9 @@
<!--ssr:0--><ul><!--ssr:1--><!--ssr:7--><li>a</li><!--ssr:7--><li>b</li><!--ssr:1--></ul>
<ul><!--ssr:2--><!--ssr:8--><li>a</li><!--ssr:8--><li>b</li><!--ssr:2--></ul>
<ul><!--ssr:3--><!--ssr:9--><li>a</li><!--ssr:9--><li>b</li><!--ssr:3--></ul>
<!--ssr:4--><!--ssr:10--><li>a</li>
<li>a</li><!--ssr:10--><li>b</li><li>b</li><!--ssr:4-->
<!--ssr:5--><!--ssr:11--><li>a</li>
<li>a</li><!--ssr:11--><li>b</li><li>b</li><!--ssr:5-->
<!--ssr:6--><!--ssr:12--><li>a</li>
<li>a</li><!--ssr:12--><li>b</li><li>b</li><!--ssr:6--><!--ssr:0--></div>

@ -0,0 +1,9 @@
<!--ssr:0--><ul><!--ssr:1--><!--ssr:7--><li>a</li><!--ssr:7--><!--ssr:1--></ul>
<ul><!--ssr:2--><!--ssr:8--><li>a</li><!--ssr:8--><!--ssr:2--></ul>
<ul><!--ssr:3--><!--ssr:9--><li>a</li><!--ssr:9--><!--ssr:3--></ul>
<!--ssr:4--><!--ssr:10--><li>a</li>
<li>a</li><!--ssr:10--><!--ssr:4-->
<!--ssr:5--><!--ssr:11--><li>a</li>
<li>a</li><!--ssr:11--><!--ssr:5-->
<!--ssr:6--><!--ssr:12--><li>a</li>
<li>a</li><!--ssr:12--><!--ssr:6--><!--ssr:0--></div>

@ -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
};
}
});

@ -0,0 +1,32 @@
<script>
let items = $state(typeof window === 'undefined' ? [{name: 'x'}]: [{name: 'a'}, {name: 'b'}]);
</script>
<ul>
{#each items as item}
<li>{item.name}</li>
{/each}
</ul>
<ul>
{#each items as item (item)}
<li>{item.name}</li>
{/each}
</ul>
<ul>
{#each items as item (item.name)}
<li>{item.name}</li>
{/each}
</ul>
{#each items as item}
<li>{item.name}</li>
<li>{item.name}</li>
{/each}
{#each items as item (item)}
<li>{item.name}</li>
<li>{item.name}</li>
{/each}
{#each items as item (item.name)}
<li>{item.name}</li>
<li>{item.name}</li>
{/each}
Loading…
Cancel
Save