svelte
Version:
Cybernetically enhanced web apps
610 lines (512 loc) • 14.7 kB
JavaScript
/** @import { EachItem, EachState, Effect, MaybeSource, Source, TemplateNode, TransitionManager, Value } from '#client' */
import {
EACH_INDEX_REACTIVE,
EACH_IS_ANIMATED,
EACH_IS_CONTROLLED,
EACH_ITEM_IMMUTABLE,
EACH_ITEM_REACTIVE,
HYDRATION_END,
HYDRATION_START_ELSE
} from '../../../../constants.js';
import {
hydrate_next,
hydrate_node,
hydrating,
remove_nodes,
set_hydrate_node,
set_hydrating
} from '../hydration.js';
import {
clear_text_content,
create_text,
get_first_child,
get_next_sibling
} from '../operations.js';
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 { INERT } from '../../constants.js';
import { queue_micro_task } from '../task.js';
import { active_effect, active_reaction, get } from '../../runtime.js';
import { DEV } from 'esm-env';
import { derived_safe_equal } from '../../reactivity/deriveds.js';
/**
* The row of a keyed each block that is currently updating. We track this
* so that `animate:` directives have something to attach themselves to
* @type {EachItem | null}
*/
export let current_each_item = null;
/** @param {EachItem | null} item */
export function set_current_each_item(item) {
current_each_item = item;
}
/**
* @param {any} _
* @param {number} i
*/
export function index(_, i) {
return i;
}
/**
* Pause multiple effects simultaneously, and coordinate their
* subsequent destruction. Used in each blocks
* @param {EachState} state
* @param {EachItem[]} items
* @param {null | Node} controlled_anchor
* @param {Map<any, EachItem>} items_map
*/
function pause_effects(state, items, controlled_anchor, items_map) {
/** @type {TransitionManager[]} */
var transitions = [];
var length = items.length;
for (var i = 0; i < length; i++) {
pause_children(items[i].e, transitions, true);
}
var is_controlled = length > 0 && transitions.length === 0 && controlled_anchor !== null;
// If we have a controlled anchor, it means that the each block is inside a single
// DOM element, so we can apply a fast-path for clearing the contents of the element.
if (is_controlled) {
var parent_node = /** @type {Element} */ (
/** @type {Element} */ (controlled_anchor).parentNode
);
clear_text_content(parent_node);
parent_node.append(/** @type {Element} */ (controlled_anchor));
items_map.clear();
link(state, items[0].prev, items[length - 1].next);
}
run_out_transitions(transitions, () => {
for (var i = 0; i < length; i++) {
var item = items[i];
if (!is_controlled) {
items_map.delete(item.k);
link(state, item.prev, item.next);
}
destroy_effect(item.e, !is_controlled);
}
});
}
/**
* @template V
* @param {Element | Comment} node The next sibling node, or the parent node if this is a 'controlled' block
* @param {number} flags
* @param {() => V[]} get_collection
* @param {(value: V, index: number) => any} get_key
* @param {(anchor: Node, item: MaybeSource<V>, index: MaybeSource<number>) => void} render_fn
* @param {null | ((anchor: Node) => void)} fallback_fn
* @returns {void}
*/
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 };
var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0;
if (is_controlled) {
var parent_node = /** @type {Element} */ (node);
anchor = hydrating
? set_hydrate_node(/** @type {Comment | Text} */ (get_first_child(parent_node)))
: parent_node.appendChild(create_text());
}
if (hydrating) {
hydrate_next();
}
/** @type {Effect | null} */
var fallback = null;
var was_empty = false;
// TODO: ideally we could use derived for runes mode but because of the ability
// to use a store which can be mutated, we can't do that here as mutating a store
// will still result in the collection array being the same from the store
var each_array = derived_safe_equal(() => {
var collection = get_collection();
return is_array(collection) ? collection : collection == null ? [] : array_from(collection);
});
block(() => {
var array = get(each_array);
var length = array.length;
if (was_empty && length === 0) {
// ignore updates if the array is empty,
// and it already was empty on previous run
return;
}
was_empty = length === 0;
/** `true` if there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */
let mismatch = false;
if (hydrating) {
var is_else = /** @type {Comment} */ (anchor).data === HYDRATION_START_ELSE;
if (is_else !== (length === 0)) {
// hydration mismatch — remove the server-rendered DOM and start over
anchor = remove_nodes();
set_hydrate_node(anchor);
set_hydrating(false);
mismatch = true;
}
}
// this is separate to the previous block because `hydrating` might change
if (hydrating) {
/** @type {EachItem | null} */
var prev = null;
/** @type {EachItem} */
var item;
for (var i = 0; i < length; i++) {
if (
hydrate_node.nodeType === 8 &&
/** @type {Comment} */ (hydrate_node).data === HYDRATION_END
) {
// The server rendered fewer items than expected,
// so break out and continue appending non-hydrated items
anchor = /** @type {Comment} */ (hydrate_node);
mismatch = true;
set_hydrating(false);
break;
}
var value = array[i];
var key = get_key(value, i);
item = create_item(
hydrate_node,
state,
prev,
null,
value,
key,
i,
render_fn,
flags,
get_collection
);
state.items.set(key, item);
prev = item;
}
// remove excess nodes
if (length > 0) {
set_hydrate_node(remove_nodes());
}
}
if (!hydrating) {
reconcile(array, state, anchor, render_fn, flags, get_key, get_collection);
}
if (fallback_fn !== null) {
if (length === 0) {
if (fallback) {
resume_effect(fallback);
} else {
fallback = branch(() => fallback_fn(anchor));
}
} else if (fallback !== null) {
pause_effect(fallback, () => {
fallback = null;
});
}
}
if (mismatch) {
// continue in hydration mode
set_hydrating(true);
}
// When we mount the each block for the first time, the collection won't be
// connected to this effect as the effect hasn't finished running yet and its deps
// won't be assigned. However, it's possible that when reconciling the each block
// that a mutation occurred and it's made the collection MAYBE_DIRTY, so reading the
// collection again can provide consistency to the reactive graph again as the deriveds
// will now be `CLEAN`.
get(each_array);
});
if (hydrating) {
anchor = hydrate_node;
}
}
/**
* Add, remove, or reorder items output by an each block as its input changes
* @template V
* @param {Array<V>} array
* @param {EachState} state
* @param {Element | Comment | Text} anchor
* @param {(anchor: Node, item: MaybeSource<V>, index: number | Source<number>, collection: () => V[]) => void} render_fn
* @param {number} flags
* @param {(value: V, index: number) => any} get_key
* @param {() => V[]} get_collection
* @returns {void}
*/
function reconcile(array, state, anchor, render_fn, flags, get_key, get_collection) {
var is_animated = (flags & EACH_IS_ANIMATED) !== 0;
var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0;
var length = array.length;
var items = state.items;
var first = state.first;
var current = first;
/** @type {undefined | Set<EachItem>} */
var seen;
/** @type {EachItem | null} */
var prev = null;
/** @type {undefined | Set<EachItem>} */
var to_animate;
/** @type {EachItem[]} */
var matched = [];
/** @type {EachItem[]} */
var stashed = [];
/** @type {V} */
var value;
/** @type {any} */
var key;
/** @type {EachItem | undefined} */
var item;
/** @type {number} */
var i;
if (is_animated) {
for (i = 0; i < length; i += 1) {
value = array[i];
key = get_key(value, i);
item = items.get(key);
if (item !== undefined) {
item.a?.measure();
(to_animate ??= new Set()).add(item);
}
}
}
for (i = 0; i < length; i += 1) {
value = array[i];
key = get_key(value, i);
item = items.get(key);
if (item === undefined) {
var child_anchor = current ? /** @type {TemplateNode} */ (current.e.nodes_start) : anchor;
prev = create_item(
child_anchor,
state,
prev,
prev === null ? state.first : prev.next,
value,
key,
i,
render_fn,
flags,
get_collection
);
items.set(key, prev);
matched = [];
stashed = [];
current = prev.next;
continue;
}
if (should_update) {
update_item(item, value, i, flags);
}
if ((item.e.f & INERT) !== 0) {
resume_effect(item.e);
if (is_animated) {
item.a?.unfix();
(to_animate ??= new Set()).delete(item);
}
}
if (item !== current) {
if (seen !== undefined && seen.has(item)) {
if (matched.length < stashed.length) {
// more efficient to move later items to the front
var start = stashed[0];
var j;
prev = start.prev;
var a = matched[0];
var b = matched[matched.length - 1];
for (j = 0; j < matched.length; j += 1) {
move(matched[j], start, anchor);
}
for (j = 0; j < stashed.length; j += 1) {
seen.delete(stashed[j]);
}
link(state, a.prev, b.next);
link(state, prev, a);
link(state, b, start);
current = start;
prev = b;
i -= 1;
matched = [];
stashed = [];
} else {
// more efficient to move earlier items to the back
seen.delete(item);
move(item, current, anchor);
link(state, item.prev, item.next);
link(state, item, prev === null ? state.first : prev.next);
link(state, prev, item);
prev = item;
}
continue;
}
matched = [];
stashed = [];
while (current !== null && current.k !== key) {
// 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) {
(seen ??= new Set()).add(current);
}
stashed.push(current);
current = current.next;
}
if (current === null) {
continue;
}
item = current;
}
matched.push(item);
prev = item;
current = item.next;
}
if (current !== null || seen !== undefined) {
var to_destroy = seen === undefined ? [] : array_from(seen);
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) {
to_destroy.push(current);
}
current = current.next;
}
var destroy_length = to_destroy.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].a?.measure();
}
for (i = 0; i < destroy_length; i += 1) {
to_destroy[i].a?.fix();
}
}
pause_effects(state, to_destroy, controlled_anchor, items);
}
}
if (is_animated) {
queue_micro_task(() => {
if (to_animate === undefined) return;
for (item of to_animate) {
item.a?.apply();
}
});
}
/** @type {Effect} */ (active_effect).first = state.first && state.first.e;
/** @type {Effect} */ (active_effect).last = prev && prev.e;
}
/**
* @param {EachItem} item
* @param {any} value
* @param {number} index
* @param {number} type
* @returns {void}
*/
function update_item(item, value, index, type) {
if ((type & EACH_ITEM_REACTIVE) !== 0) {
internal_set(item.v, value);
}
if ((type & EACH_INDEX_REACTIVE) !== 0) {
internal_set(/** @type {Value<number>} */ (item.i), index);
} else {
item.i = index;
}
}
/**
* @template V
* @param {Node} anchor
* @param {EachState} state
* @param {EachItem | null} prev
* @param {EachItem | null} next
* @param {V} value
* @param {unknown} key
* @param {number} index
* @param {(anchor: Node, item: V | Source<V>, index: number | Value<number>, collection: () => V[]) => void} render_fn
* @param {number} flags
* @param {() => V[]} get_collection
* @returns {EachItem}
*/
function create_item(
anchor,
state,
prev,
next,
value,
key,
index,
render_fn,
flags,
get_collection
) {
var previous_each_item = current_each_item;
var reactive = (flags & EACH_ITEM_REACTIVE) !== 0;
var mutable = (flags & EACH_ITEM_IMMUTABLE) === 0;
var v = reactive ? (mutable ? mutable_source(value) : source(value)) : value;
var i = (flags & EACH_INDEX_REACTIVE) === 0 ? index : source(index);
if (DEV && reactive) {
// 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).debug = () => {
var collection_index = typeof i === 'number' ? index : i.v;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
get_collection()[collection_index];
};
}
/** @type {EachItem} */
var item = {
i,
v,
k: key,
a: null,
// @ts-expect-error
e: null,
prev,
next
};
current_each_item = item;
try {
item.e = branch(() => render_fn(anchor, v, i, get_collection), hydrating);
item.e.prev = prev && prev.e;
item.e.next = next && next.e;
if (prev === null) {
state.first = item;
} else {
prev.next = item;
prev.e.next = item.e;
}
if (next !== null) {
next.prev = item;
next.e.prev = item.e;
}
return item;
} finally {
current_each_item = previous_each_item;
}
}
/**
* @param {EachItem} item
* @param {EachItem | null} next
* @param {Text | Element | Comment} anchor
*/
function move(item, next, anchor) {
var end = item.next ? /** @type {TemplateNode} */ (item.next.e.nodes_start) : anchor;
var dest = next ? /** @type {TemplateNode} */ (next.e.nodes_start) : anchor;
var node = /** @type {TemplateNode} */ (item.e.nodes_start);
while (node !== end) {
var next_node = /** @type {TemplateNode} */ (get_next_sibling(node));
dest.before(node);
node = next_node;
}
}
/**
* @param {EachState} state
* @param {EachItem | null} prev
* @param {EachItem | null} next
*/
function link(state, prev, next) {
if (prev === null) {
state.first = next;
} else {
prev.next = next;
prev.e.next = next && next.e;
}
if (next !== null) {
next.prev = prev;
next.e.prev = prev && prev.e;
}
}