Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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/upset-eyes-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: maintain correct linked list of effects when updating each blocks
54 changes: 29 additions & 25 deletions packages/svelte/src/internal/client/dom/blocks/each.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,11 @@ function pause_effects(state, to_destroy, controlled_anchor) {
export function each(node, flags, get_collection, get_key, render_fn, fallback_fn = null) {
var anchor = node;

/** @type {EachState} */
var state = { flags, items: new Map(), first: null };
/** @type {Map<any, EachItem>} */
var items = new Map();

/** @type {EachItem | null} */
var first = null;

var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0;
var is_reactive_value = (flags & EACH_ITEM_REACTIVE) !== 0;
Expand Down Expand Up @@ -166,7 +169,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var first_run = true;

function commit() {
reconcile(each_effect, array, state, anchor, flags, get_key);
reconcile(state, array, anchor, flags, get_key);

if (fallback !== null) {
if (array.length === 0) {
Expand All @@ -177,7 +180,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
resume_effect(fallback.effect);
}

each_effect.first = fallback.effect;
effect.first = fallback.effect;
} else {
pause_effect(fallback.effect, () => {
// TODO only null out if no pending batch needs it,
Expand All @@ -189,7 +192,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
}
}

var each_effect = block(() => {
var effect = block(() => {
array = /** @type {V[]} */ (get(each_array));
var length = array.length;

Expand Down Expand Up @@ -230,7 +233,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var value = array[i];
var key = get_key(value, i);

var item = first_run ? null : state.items.get(key);
var item = first_run ? null : items.get(key);

if (item) {
// update before reconciliation, to trigger any async updates
Expand Down Expand Up @@ -263,15 +266,15 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
item.o = true;

if (prev === null) {
state.first = item;
first = item;
} else {
prev.next = item;
}

prev = item;
}

state.items.set(key, item);
items.set(key, item);
}

keys.add(key);
Expand Down Expand Up @@ -302,7 +305,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f

if (!first_run) {
if (defer) {
for (const [key, item] of state.items) {
for (const [key, item] of items) {
if (!keys.has(key)) {
batch.skipped_effects.add(item.e);
}
Expand Down Expand Up @@ -331,6 +334,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
get(each_array);
});

/** @type {EachState} */
var state = { effect, flags, items, first };

first_run = false;

if (hydrating) {
Expand All @@ -341,15 +347,14 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
/**
* Add, remove, or reorder items output by an each block as its input changes
* @template V
* @param {Effect} each_effect
* @param {Array<V>} array
* @param {EachState} state
* @param {Array<V>} array
* @param {Element | Comment | Text} anchor
* @param {number} flags
* @param {(value: V, index: number) => any} get_key
* @returns {void}
*/
function reconcile(each_effect, array, state, anchor, flags, get_key) {
function reconcile(state, array, anchor, flags, get_key) {
var is_animated = (flags & EACH_IS_ANIMATED) !== 0;

var length = array.length;
Expand Down Expand Up @@ -536,16 +541,6 @@ function reconcile(each_effect, array, state, anchor, flags, get_key) {
}
});
}

// TODO i have an inkling that the rest of this function is wrong...
// the offscreen items need to be linked, so that they all update correctly.
// the last onscreen item should link to the first offscreen item, etc
each_effect.first = state.first && state.first.e;
each_effect.last = prev && prev.e;

if (prev) {
prev.e.next = null;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for clarification: This now keeps a link to the offscreen effects such that they are still connected precisely because prev.e.next is no longer set to null?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exactly — all effects are linked whether they're onscreen or offscreen, such that updates correctly flow through to offscreen effects as well as onscreen ones

}

/**
Expand Down Expand Up @@ -601,8 +596,6 @@ function create_item(anchor, prev, value, key, index, render_fn, flags, get_coll

item.e = branch(() => render_fn(/** @type {Node} */ (anchor), v, i, get_collection));

item.e.prev = prev && prev.e;

if (prev !== null) {
prev.next = item;
prev.e.next = item.e;
Expand Down Expand Up @@ -640,12 +633,23 @@ function move(item, next, anchor) {
function link(state, prev, next) {
if (prev === null) {
state.first = next;
state.effect.first = next && next.e;
} else {
if (prev.e.next) {
prev.e.next.prev = null;
}

prev.next = next;
prev.e.next = next && next.e;
}

if (next !== null) {
if (next === null) {
state.effect.last = prev && prev.e;
} else {
if (next.e.prev) {
next.e.prev.next = null;
}

next.prev = prev;
next.e.prev = prev && prev.e;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/internal/client/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export type TemplateNode = Text | Element | Comment;
export type Dom = TemplateNode | TemplateNode[];

export type EachState = {
/** the each block effect */
effect: Effect;
/** flags */
flags: number;
/** a key -> item lookup */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { tick } from 'svelte';
import { test } from '../../test';

export default test({
async test({ assert, target }) {
const [step_back, step_forward, jump_back, jump_forward] = target.querySelectorAll('button');
const [div] = target.querySelectorAll('div');

step_back.click();
await tick();

step_forward.click();
await tick();

step_forward.click();
await tick();

// if the effects get linked in a circle, we will never get here
assert.htmlEqual(div.innerHTML, '<p>5</p><p>6</p><p>7</p>');

jump_forward.click();
await tick();

step_forward.click();
await tick();

step_forward.click();
await tick();

assert.htmlEqual(div.innerHTML, '<p>12</p><p>13</p><p>14</p>');
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script lang="ts">
let items = $state([4, 5, 6]);
</script>

<button onclick={() => {
items = items.map((n) => n - 1);
}}>
step back
</button>

<button onclick={() => {
items = items.map((n) => n + 1);
}}>
step forward
</button>

<button onclick={() => {
items = items.map((n) => n - 5);
}}>
jump back
</button>

<button onclick={() => {
items = items.map((n) => n + 5);
}}>
jump forward
</button>

<div>
{#each items as item (item)}
<p>{item}</p>
{/each}
</div>
Loading