UNPKG

svelte

Version:

Cybernetically enhanced web apps

667 lines (548 loc) 16 kB
/** @import { EachItem, EachOutroGroup, EachState, Effect, EffectNodes, MaybeSource, Source, TemplateNode, TransitionManager, Value } from '#client' */ /** @import { Batch } from '../../reactivity/batch.js'; */ 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, read_hydration_instruction, skip_nodes, set_hydrate_node, set_hydrating } from '../hydration.js'; import { clear_text_content, create_text, get_first_child, get_next_sibling, should_defer_append } from '../operations.js'; import { block, branch, destroy_effect, 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, 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 */ export function index(_, i) { return i; } /** * Pause multiple effects simultaneously, and coordinate their * subsequent destruction. Used in each blocks * @param {EachState} state * @param {Effect[]} to_destroy * @param {null | Node} controlled_anchor */ function pause_effects(state, to_destroy, controlled_anchor) { /** @type {TransitionManager[]} */ var transitions = []; var length = to_destroy.length; /** @type {EachOutroGroup} */ var group; var remaining = to_destroy.length; for (var i = 0; i < length; i++) { 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 ); } 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; if (fast_path) { var anchor = /** @type {Element} */ (controlled_anchor); var parent_node = /** @type {Element} */ (anchor.parentNode); clear_text_content(parent_node); parent_node.append(anchor); state.items.clear(); } destroy_effects(to_destroy, !fast_path); } else { group = { pending: new Set(to_destroy), done: new Set() }; (state.outrogroups ??= new Set()).add(group); } } /** * @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 * @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 {Map<any, EachItem>} */ var items = new Map(); var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; if (is_controlled) { var parent_node = /** @type {Element} */ (node); anchor = hydrating ? set_hydrate_node(get_first_child(parent_node)) : parent_node.appendChild(create_text()); } if (hydrating) { hydrate_next(); } /** @type {Effect | null} */ var fallback = null; // 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); }); /** @type {V[]} */ var array; 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.f & EFFECT_OFFSCREEN) === 0) { resume_effect(fallback); } else { fallback.f ^= EFFECT_OFFSCREEN; move(fallback, null, anchor); } } else { pause_effect(fallback, () => { // TODO only null out if no pending batch needs it, // otherwise re-add `fallback.fragment` and move the // effect into it fallback = null; }); } } } var effect = block(() => { array = /** @type {V[]} */ (get(each_array)); var length = array.length; /** `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 = read_hydration_instruction(anchor) === HYDRATION_START_ELSE; if (is_else !== (length === 0)) { // hydration mismatch — remove the server-rendered DOM and start over anchor = skip_nodes(); set_hydrate_node(anchor); set_hydrating(false); mismatch = true; } } var keys = new Set(); var batch = /** @type {Batch} */ (current_batch); var defer = should_defer_append(); for (var index = 0; index < length; index += 1) { if ( hydrating && hydrate_node.nodeType === COMMENT_NODE && /** @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); } 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 (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( items, first_run ? anchor : (offscreen_anchor ??= create_text()), value, key, index, render_fn, flags, get_collection ); if (!first_run) { item.e.f |= EFFECT_OFFSCREEN; } items.set(key, item); } keys.add(key); } if (length === 0 && fallback_fn && !fallback) { if (first_run) { fallback = branch(() => fallback_fn(anchor)); } else { fallback = branch(() => fallback_fn((offscreen_anchor ??= create_text()))); fallback.f |= EFFECT_OFFSCREEN; } } // remove excess nodes if (hydrating && length > 0) { set_hydrate_node(skip_nodes()); } if (!first_run) { if (defer) { for (const [key, item] of items) { if (!keys.has(key)) { batch.skipped_effects.add(item.e); } } batch.oncommit(commit); batch.ondiscard(() => { // TODO presumably we need to do something here? }); } else { commit(); } } 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); }); /** @type {EachState} */ var state = { effect, flags, items, outrogroups: null, fallback }; first_run = false; if (hydrating) { anchor = hydrate_node; } } /** * Add, remove, or reorder items output by an each block as its input changes * @template V * @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(state, array, anchor, flags, get_key) { var is_animated = (flags & EACH_IS_ANIMATED) !== 0; var length = array.length; var items = state.items; var current = state.effect.first; /** @type {undefined | Set<Effect>} */ var seen; /** @type {Effect | null} */ var prev = null; /** @type {undefined | Set<Effect>} */ var to_animate; /** @type {Effect[]} */ var matched = []; /** @type {Effect[]} */ var stashed = []; /** @type {V} */ var value; /** @type {any} */ var key; /** @type {Effect | undefined} */ var effect; /** @type {number} */ var i; if (is_animated) { for (i = 0; i < length; i += 1) { value = array[i]; key = get_key(value, i); 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 ((effect.f & EFFECT_OFFSCREEN) === 0) { effect.nodes?.a?.measure(); (to_animate ??= new Set()).add(effect); } } } for (i = 0; i < length; i += 1) { value = array[i]; key = get_key(value, i); effect = /** @type {EachItem} */ (items.get(key)).e; if (state.outrogroups !== null) { for (const group of state.outrogroups) { group.pending.delete(effect); group.done.delete(effect); } } if ((effect.f & EFFECT_OFFSCREEN) !== 0) { effect.f ^= EFFECT_OFFSCREEN; if (effect === current) { move(effect, null, anchor); } else { var next = prev ? prev.next : current; 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(effect, next, anchor); prev = effect; matched = []; stashed = []; current = prev.next; continue; } } if ((effect.f & INERT) !== 0) { resume_effect(effect); if (is_animated) { effect.nodes?.a?.unfix(); (to_animate ??= new Set()).delete(effect); } } 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]; 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(effect); move(effect, current, anchor); link(state, effect.prev, effect.next); link(state, effect, prev === null ? state.effect.first : prev.next); link(state, prev, effect); prev = effect; } continue; } matched = []; stashed = []; while (current !== null && current !== effect) { (seen ??= new Set()).add(current); stashed.push(current); current = current.next; } if (current === null) { continue; } } if ((effect.f & EFFECT_OFFSCREEN) === 0) { matched.push(effect); } prev = effect; current = effect.next; } 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) { /** @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.f & INERT) === 0 && current !== state.fallback) { 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].nodes?.a?.measure(); } for (i = 0; i < destroy_length; i += 1) { to_destroy[i].nodes?.a?.fix(); } } pause_effects(state, to_destroy, controlled_anchor); } } if (is_animated) { queue_micro_task(() => { if (to_animate === undefined) return; for (effect of to_animate) { effect.nodes?.a?.apply(); } }); } } /** * @template V * @param {Map<any, EachItem>} items * @param {Node} anchor * @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(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 i = (flags & EACH_INDEX_REACTIVE) !== 0 ? source(index) : null; 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 v.trace = () => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions get_collection()[i?.v ?? index]; }; } return { v, i, e: branch(() => { render_fn(anchor, v ?? value, i ?? index, get_collection); return () => { items.delete(key); }; }) }; } /** * @param {Effect} effect * @param {Effect | null} next * @param {Text | Element | Comment} anchor */ function move(effect, next, anchor) { if (!effect.nodes) return; var node = effect.nodes.start; var end = effect.nodes.end; var dest = next && (next.f & EFFECT_OFFSCREEN) === 0 ? /** @type {EffectNodes} */ (next.nodes).start : anchor; 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 {Effect | null} prev * @param {Effect | null} next */ function link(state, prev, next) { if (prev === null) { state.effect.first = next; } else { prev.next = next; } if (next === null) { state.effect.last = prev; } else { next.prev = prev; } }