fix: destroy each items after siblings are resumed (#17258)

* fix: destroy each items after siblings are resumed

* changeset

* remove unused exports

* tidy up

* fix: correctly reconcile each blocks after outroing branches are resumed

* WIP

* add some logging

* fix

* remove item on destroy

* remove

* remove

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* tests passing

* tidy up

* tidy up

* note to self

* tidy up

* fix

* add stress test to repo
pull/17299/head
Rich Harris 3 days ago committed by GitHub
parent 3a3b7e8a3f
commit 190e64acd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: correctly reconcile each blocks after outroing branches are resumed

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: destroy each items after siblings are resumed

@ -40,6 +40,7 @@ export const EAGER_EFFECT = 1 << 17;
export const HEAD_EFFECT = 1 << 18;
export const EFFECT_PRESERVED = 1 << 19;
export const USER_EFFECT = 1 << 20;
export const EFFECT_OFFSCREEN = 1 << 25;
// Flags exclusive to deriveds
/**

@ -1,4 +1,4 @@
/** @import { EachItem, EachState, Effect, EffectNodes, MaybeSource, Source, TemplateNode, TransitionManager, Value } from '#client' */
/** @import { EachItem, EachOutroGroup, EachState, Effect, EffectNodes, MaybeSource, Source, TemplateNode, TransitionManager, Value } from '#client' */
/** @import { Batch } from '../../reactivity/batch.js'; */
import {
EACH_INDEX_REACTIVE,
@ -29,20 +29,22 @@ import {
block,
branch,
destroy_effect,
run_out_transitions,
pause_children,
pause_effect,
resume_effect
} from '../../reactivity/effects.js';
import { source, mutable_source, internal_set } from '../../reactivity/sources.js';
import { array_from, is_array } from '../../../shared/utils.js';
import { COMMENT_NODE, INERT } from '#client/constants';
import { COMMENT_NODE, EFFECT_OFFSCREEN, INERT } from '#client/constants';
import { queue_micro_task } from '../task.js';
import { get } from '../../runtime.js';
import { DEV } from 'esm-env';
import { derived_safe_equal } from '../../reactivity/deriveds.js';
import { current_batch } from '../../reactivity/batch.js';
// When making substantive changes to this file, validate them with the each block stress test:
// https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b
// This test also exists in this repo, as `packages/svelte/tests/manual/each-stress-test`
/**
* @param {any} _
* @param {number} i
@ -55,7 +57,7 @@ export function index(_, i) {
* Pause multiple effects simultaneously, and coordinate their
* subsequent destruction. Used in each blocks
* @param {EachState} state
* @param {EachItem[]} to_destroy
* @param {Effect[]} to_destroy
* @param {null | Node} controlled_anchor
*/
function pause_effects(state, to_destroy, controlled_anchor) {
@ -63,19 +65,44 @@ function pause_effects(state, to_destroy, controlled_anchor) {
var transitions = [];
var length = to_destroy.length;
/** @type {EachOutroGroup} */
var group;
var remaining = to_destroy.length;
for (var i = 0; i < length; i++) {
pause_children(to_destroy[i].e, transitions, true);
let effect = to_destroy[i];
pause_effect(
effect,
() => {
if (group) {
group.pending.delete(effect);
group.done.add(effect);
if (group.pending.size === 0) {
var groups = /** @type {Set<EachOutroGroup>} */ (state.outrogroups);
destroy_effects(array_from(group.done));
groups.delete(group);
if (groups.size === 0) {
state.outrogroups = null;
}
}
} else {
remaining -= 1;
}
},
false
);
}
run_out_transitions(transitions, () => {
if (remaining === 0) {
// If we're in a controlled each block (i.e. the block is the only child of an
// element), and we are removing all items, _and_ there are no out transitions,
// we can use the fast path — emptying the element and replacing the anchor
var fast_path = transitions.length === 0 && controlled_anchor !== null;
// TODO only destroy effects if no pending batch needs them. otherwise,
// just set `item.o` back to `false`
if (fast_path) {
var anchor = /** @type {Element} */ (controlled_anchor);
var parent_node = /** @type {Element} */ (anchor.parentNode);
@ -84,26 +111,34 @@ function pause_effects(state, to_destroy, controlled_anchor) {
parent_node.append(anchor);
state.items.clear();
link(state, to_destroy[0].prev, to_destroy[length - 1].next);
}
for (var i = 0; i < length; i++) {
var item = to_destroy[i];
destroy_effects(to_destroy, !fast_path);
} else {
group = {
pending: new Set(to_destroy),
done: new Set()
};
if (!fast_path) {
state.items.delete(item.k);
link(state, item.prev, item.next);
(state.outrogroups ??= new Set()).add(group);
}
destroy_effect(item.e, !fast_path);
}
if (state.first === to_destroy[0]) {
state.first = to_destroy[0].prev;
/**
* @param {Effect[]} to_destroy
* @param {boolean} remove_dom
*/
function destroy_effects(to_destroy, remove_dom = true) {
// TODO only destroy effects if no pending batch needs them. otherwise,
// just re-add the `EFFECT_OFFSCREEN` flag
for (var i = 0; i < to_destroy.length; i++) {
destroy_effect(to_destroy[i], remove_dom);
}
});
}
/** @type {TemplateNode} */
var offscreen_anchor;
/**
* @template V
* @param {Element | Comment} node The next sibling node, or the parent node if this is a 'controlled' block
@ -120,12 +155,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
/** @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;
var is_reactive_index = (flags & EACH_INDEX_REACTIVE) !== 0;
if (is_controlled) {
var parent_node = /** @type {Element} */ (node);
@ -139,7 +169,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
hydrate_next();
}
/** @type {{ fragment: DocumentFragment | null, effect: Effect } | null} */
/** @type {Effect | null} */
var fallback = null;
// TODO: ideally we could use derived for runes mode but because of the ability
@ -157,20 +187,19 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var first_run = true;
function commit() {
state.fallback = fallback;
reconcile(state, array, anchor, flags, get_key);
if (fallback !== null) {
if (array.length === 0) {
if (fallback.fragment) {
anchor.before(fallback.fragment);
fallback.fragment = null;
if ((fallback.f & EFFECT_OFFSCREEN) === 0) {
resume_effect(fallback);
} else {
resume_effect(fallback.effect);
fallback.f ^= EFFECT_OFFSCREEN;
move(fallback, null, anchor);
}
effect.first = fallback.effect;
} else {
pause_effect(fallback.effect, () => {
pause_effect(fallback, () => {
// TODO only null out if no pending batch needs it,
// otherwise re-add `fallback.fragment` and move the
// effect into it
@ -202,10 +231,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var keys = new Set();
var batch = /** @type {Batch} */ (current_batch);
var prev = null;
var defer = should_defer_append();
for (var i = 0; i < length; i += 1) {
for (var index = 0; index < length; index += 1) {
if (
hydrating &&
hydrate_node.nodeType === COMMENT_NODE &&
@ -218,46 +246,33 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
set_hydrating(false);
}
var value = array[i];
var key = get_key(value, i);
var value = array[index];
var key = get_key(value, index);
var item = first_run ? null : items.get(key);
if (item) {
// update before reconciliation, to trigger any async updates
if (is_reactive_value) {
internal_set(item.v, value);
}
if (is_reactive_index) {
internal_set(/** @type {Value<number>} */ (item.i), i);
}
if (item.v) internal_set(item.v, value);
if (item.i) internal_set(item.i, index);
if (defer) {
batch.skipped_effects.delete(item.e);
}
} else {
item = create_item(
first_run ? anchor : null,
prev,
items,
first_run ? anchor : (offscreen_anchor ??= create_text()),
value,
key,
i,
index,
render_fn,
flags,
get_collection
);
if (first_run) {
item.o = true;
if (prev === null) {
first = item;
} else {
prev.next = item;
}
prev = item;
if (!first_run) {
item.e.f |= EFFECT_OFFSCREEN;
}
items.set(key, item);
@ -268,19 +283,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
if (length === 0 && fallback_fn && !fallback) {
if (first_run) {
fallback = {
fragment: null,
effect: branch(() => fallback_fn(anchor))
};
fallback = branch(() => fallback_fn(anchor));
} else {
var fragment = document.createDocumentFragment();
var target = create_text();
fragment.append(target);
fallback = {
fragment,
effect: branch(() => fallback_fn(target))
};
fallback = branch(() => fallback_fn((offscreen_anchor ??= create_text())));
fallback.f |= EFFECT_OFFSCREEN;
}
}
@ -321,7 +327,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
});
/** @type {EachState} */
var state = { effect, flags, items, first };
var state = { effect, flags, items, outrogroups: null, fallback };
first_run = false;
@ -345,21 +351,21 @@ function reconcile(state, array, anchor, flags, get_key) {
var length = array.length;
var items = state.items;
var current = state.first;
var current = state.effect.first;
/** @type {undefined | Set<EachItem>} */
/** @type {undefined | Set<Effect>} */
var seen;
/** @type {EachItem | null} */
/** @type {Effect | null} */
var prev = null;
/** @type {undefined | Set<EachItem>} */
/** @type {undefined | Set<Effect>} */
var to_animate;
/** @type {EachItem[]} */
/** @type {Effect[]} */
var matched = [];
/** @type {EachItem[]} */
/** @type {Effect[]} */
var stashed = [];
/** @type {V} */
@ -368,8 +374,8 @@ function reconcile(state, array, anchor, flags, get_key) {
/** @type {any} */
var key;
/** @type {EachItem | undefined} */
var item;
/** @type {Effect | undefined} */
var effect;
/** @type {number} */
var i;
@ -378,13 +384,13 @@ function reconcile(state, array, anchor, flags, get_key) {
for (i = 0; i < length; i += 1) {
value = array[i];
key = get_key(value, i);
item = /** @type {EachItem} */ (items.get(key));
effect = /** @type {EachItem} */ (items.get(key)).e;
// offscreen == coming in now, no animation in that case,
// else this would happen https://github.com/sveltejs/svelte/issues/17181
if (item.o) {
item.e.nodes?.a?.measure();
(to_animate ??= new Set()).add(item);
if ((effect.f & EFFECT_OFFSCREEN) === 0) {
effect.nodes?.a?.measure();
(to_animate ??= new Set()).add(effect);
}
}
}
@ -393,20 +399,34 @@ function reconcile(state, array, anchor, flags, get_key) {
value = array[i];
key = get_key(value, i);
item = /** @type {EachItem} */ (items.get(key));
effect = /** @type {EachItem} */ (items.get(key)).e;
state.first ??= item;
if (state.outrogroups !== null) {
for (const group of state.outrogroups) {
group.pending.delete(effect);
group.done.delete(effect);
}
}
if (!item.o) {
item.o = true;
if ((effect.f & EFFECT_OFFSCREEN) !== 0) {
effect.f ^= EFFECT_OFFSCREEN;
if (effect === current) {
move(effect, null, anchor);
} else {
var next = prev ? prev.next : current;
link(state, prev, item);
link(state, item, next);
if (effect === state.effect.last) {
state.effect.last = effect.prev;
}
if (effect.prev) effect.prev.next = effect.next;
if (effect.next) effect.next.prev = effect.prev;
link(state, prev, effect);
link(state, effect, next);
move(item, next, anchor);
prev = item;
move(effect, next, anchor);
prev = effect;
matched = [];
stashed = [];
@ -414,17 +434,18 @@ function reconcile(state, array, anchor, flags, get_key) {
current = prev.next;
continue;
}
}
if ((item.e.f & INERT) !== 0) {
resume_effect(item.e);
if ((effect.f & INERT) !== 0) {
resume_effect(effect);
if (is_animated) {
item.e.nodes?.a?.unfix();
(to_animate ??= new Set()).delete(item);
effect.nodes?.a?.unfix();
(to_animate ??= new Set()).delete(effect);
}
}
if (item !== current) {
if (seen !== undefined && seen.has(item)) {
if (effect !== current) {
if (seen !== undefined && seen.has(effect)) {
if (matched.length < stashed.length) {
// more efficient to move later items to the front
var start = stashed[0];
@ -455,14 +476,14 @@ function reconcile(state, array, anchor, flags, get_key) {
stashed = [];
} else {
// more efficient to move earlier items to the back
seen.delete(item);
move(item, current, anchor);
seen.delete(effect);
move(effect, current, anchor);
link(state, item.prev, item.next);
link(state, item, prev === null ? state.first : prev.next);
link(state, prev, item);
link(state, effect.prev, effect.next);
link(state, effect, prev === null ? state.effect.first : prev.next);
link(state, prev, effect);
prev = item;
prev = effect;
}
continue;
@ -471,12 +492,8 @@ function reconcile(state, array, anchor, flags, get_key) {
matched = [];
stashed = [];
while (current !== null && current !== item) {
// If the each block isn't inert and an item has an effect that is already inert,
// skip over adding it to our seen Set as the item is already being handled
if ((current.e.f & INERT) === 0) {
while (current !== null && current !== effect) {
(seen ??= new Set()).add(current);
}
stashed.push(current);
current = current.next;
}
@ -484,42 +501,62 @@ function reconcile(state, array, anchor, flags, get_key) {
if (current === null) {
continue;
}
}
item = current;
if ((effect.f & EFFECT_OFFSCREEN) === 0) {
matched.push(effect);
}
matched.push(item);
prev = item;
current = item.next;
prev = effect;
current = effect.next;
}
let has_offscreen_items = items.size > length;
if (state.outrogroups !== null) {
for (const group of state.outrogroups) {
if (group.pending.size === 0) {
destroy_effects(array_from(group.done));
state.outrogroups?.delete(group);
}
}
if (state.outrogroups.size === 0) {
state.outrogroups = null;
}
}
if (current !== null || seen !== undefined) {
var to_destroy = seen === undefined ? [] : array_from(seen);
/** @type {Effect[]} */
var to_destroy = [];
if (seen !== undefined) {
for (effect of seen) {
if ((effect.f & INERT) === 0) {
to_destroy.push(effect);
}
}
}
while (current !== null) {
// If the each block isn't inert, then inert effects are currently outroing and will be removed once the transition is finished
if ((current.e.f & INERT) === 0) {
if ((current.f & INERT) === 0 && current !== state.fallback) {
to_destroy.push(current);
}
current = current.next;
}
var destroy_length = to_destroy.length;
has_offscreen_items = items.size - destroy_length > length;
if (destroy_length > 0) {
var controlled_anchor = (flags & EACH_IS_CONTROLLED) !== 0 && length === 0 ? anchor : null;
if (is_animated) {
for (i = 0; i < destroy_length; i += 1) {
to_destroy[i].e.nodes?.a?.measure();
to_destroy[i].nodes?.a?.measure();
}
for (i = 0; i < destroy_length; i += 1) {
to_destroy[i].e.nodes?.a?.fix();
to_destroy[i].nodes?.a?.fix();
}
}
@ -527,23 +564,11 @@ function reconcile(state, array, anchor, flags, get_key) {
}
}
// Append offscreen items at the end
if (has_offscreen_items) {
for (const item of items.values()) {
if (!item.o) {
link(state, prev, item);
prev = item;
}
}
}
state.effect.last = prev && prev.e;
if (is_animated) {
queue_micro_task(() => {
if (to_animate === undefined) return;
for (item of to_animate) {
item.e.nodes?.a?.apply();
for (effect of to_animate) {
effect.nodes?.a?.apply();
}
});
}
@ -551,8 +576,8 @@ function reconcile(state, array, anchor, flags, get_key) {
/**
* @template V
* @param {Node | null} anchor
* @param {EachItem | null} prev
* @param {Map<any, EachItem>} items
* @param {Node} anchor
* @param {V} value
* @param {unknown} key
* @param {number} index
@ -561,96 +586,81 @@ function reconcile(state, array, anchor, flags, get_key) {
* @param {() => V[]} get_collection
* @returns {EachItem}
*/
function create_item(anchor, prev, value, key, index, render_fn, flags, get_collection) {
var reactive = (flags & EACH_ITEM_REACTIVE) !== 0;
var mutable = (flags & EACH_ITEM_IMMUTABLE) === 0;
function create_item(items, anchor, value, key, index, render_fn, flags, get_collection) {
var v =
(flags & EACH_ITEM_REACTIVE) !== 0
? (flags & EACH_ITEM_IMMUTABLE) === 0
? mutable_source(value, false, false)
: source(value)
: null;
var v = reactive ? (mutable ? mutable_source(value, false, false) : source(value)) : value;
var i = (flags & EACH_INDEX_REACTIVE) === 0 ? index : source(index);
var i = (flags & EACH_INDEX_REACTIVE) !== 0 ? source(index) : null;
if (DEV && reactive) {
if (DEV && v) {
// For tracing purposes, we need to link the source signal we create with the
// collection + index so that tracing works as intended
/** @type {Value} */ (v).trace = () => {
var collection_index = typeof i === 'number' ? index : i.v;
v.trace = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
get_collection()[collection_index];
get_collection()[i?.v ?? index];
};
}
/** @type {EachItem} */
var item = {
i,
return {
v,
k: key,
// @ts-expect-error
e: null,
o: false,
prev,
next: null
};
if (anchor === null) {
var fragment = document.createDocumentFragment();
fragment.append((anchor = create_text()));
}
item.e = branch(() => render_fn(/** @type {Node} */ (anchor), v, i, get_collection));
if (prev !== null) {
// we only need to set `prev.next = item`, because
// `item.prev = prev` was set on initialization.
// the effects themselves are already linked
prev.next = item;
}
i,
e: branch(() => {
render_fn(anchor, v ?? value, i ?? index, get_collection);
return item;
return () => {
items.delete(key);
};
})
};
}
/**
* @param {EachItem} item
* @param {EachItem | null} next
* @param {Effect} effect
* @param {Effect | null} next
* @param {Text | Element | Comment} anchor
*/
function move(item, next, anchor) {
if (!item.e.nodes) return;
function move(effect, next, anchor) {
if (!effect.nodes) return;
var end = item.next ? /** @type {EffectNodes} */ (item.next.e.nodes).start : anchor;
var node = effect.nodes.start;
var end = effect.nodes.end;
var dest = next ? /** @type {EffectNodes} */ (next.e.nodes).start : anchor;
var node = /** @type {TemplateNode} */ (item.e.nodes.start);
var dest =
next && (next.f & EFFECT_OFFSCREEN) === 0
? /** @type {EffectNodes} */ (next.nodes).start
: anchor;
while (node !== null && node !== end) {
while (node !== null) {
var next_node = /** @type {TemplateNode} */ (get_next_sibling(node));
dest.before(node);
if (node === end) {
return;
}
node = next_node;
}
}
/**
* @param {EachState} state
* @param {EachItem | null} prev
* @param {EachItem | null} next
* @param {Effect | null} prev
* @param {Effect | null} next
*/
function link(state, prev, next) {
if (prev === null) {
state.first = next;
state.effect.first = next && next.e;
state.effect.first = next;
} else {
if (prev.e.next) {
prev.e.next.prev = null;
}
prev.next = next;
prev.e.next = next && next.e;
}
if (next !== null) {
if (next.e.prev) {
next.e.prev.next = null;
}
if (next === null) {
state.effect.last = prev;
} else {
next.prev = prev;
next.e.prev = prev && prev.e;
}
}

@ -590,17 +590,11 @@ export function pause_effect(effect, callback, destroy = true) {
pause_children(effect, transitions, true);
run_out_transitions(transitions, () => {
var fn = () => {
if (destroy) destroy_effect(effect);
if (callback) callback();
});
}
};
/**
* @param {TransitionManager[]} transitions
* @param {() => void} fn
*/
export function run_out_transitions(transitions, fn) {
var remaining = transitions.length;
if (remaining > 0) {
var check = () => --remaining || fn();
@ -617,7 +611,7 @@ export function run_out_transitions(transitions, fn) {
* @param {TransitionManager[]} transitions
* @param {boolean} local
*/
export function pause_children(effect, transitions, local) {
function pause_children(effect, transitions, local) {
if ((effect.f & INERT) !== 0) return;
effect.f ^= INERT;

@ -72,6 +72,11 @@ export type TemplateNode = Text | Element | Comment;
export type Dom = TemplateNode | TemplateNode[];
export type EachOutroGroup = {
pending: Set<Effect>;
done: Set<Effect>;
};
export type EachState = {
/** the each block effect */
effect: Effect;
@ -79,23 +84,19 @@ export type EachState = {
flags: number;
/** a key -> item lookup */
items: Map<any, EachItem>;
/** head of the linked list of items */
first: EachItem | null;
/** all outro groups that this item is a part of */
outrogroups: Set<EachOutroGroup> | null;
/** `{:else}` effect */
fallback: Effect | null;
};
export type EachItem = {
/** value */
v: Source<any> | null;
/** index */
i: Source<number> | null;
/** effect */
e: Effect;
/** item */
v: any | Source<any>;
/** index */
i: number | Source<number>;
/** key */
k: unknown;
/** true if onscreen */
o: boolean;
prev: EachItem | null;
next: EachItem | null;
};
export interface TransitionManager {

@ -0,0 +1,194 @@
<script lang="ts">
import { tick } from 'svelte';
const VALUES = Array.from('abcdefghijklmnopqrstuvwxyz');
const presets = [
// b is never destroyed
[
"ab",
"",
"a",
"abc"
],
// the final state is 'abc', not 'cba'
[
"abc",
"",
"cba"
],
// the case in https://github.com/sveltejs/svelte/pull/17240
[
"abc",
"adbc",
"adebc"
],
[
"ab",
"a",
"abc"
],
[
"a",
"bc",
"bcd"
],
// add more presets by hitting 'party' and copying from the console
];
function shuffle() {
const values = VALUES.slice();
const number = Math.floor(Math.random() * VALUES.length);
let shuffled = '';
for (let i = 0; i < number; i++) {
shuffled += (values.splice(Math.floor(Math.random() * (number - i)), 1))[0];
}
return shuffled;
}
function mark(node) {
let prev = -1;
return {
duration: transition ? (slow ? 5000 : 500) : 0,
tick(t) {
const direction = t >= prev ? 'in' : 'out';
node.style.color = direction === 'in' ? '' : 'grey';
prev = t;
}
}
}
const record = [];
const sleep = (ms = slow ? 1000 : 100) => new Promise((f) => setTimeout(f, ms));
async function test(x: string) {
console.group(JSON.stringify(x));
error = null;
list = x;
record.push(list);
if (transition) {
await sleep();
} else {
await tick();
await tick();
}
check('reconcile');
n += 1;
await tick();
check('update');
console.groupEnd();
}
function check(task: string) {
const expected = list.split('').map((c) => `(${c}:${n})`).join('') || '(fallback)';
const children = Array.from(container.children);
const filtered = children.filter((span: HTMLElement) => !span.style.color);
const received = filtered.map((span) => span.textContent).join('');
if (expected !== received) {
console.log('expected:', expected);
console.log('received:', received);
console.log(JSON.stringify(record, null, ' '));
error = `failed to ${task}`;
throw new Error(error);
}
}
let list = $state('');
let n = $state(0);
let error = $state(null);
let slow = $state(false);
let transition = $state(true);
let partying = $state(false);
let container: HTMLElement;
</script>
<h1>each block stress test</h1>
<label>
<input type="checkbox" bind:checked={transition} />
transition
</label>
<label>
<input type="checkbox" bind:checked={slow} />
slow
</label>
<fieldset>
<legend>random</legend>
<button onclick={() => test(shuffle())}>test</button>
<button onclick={async () => {
if (partying) {
partying = false;
} else {
partying = true;
while (partying) await test(shuffle());
}
}}>{partying ? 'stop' : 'party'}</button>
</fieldset>
<fieldset>
<legend>presets</legend>
{#each presets as preset, index}
<button onclick={async () => {
for (let i = 0; i < preset.length; i += 1) {
await test(preset[i]);
}
}}>{index + 1}</button>
{/each}
</fieldset>
<form onsubmit={(e) => {
e.preventDefault();
test(e.currentTarget.querySelector('input').value);
}}>
<fieldset>
<legend>input</legend>
<input />
</fieldset>
</form>
<div id="output" bind:this={container}>
{#each list as c (c)}
<span transition:mark>({c}:{n})</span>
{:else}
<span transition:mark>(fallback)</span>
{/each}
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<style>
fieldset {
display: flex;
gap: 0.5em;
border-radius: 0.5em;
corner-shape: squircle;
margin: 0 0 1em 0;
padding: 0.2em 0.8em 0.8em;
}
legend {
padding: 0.2em 0.5em;
left: -0.2em;
position: relative;
}
.error {
color: red;
}
</style>

@ -0,0 +1,33 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, raf }) {
const [clear, push] = target.querySelectorAll('button');
flushSync(() => clear.click());
flushSync(() => push.click());
raf.tick(500);
assert.htmlEqual(
target.innerHTML,
`
<button>clear</button>
<button>push</button>
<span style="opacity: 1;">1</span>
<span style="opacity: 0.5;">2</span>
`
);
raf.tick(1000);
assert.htmlEqual(
target.innerHTML,
`
<button>clear</button>
<button>push</button>
<span style="opacity: 1;">1</span>
`
);
}
});

@ -0,0 +1,19 @@
<script>
function fade(node) {
return {
duration: 1000,
tick(t) {
node.style.opacity = t;
}
}
}
let items = $state([1, 2]);
</script>
<button onclick={() => items = []}>clear</button>
<button onclick={() => items.push(items.length + 1)}>push</button>
{#each items as item}
<span transition:fade={{duration: 1000}}>{item}</span>
{/each}

@ -0,0 +1,23 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, raf }) {
const [clear, reverse] = target.querySelectorAll('button');
flushSync(() => clear.click());
flushSync(() => reverse.click());
raf.tick(1);
assert.htmlEqual(
target.innerHTML,
`
<button>clear</button>
<button>reverse</button>
<span style="opacity: 1;">c</span>
<span style="opacity: 1;">b</span>
<span style="opacity: 1;">a</span>
`
);
}
});

@ -0,0 +1,19 @@
<script>
function fade(node) {
return {
duration: 1000,
tick(t) {
node.style.opacity = t;
}
}
}
let items = $state(['a', 'b', 'c']);
</script>
<button onclick={() => items = []}>clear</button>
<button onclick={() => items = ['c', 'b', 'a']}>reverse</button>
{#each items as item (item)}
<span transition:fade={{duration: 1000}}>{item}</span>
{/each}
Loading…
Cancel
Save