From e8ce41815a734b296aac0f03d9df99abea209c8a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 21 Mar 2024 19:13:15 -0400 Subject: [PATCH] chore: simplify transitions (#10798) * replace transition code * get rid of some stuff * simplify * remove some junk * not sure how we solved this before, but i guess this makes sense * oh hey i don't think we need this * make elseif transparent for transition purposes * oops * edge case * fix * do not want * rename * transition out ecah blocks when emptying * baby steps * hydration fix * tidy up * tidy up * tidy up * fallbacks * man i love deleting code * tidy up * note to self * why was this an effect * tidy up * tidy up * key blocks * temporary * fix * WIP * fix * simplify * emit events * delete delete delete * destroy child effects * simplify helper * simplify helper * fix * remove commented out code * fix wonky test * fix test * fix test * fix test * dynamic components * fix test * await * tidy up * fix * fix * fix test * tidy up * we dont need to reconcile during hydration * simplify * tidy up * fix * reduce indentation * simplify * remove some junk * remove some junk * simplify * tidy up * prefer while over do-while (this appears to have the same behaviour) * group fast paths * rename * unused import * unused exports * try this * simplify * simplify * simplify * simplify * tidy up * simplify * simplify * tidy up * simplify * simplify * more compact names * simplify * better comments * simplify * tidy up * we don't actually gain anything from this * fix binding group order bug (revealed by previous commit, but exists separately from it) * tidy up * simplify * tidy up * remove some junk * simplify * note to self * tidy up * revert this bit * tidy up * simplify * simplify * simplify * symmetry * tidy up * var * rename some stuff * tidy up * simplify * keyed each transitions * make elements inert * deferred transitions * fix * fix test * fix some tests * simplify * fix * fix test * fix * eh that'll do for now * fix * revert all these random changes * fix * fix * simplify * tidy up * simplify * simplify * tidy up * tidy up * tidy up * WIP * WIP * working * tidy up * fix * tidy up * tidy up * lerp * tidy up * rename * rename * almost everything working * tidy up * ALL TESTS PASSING * fix treeshaking * Apply suggestions from code review Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> * comment * explain elseif locality * explain flushSync * comments * this is accounted for * add some comments * remove outdated comment * add comment * add comments * rename * a few naming tweaks * explain each_item_block stuff * remove unused arg * optimise * add some comments * fix test post-optimisation * explicit comparisons * some docs * fix intro events * move effect.ran into the bitmask * docs * rename run_transitions to should_intro, add explanatory jsdoc * add some more docs * remove animation before measuring * only animate blocks that persist * note to self --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- packages/svelte/.gitignore | 2 + .../svelte/scripts/check-treeshakeability.js | 8 +- .../3-transform/client/visitors/template.js | 107 +- packages/svelte/src/constants.js | 4 + .../svelte/src/internal/client/constants.js | 13 +- .../src/internal/client/dom/blocks/await.js | 270 ++--- .../internal/client/dom/blocks/css-props.js | 4 +- .../src/internal/client/dom/blocks/each.js | 1054 ++++++----------- .../src/internal/client/dom/blocks/if.js | 264 ++--- .../src/internal/client/dom/blocks/key.js | 170 +-- .../src/internal/client/dom/blocks/snippet.js | 7 +- .../client/dom/blocks/svelte-component.js | 170 +-- .../client/dom/blocks/svelte-element.js | 191 +-- .../internal/client/dom/blocks/svelte-head.js | 7 +- .../client/dom/elements/bindings/input.js | 7 +- .../client/dom/elements/transitions.js | 942 ++++----------- .../src/internal/client/dom/hydration.js | 39 +- .../src/internal/client/dom/reconciler.js | 27 +- .../src/internal/client/dom/template.js | 12 +- packages/svelte/src/internal/client/loop.js | 21 +- .../src/internal/client/reactivity/effects.js | 159 ++- .../src/internal/client/reactivity/types.d.ts | 9 +- packages/svelte/src/internal/client/render.js | 33 +- .../svelte/src/internal/client/runtime.js | 37 +- packages/svelte/src/internal/client/timing.js | 3 +- .../svelte/src/internal/client/types.d.ts | 166 +-- packages/svelte/tests/animation-helpers.js | 168 +-- .../Component.svelte | 10 +- .../main.svelte | 6 +- .../class-shortcut-with-transition/_config.js | 2 +- .../dynamic-element-animation/_config.js | 11 +- .../dynamic-element-animation/main.svelte | 4 +- .../dynamic-element-transition/_config.js | 5 +- .../_config.js | 11 +- .../transition-css-duration/_config.js | 10 +- .../samples/transition-css-iframe/Foo.svelte | 2 +- .../samples/transition-css-iframe/_config.js | 9 +- .../transition-css-in-out-in/_config.js | 13 +- .../transition-css-in-out-in/main.svelte | 6 +- .../samples/transition-inert/_config.js | 19 +- .../_config.js | 8 +- .../_config.js | 42 - .../transition-js-await-block/_config.js | 6 +- .../_config.js | 6 +- .../_config.js | 2 +- .../dynamic-component-transition/_config.js | 2 +- .../_config.js | 2 +- 47 files changed, 1608 insertions(+), 2462 deletions(-) diff --git a/packages/svelte/.gitignore b/packages/svelte/.gitignore index e4925570e5..8b3d7652a1 100644 --- a/packages/svelte/.gitignore +++ b/packages/svelte/.gitignore @@ -11,3 +11,5 @@ /motion.d.ts /store.d.ts /transition.d.ts + +/scripts/_bundle.js diff --git a/packages/svelte/scripts/check-treeshakeability.js b/packages/svelte/scripts/check-treeshakeability.js index 21f33ab6f0..9ded247b2b 100644 --- a/packages/svelte/scripts/check-treeshakeability.js +++ b/packages/svelte/scripts/check-treeshakeability.js @@ -74,7 +74,7 @@ for (const key in pkg.exports) { } const client_main = path.resolve(pkg.exports['.'].browser); -const without_hydration = await bundle_code( +const bundle = await bundle_code( // Use all features which contain hydration code to ensure it's treeshakeable compile( ` @@ -109,15 +109,17 @@ const without_hydration = await bundle_code( ).js.code ); -if (!without_hydration.includes('current_hydration_fragment')) { +if (!bundle.includes('current_hydration_fragment')) { // eslint-disable-next-line no-console console.error(`✅ Hydration code treeshakeable`); } else { // eslint-disable-next-line no-console - console.error(without_hydration); + console.error(bundle); // eslint-disable-next-line no-console console.error(`❌ Hydration code not treeshakeable`); failed = true; + + fs.writeFileSync('scripts/_bundle.js', bundle); } // eslint-disable-next-line no-console diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js index b2ccd60251..6f956a230d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js @@ -28,10 +28,14 @@ import { AttributeAliases, DOMBooleanAttributes, EACH_INDEX_REACTIVE, + EACH_IS_ANIMATED, EACH_IS_CONTROLLED, EACH_IS_STRICT_EQUALS, EACH_ITEM_REACTIVE, - EACH_KEYED + EACH_KEYED, + TRANSITION_GLOBAL, + TRANSITION_IN, + TRANSITION_OUT } from '../../../../../constants.js'; import { regex_is_valid_identifier } from '../../../patterns.js'; import { javascript_visitors_runes } from './javascript-runes.js'; @@ -1922,7 +1926,7 @@ export const template_visitors = { state.init.push( b.stmt( b.call( - '$.animate', + '$.animation', state.node, b.thunk( /** @type {import('estree').Expression} */ (visit(parse_directive_name(node.name))) @@ -1939,25 +1943,21 @@ export const template_visitors = { error(node, 'INTERNAL', 'Node should have been handled elsewhere'); }, TransitionDirective(node, { state, visit }) { - const type = node.intro && node.outro ? '$.transition' : node.intro ? '$.in' : '$.out'; - const expression = - node.expression === null - ? b.literal(null) - : b.thunk(/** @type {import('estree').Expression} */ (visit(node.expression))); + let flags = node.modifiers.includes('global') ? TRANSITION_GLOBAL : 0; + if (node.intro) flags |= TRANSITION_IN; + if (node.outro) flags |= TRANSITION_OUT; - state.init.push( - b.stmt( - b.call( - type, - state.node, - b.thunk( - /** @type {import('estree').Expression} */ (visit(parse_directive_name(node.name))) - ), - expression, - node.modifiers.includes('global') ? b.true : b.false - ) - ) - ); + const args = [ + b.literal(flags), + state.node, + b.thunk(/** @type {import('estree').Expression} */ (visit(parse_directive_name(node.name)))) + ]; + + if (node.expression) { + args.push(b.thunk(/** @type {import('estree').Expression} */ (visit(node.expression)))); + } + + state.init.push(b.stmt(b.call('$.transition', ...args))); }, RegularElement(node, context) { if (node.name === 'noscript') { @@ -2345,6 +2345,19 @@ export const template_visitors = { each_type |= EACH_ITEM_REACTIVE; } + // Since `animate:` can only appear on elements that are the sole child of a keyed each block, + // we can determine at compile time whether the each block is animated or not (in which + // case it should measure animated elements before and after reconciliation). + if ( + node.key && + node.body.nodes.some((child) => { + if (child.type !== 'RegularElement' && child.type !== 'SvelteElement') return false; + return child.attributes.some((attr) => attr.type === 'AnimateDirective'); + }) + ) { + each_type |= EACH_IS_ANIMATED; + } + if (each_node_meta.is_controlled) { each_type |= EACH_IS_CONTROLLED; } @@ -2557,22 +2570,44 @@ export const template_visitors = { context.visit(node.consequent) ); - context.state.after_update.push( - b.stmt( - b.call( - '$.if', - context.state.node, - b.thunk(/** @type {import('estree').Expression} */ (context.visit(node.test))), - b.arrow([b.id('$$anchor')], consequent), - node.alternate - ? b.arrow( - [b.id('$$anchor')], - /** @type {import('estree').BlockStatement} */ (context.visit(node.alternate)) - ) - : b.literal(null) - ) - ) - ); + const args = [ + context.state.node, + b.thunk(/** @type {import('estree').Expression} */ (context.visit(node.test))), + b.arrow([b.id('$$anchor')], consequent), + node.alternate + ? b.arrow( + [b.id('$$anchor')], + /** @type {import('estree').BlockStatement} */ (context.visit(node.alternate)) + ) + : b.literal(null) + ]; + + if (node.elseif) { + // We treat this... + // + // {#if x} + // ... + // {:else} + // {#if y} + //
...
+ // {/if} + // {/if} + // + // ...slightly differently to this... + // + // {#if x} + // ... + // {:else if y} + //
...
+ // {/if} + // + // ...even though they're logically equivalent. In the first case, the + // transition will only play when `y` changes, but in the second it + // should play when `x` or `y` change — both are considered 'local' + args.push(b.literal(true)); + } + + context.state.after_update.push(b.stmt(b.call('$.if', ...args))); }, AwaitBlock(node, context) { context.state.template.push(''); diff --git a/packages/svelte/src/constants.js b/packages/svelte/src/constants.js index 1e7279c885..f26a567506 100644 --- a/packages/svelte/src/constants.js +++ b/packages/svelte/src/constants.js @@ -12,6 +12,10 @@ export const PROPS_IS_RUNES = 1 << 1; export const PROPS_IS_UPDATED = 1 << 2; export const PROPS_IS_LAZY_INITIAL = 1 << 3; +export const TRANSITION_IN = 1; +export const TRANSITION_OUT = 1 << 1; +export const TRANSITION_GLOBAL = 1 << 2; + /** List of Element events that will be delegated */ export const DelegatedEvents = [ 'beforeinput', diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 794a04ebb8..17917d848a 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -9,17 +9,8 @@ export const DIRTY = 1 << 9; export const MAYBE_DIRTY = 1 << 10; export const INERT = 1 << 11; export const DESTROYED = 1 << 12; - -export const ROOT_BLOCK = 0; -export const IF_BLOCK = 1; -export const EACH_BLOCK = 2; -export const EACH_ITEM_BLOCK = 3; -export const AWAIT_BLOCK = 4; -export const KEY_BLOCK = 5; -export const HEAD_BLOCK = 6; -export const DYNAMIC_COMPONENT_BLOCK = 7; -export const DYNAMIC_ELEMENT_BLOCK = 8; -export const SNIPPET_BLOCK = 9; +export const IS_ELSEIF = 1 << 13; +export const EFFECT_RAN = 1 << 14; export const UNINITIALIZED = Symbol(); export const STATE_SYMBOL = Symbol('$state'); diff --git a/packages/svelte/src/internal/client/dom/blocks/await.js b/packages/svelte/src/internal/client/dom/blocks/await.js index 1a12eb4b3e..d6820c058f 100644 --- a/packages/svelte/src/internal/client/dom/blocks/await.js +++ b/packages/svelte/src/internal/client/dom/blocks/await.js @@ -1,10 +1,16 @@ import { is_promise } from '../../../common.js'; import { hydrate_block_anchor } from '../hydration.js'; import { remove } from '../reconciler.js'; -import { current_block, execute_effect, flushSync } from '../../runtime.js'; -import { destroy_effect, render_effect } from '../../reactivity/effects.js'; -import { trigger_transitions } from '../elements/transitions.js'; -import { AWAIT_BLOCK, UNINITIALIZED } from '../../constants.js'; +import { + current_block, + current_component_context, + flushSync, + set_current_component_context, + set_current_effect, + set_current_reaction +} from '../../runtime.js'; +import { destroy_effect, pause_effect, render_effect } from '../../reactivity/effects.js'; +import { DESTROYED, INERT } from '../../constants.js'; /** @returns {import('../../types.js').AwaitBlock} */ export function create_await_block() { @@ -16,179 +22,125 @@ export function create_await_block() { // parent p: /** @type {import('../../types.js').Block} */ (current_block), // pending - n: true, - // transition - r: null, - // type - t: AWAIT_BLOCK + n: true }; } /** * @template V - * @param {Comment} anchor_node - * @param {(() => Promise)} input + * @param {Comment} anchor + * @param {(() => Promise)} get_input * @param {null | ((anchor: Node) => void)} pending_fn * @param {null | ((anchor: Node, value: V) => void)} then_fn * @param {null | ((anchor: Node, error: unknown) => void)} catch_fn * @returns {void} */ -export function await_block(anchor_node, input, pending_fn, then_fn, catch_fn) { +export function await_block(anchor, get_input, pending_fn, then_fn, catch_fn) { const block = create_await_block(); - /** @type {null | import('../../types.js').Render} */ - let current_render = null; - hydrate_block_anchor(anchor_node); - - /** @type {{}} */ - let latest_token; - - /** @type {typeof UNINITIALIZED | V} */ - let resolved_value = UNINITIALIZED; - - /** @type {unknown} */ - let error = UNINITIALIZED; - let pending = false; - block.r = - /** - * @param {import('../../types.js').Transition} transition - * @returns {void} - */ - (transition) => { - const render = /** @type {import('../../types.js').Render} */ (current_render); - const transitions = render.s; - transitions.add(transition); - transition.f(() => { - transitions.delete(transition); - if (transitions.size === 0) { - // If the current render has changed since, then we can remove the old render - // effect as it's stale. - if (current_render !== render && render.e !== null) { - if (render.d !== null) { - remove(render.d); - render.d = null; - } - destroy_effect(render.e); - render.e = null; - } - } - }); - }; - const create_render_effect = () => { - /** @type {import('../../types.js').Render} */ - const render = { - d: null, - e: null, - s: new Set(), - p: current_render - }; - const effect = render_effect( - () => { - if (error === UNINITIALIZED) { - if (resolved_value === UNINITIALIZED) { - // pending = true - block.n = true; - if (pending_fn !== null) { - pending_fn(anchor_node); - } - } else if (then_fn !== null) { - // pending = false - block.n = false; - then_fn(anchor_node, resolved_value); - } - } else if (catch_fn !== null) { - // pending = false - block.n = false; - catch_fn(anchor_node, error); + const component_context = current_component_context; + + hydrate_block_anchor(anchor); + + /** @type {any} */ + let input; + + /** @type {import('#client').Effect | null} */ + let pending_effect; + + /** @type {import('#client').Effect | null} */ + let then_effect; + + /** @type {import('#client').Effect | null} */ + let catch_effect; + + /** + * @param {(anchor: Comment, value: any) => void} fn + * @param {any} value + */ + function create_effect(fn, value) { + set_current_effect(branch); + set_current_reaction(branch); // TODO do we need both? + set_current_component_context(component_context); + var effect = render_effect(() => fn(anchor, value), {}, true); + set_current_component_context(null); + set_current_reaction(null); + set_current_effect(null); + + // without this, the DOM does not update until two ticks after the promise, + // resolves which is unexpected behaviour (and somewhat irksome to test) + flushSync(); + + return effect; + } + + /** @param {import('#client').Effect} effect */ + function pause(effect) { + if ((effect.f & DESTROYED) !== 0) return; + const block = effect.block; + + pause_effect(effect, () => { + // TODO make this unnecessary + const dom = block?.d; + if (dom) remove(dom); + }); + } + + const branch = render_effect(() => { + if (input === (input = get_input())) return; + + if (is_promise(input)) { + const promise = /** @type {Promise} */ (input); + + if (pending_fn) { + if (pending_effect && (pending_effect.f & INERT) === 0) { + if (pending_effect.block?.d) remove(pending_effect.block.d); + destroy_effect(pending_effect); } - render.d = block.d; - block.d = null; - }, - block, - true, - true - ); - render.e = effect; - current_render = render; - }; - const render = () => { - const render = current_render; - if (render === null) { - create_render_effect(); - return; - } - const transitions = render.s; - if (transitions.size === 0) { - if (render.d !== null) { - remove(render.d); - render.d = null; - } - if (render.e) { - execute_effect(render.e); - } else { - create_render_effect(); + + pending_effect = render_effect(() => pending_fn(anchor), {}, true); } - } else { - create_render_effect(); - trigger_transitions(transitions, 'out'); - } - }; - const await_effect = render_effect( - () => { - const token = {}; - latest_token = token; - const promise = input(); - if (is_promise(promise)) { - promise.then( - /** @param {V} v */ - (v) => { - if (latest_token === token) { - // Ensure UI is in sync before resolving value. - flushSync(); - resolved_value = v; - pending = false; - render(); - } - }, - /** @param {unknown} _error */ - (_error) => { - error = _error; - pending = false; - render(); + + if (then_effect) pause(then_effect); + if (catch_effect) pause(catch_effect); + + promise.then( + (value) => { + if (promise !== input) return; + if (pending_effect) pause(pending_effect); + + if (then_fn) { + then_effect = create_effect(then_fn, value); + } + }, + (error) => { + if (promise !== input) return; + if (pending_effect) pause(pending_effect); + + if (catch_fn) { + catch_effect = create_effect(catch_fn, error); } - ); - if (resolved_value !== UNINITIALIZED || error !== UNINITIALIZED) { - error = UNINITIALIZED; - resolved_value = UNINITIALIZED; } - if (!pending) { - pending = true; - render(); + ); + } else { + if (pending_effect) pause(pending_effect); + if (catch_effect) pause(catch_effect); + + if (then_fn) { + if (then_effect) { + if (then_effect.block?.d) remove(then_effect.block.d); + destroy_effect(then_effect); } - } else { - error = UNINITIALIZED; - resolved_value = promise; - pending = false; - render(); - } - }, - block, - false - ); - await_effect.ondestroy = () => { - let render = current_render; - latest_token = {}; - while (render !== null) { - const dom = render.d; - if (dom !== null) { - remove(dom); - } - const effect = render.e; - if (effect !== null) { - destroy_effect(effect); + + then_effect = render_effect(() => then_fn(anchor, input), {}, true); } - render = render.p; } + }, block); + + branch.ondestroy = () => { + // TODO this sucks, tidy it up + if (pending_effect?.block?.d) remove(pending_effect.block.d); + if (then_effect?.block?.d) remove(then_effect.block.d); + if (catch_effect?.block?.d) remove(catch_effect.block.d); }; - block.e = await_effect; } diff --git a/packages/svelte/src/internal/client/dom/blocks/css-props.js b/packages/svelte/src/internal/client/dom/blocks/css-props.js index aee98617d7..5bfb41dd91 100644 --- a/packages/svelte/src/internal/client/dom/blocks/css-props.js +++ b/packages/svelte/src/internal/client/dom/blocks/css-props.js @@ -2,7 +2,7 @@ import { namespace_svg } from '../../../../constants.js'; import { current_hydration_fragment, hydrate_block_anchor, hydrating } from '../hydration.js'; import { empty } from '../operations.js'; import { render_effect } from '../../reactivity/effects.js'; -import { insert, remove } from '../reconciler.js'; +import { remove } from '../reconciler.js'; /** * @param {Element | Text | Comment} anchor @@ -33,7 +33,7 @@ export function css_props(anchor, is_html, props, component) { tag = document.createElementNS(namespace_svg, 'g'); } - insert(tag, null, anchor); + anchor.before(tag); component_anchor = empty(); tag.appendChild(component_anchor); } diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index fd3cf82ae3..b815fdc8b0 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -6,7 +6,6 @@ import { EACH_ITEM_REACTIVE, EACH_KEYED } from '../../../../constants.js'; -import { noop } from '../../../common.js'; import { current_hydration_fragment, get_hydration_fragment, @@ -14,419 +13,297 @@ import { hydrating, set_current_hydration_fragment } from '../hydration.js'; -import { clear_text_content, empty } from '../operations.js'; +import { empty } from '../operations.js'; import { insert, remove } from '../reconciler.js'; -import { current_block, execute_effect } from '../../runtime.js'; -import { destroy_effect, render_effect } from '../../reactivity/effects.js'; +import { current_block, untrack } from '../../runtime.js'; +import { + destroy_effect, + pause_effect, + render_effect, + resume_effect, + user_effect +} from '../../reactivity/effects.js'; import { source, mutable_source, set } from '../../reactivity/sources.js'; -import { trigger_transitions } from '../elements/transitions.js'; import { is_array, is_frozen, map_get, map_set } from '../../utils.js'; -import { EACH_BLOCK, EACH_ITEM_BLOCK, STATE_SYMBOL } from '../../constants.js'; +import { STATE_SYMBOL } from '../../constants.js'; -const NEW_BLOCK = -1; -const MOVED_BLOCK = 99999999; -const LIS_BLOCK = -2; +var NEW_BLOCK = -1; +var LIS_BLOCK = -2; /** - * @param {number} flags - * @param {Element | Comment} anchor - * @returns {import('../../types.js').EachBlock} + * 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 {import('#client').EachItemBlock | null} */ -export function create_each_block(flags, anchor) { - return { - // anchor - a: anchor, - // dom - d: null, - // flags - f: flags, - // items - v: [], - // effect - e: null, - p: /** @type {import('../../types.js').Block} */ (current_block), - // transition - r: null, - // transitions - s: [], - // type - t: EACH_BLOCK - }; -} +export let current_each_item_block = null; -/** - * @param {any | import('../../types.js').Value} item - * @param {number | import('../../types.js').Value} index - * @param {null | unknown} key - * @returns {import('../../types.js').EachItemBlock} - */ -export function create_each_item_block(item, index, key) { - return { - // animate transition - a: null, - // dom - d: null, - // effect - e: null, - // index - i: index, - // key - k: key, - // item - v: item, - // parent - p: /** @type {import('../../types.js').EachBlock} */ (current_block), - // transition - r: null, - // transitions - s: null, - // type - t: EACH_ITEM_BLOCK - }; +/** @param {import('#client').EachItemBlock | null} block */ +export function set_current_each_item_block(block) { + current_each_item_block = block; } /** * @template V - * @param {Element | Comment} anchor_node - * @param {() => V[]} collection + * @param {Element | Comment} anchor The next sibling node, or the parent node if this is a 'controlled' block + * @param {() => V[]} get_collection * @param {number} flags - * @param {null | ((item: V) => string)} key_fn + * @param {null | ((item: V) => string)} get_key * @param {(anchor: null, item: V, index: import('#client').MaybeSource) => void} render_fn * @param {null | ((anchor: Node) => void)} fallback_fn * @param {typeof reconcile_indexed_array | reconcile_tracked_array} reconcile_fn * @returns {void} */ -function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, reconcile_fn) { - const is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; - const block = create_each_block(flags, anchor_node); - - /** @type {null | import('../../types.js').Render} */ - let current_fallback = null; - hydrate_block_anchor(anchor_node, is_controlled); - - /** @type {V[]} */ - let array; - - /** @type {Array | null} */ - let keys = null; - - /** @type {null | import('../../types.js').Effect} */ - let render = null; - - /** - * Whether or not there was a "rendered fallback but want to render items" (or vice versa) hydration mismatch. - * Needs to be a `let` or else it isn't treeshaken out - */ - let mismatch = false; - - block.r = - /** @param {import('../../types.js').Transition} transition */ - (transition) => { - const fallback = /** @type {import('../../types.js').Render} */ (current_fallback); - const transitions = fallback.s; - transitions.add(transition); - transition.f(() => { - transitions.delete(transition); - if (transitions.size === 0) { - if (fallback.e !== null) { - if (fallback.d !== null) { - remove(fallback.d); - fallback.d = null; - } - destroy_effect(fallback.e); - fallback.e = null; - } - } - }); - }; - - const create_fallback_effect = () => { - /** @type {import('../../types.js').Render} */ - const fallback = { - d: null, - e: null, - s: new Set(), - p: current_fallback - }; - // Managed effect - const effect = render_effect( - () => { - const dom = block.d; - if (dom !== null) { - remove(dom); - block.d = null; - } - let anchor = block.a; - const is_controlled = (block.f & EACH_IS_CONTROLLED) !== 0; - if (is_controlled) { - // If the each block is controlled, then the anchor node will be the surrounding - // element in which the each block is rendered, which requires certain handling - // depending on whether we're in hydration mode or not - if (!hydrating) { - // Create a new anchor on the fly because there's none due to the optimization - anchor = empty(); - block.a.appendChild(anchor); - } else { - // In case of hydration the anchor will be the first child of the surrounding element - anchor = /** @type {Comment} */ (anchor.firstChild); - } - } - /** @type {(anchor: Node) => void} */ (fallback_fn)(anchor); - fallback.d = block.d; - block.d = null; - }, - block, - true - ); - fallback.e = effect; - current_fallback = fallback; +function each(anchor, get_collection, flags, get_key, render_fn, fallback_fn, reconcile_fn) { + /** @type {import('#client').EachBlock} */ + var block = { + // dom + d: null, + // flags + f: flags, + // items + v: [], + // effect + e: null, + p: /** @type {import('#client').Block} */ (current_block) }; - /** @param {import('../../types.js').EachBlock} block */ - const render_each = (block) => { - const flags = block.f; - const is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; - const anchor_node = block.a; - reconcile_fn(array, block, anchor_node, is_controlled, render_fn, flags, true, keys); - }; + var is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; + hydrate_block_anchor(is_controlled ? /** @type {Node} */ (anchor.firstChild) : anchor); - const each = render_effect( + if (is_controlled) { + var parent_node = /** @type {Element} */ (anchor); + parent_node.append((anchor = empty())); + } + + /** @type {import('#client').Effect | null} */ + var fallback = null; + + var effect = render_effect( () => { - /** @type {V[]} */ - const maybe_array = collection(); - array = is_array(maybe_array) - ? maybe_array - : maybe_array == null + var collection = get_collection(); + + var array = is_array(collection) + ? collection + : collection == null ? [] - : Array.from(maybe_array); + : Array.from(collection); + + var keys = get_key === null ? array : array.map(get_key); + + var length = array.length; + + // If we are working with an array that isn't proxied or frozen, then remove strict equality and ensure the items + // are treated as reactive, so they get wrapped in a signal. + var flags = block.f; + if ((flags & EACH_IS_STRICT_EQUALS) !== 0 && !is_frozen(array) && !(STATE_SYMBOL in array)) { + flags ^= EACH_IS_STRICT_EQUALS; - if (key_fn !== null) { - keys = array.map(key_fn); - } else if ((flags & EACH_KEYED) === 0) { - array.map(noop); + // Additionally if we're in an keyed each block, we'll need ensure the items are all wrapped in signals. + if ((flags & EACH_KEYED) !== 0 && (flags & EACH_ITEM_REACTIVE) === 0) { + flags ^= EACH_ITEM_REACTIVE; + } } - const 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) { - const is_each_else_comment = + var is_else = /** @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)) { + + if (is_else !== (length === 0)) { + // hydration mismatch — remove the server-rendered DOM and start over remove(current_hydration_fragment); set_current_hydration_fragment(null); mismatch = true; - } else if (is_each_else_comment) { + } else if (is_else) { // Remove the each_else comment node or else it will confuse the subsequent hydration algorithm - /** @type {import('../../types.js').TemplateNode[]} */ ( - current_hydration_fragment - ).shift(); + /** @type {import('#client').TemplateNode[]} */ (current_hydration_fragment).shift(); + } + } + + // this is separate to the previous block because `hydrating` might change + if (hydrating) { + var b_blocks = []; + + // Hydrate block + var hydration_list = /** @type {import('#client').TemplateNode[]} */ ( + current_hydration_fragment + ); + var hydrating_node = hydration_list[0]; + + for (var i = 0; i < length; i++) { + var fragment = get_hydration_fragment(hydrating_node); + 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 + mismatch = true; + break; + } + + b_blocks[i] = create_block(array[i], keys?.[i], i, render_fn, flags); + + // TODO helperise this + hydrating_node = /** @type {import('#client').TemplateNode} */ ( + /** @type {Node} */ ( + /** @type {Node} */ (fragment[fragment.length - 1] || hydrating_node).nextSibling + ).nextSibling + ); } + + remove_excess_hydration_nodes(hydration_list, hydrating_node); + + block.v = b_blocks; + } + + if (!hydrating) { + // TODO add 'empty controlled block' optimisation here + reconcile_fn(array, block, anchor, render_fn, flags, keys); } if (fallback_fn !== null) { if (length === 0) { - if (block.v.length !== 0 || render === null) { - render_each(block); - create_fallback_effect(); - return; - } - } else if (block.v.length === 0 && current_fallback !== null) { - const fallback = current_fallback; - const transitions = fallback.s; - if (transitions.size === 0) { - if (fallback.d !== null) { - remove(fallback.d); - fallback.d = null; - } + if (fallback) { + resume_effect(fallback); } else { - trigger_transitions(transitions, 'out'); + fallback = render_effect( + () => { + fallback_fn(anchor); + var dom = block.d; // TODO would be nice if this was just returned from the managed effect function... + + return () => { + if (dom !== null) { + remove(dom); + dom = null; + } + }; + }, + block, + true + ); } + } else if (fallback !== null) { + pause_effect(fallback, () => { + fallback = null; + }); } } - if (render !== null) { - execute_effect(render); + if (mismatch) { + // Set a fragment so that Svelte continues to operate in hydration mode + set_current_hydration_fragment([]); } }, block, false ); - render = render_effect(render_each, block, true); - - if (mismatch) { - // Set a fragment so that Svelte continues to operate in hydration mode - set_current_hydration_fragment([]); - } - - each.ondestroy = () => { - const flags = block.f; - const anchor_node = block.a; - const is_controlled = (flags & EACH_IS_CONTROLLED) !== 0; - let fallback = current_fallback; - while (fallback !== null) { - const dom = fallback.d; - if (dom !== null) { - remove(dom); - } - const effect = fallback.e; - if (effect !== null) { - destroy_effect(effect); + effect.ondestroy = () => { + for (var b of block.v) { + if (b.d !== null) { + destroy_effect(b.e); + remove(b.d); } - fallback = fallback.p; } - // Clear the array - reconcile_fn([], block, anchor_node, is_controlled, render_fn, flags, false, keys); - destroy_effect(/** @type {import('#client').Effect} */ (render)); + + if (fallback) destroy_effect(fallback); }; - block.e = each; + block.e = effect; } /** * @template V - * @param {Element | Comment} anchor_node - * @param {() => V[]} collection + * @param {Element | Comment} anchor + * @param {() => V[]} get_collection * @param {number} flags - * @param {null | ((item: V) => string)} key_fn + * @param {null | ((item: V) => string)} get_key * @param {(anchor: null, item: V, index: import('#client').MaybeSource) => void} render_fn * @param {null | ((anchor: Node) => void)} fallback_fn * @returns {void} */ -export function each_keyed(anchor_node, collection, flags, key_fn, render_fn, fallback_fn) { - each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, reconcile_tracked_array); +export function each_keyed(anchor, get_collection, flags, get_key, render_fn, fallback_fn) { + each(anchor, get_collection, flags, get_key, render_fn, fallback_fn, reconcile_tracked_array); } /** * @template V - * @param {Element | Comment} anchor_node - * @param {() => V[]} collection + * @param {Element | Comment} anchor + * @param {() => V[]} get_collection * @param {number} flags * @param {(anchor: null, item: V, index: import('#client').MaybeSource) => void} render_fn * @param {null | ((anchor: Node) => void)} fallback_fn * @returns {void} */ -export function each_indexed(anchor_node, collection, flags, render_fn, fallback_fn) { - each(anchor_node, collection, flags, null, render_fn, fallback_fn, reconcile_indexed_array); +export function each_indexed(anchor, get_collection, flags, render_fn, fallback_fn) { + each(anchor, get_collection, flags, null, render_fn, fallback_fn, reconcile_indexed_array); } /** * @template V * @param {Array} array - * @param {import('../../types.js').EachBlock} each_block - * @param {Element | Comment | Text} dom - * @param {boolean} is_controlled - * @param {(anchor: null, item: V, index: number | import('../../types.js').Source) => void} render_fn + * @param {import('#client').EachBlock} each_block + * @param {Element | Comment | Text} anchor + * @param {(anchor: null, item: V, index: number | import('#client').Source) => void} render_fn * @param {number} flags - * @param {boolean} apply_transitions * @returns {void} */ -function reconcile_indexed_array( - array, - each_block, - dom, - is_controlled, - render_fn, - flags, - apply_transitions -) { - // If we are working with an array that isn't proxied or frozen, then remove strict equality and ensure the items - // are treated as reactive, so they get wrapped in a signal. - if ((flags & EACH_IS_STRICT_EQUALS) !== 0 && !is_frozen(array) && !(STATE_SYMBOL in array)) { - flags ^= EACH_IS_STRICT_EQUALS; - } +function reconcile_indexed_array(array, each_block, anchor, render_fn, flags) { var a_blocks = each_block.v; - var active_transitions = each_block.s; - /** @type {number | void} */ var a = a_blocks.length; - - /** @type {number} */ var b = array.length; - var length = Math.max(a, b); - var index = 0; + var min = Math.min(a, b); - /** @type {Array} */ - var b_blocks; - var block; + /** @type {typeof a_blocks} */ + var b_blocks = Array(b); - if (active_transitions.length !== 0) { - destroy_active_transition_blocks(active_transitions); + var block; + var item; + + // update items + for (var i = 0; i < min; i += 1) { + item = array[i]; + block = a_blocks[i]; + b_blocks[i] = block; + update_block(block, item, i, flags); + resume_effect(block.e); } - if (b === 0) { - b_blocks = []; - // Remove old blocks - if (is_controlled && a !== 0) { - clear_text_content(dom); - } - while (index < length) { - block = a_blocks[index++]; - destroy_each_item_block(block, active_transitions, apply_transitions, is_controlled); + if (b > a) { + // add items + for (; i < b; i += 1) { + item = array[i]; + block = create_block(item, null, i, render_fn, flags); + b_blocks[i] = block; + insert_block(block, anchor); } - } else { - var item; - /** `true` if there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ - let mismatch = false; - b_blocks = Array(b); - if (hydrating) { - // Hydrate block - var hydration_list = /** @type {import('../../types.js').TemplateNode[]} */ ( - current_hydration_fragment - ); - var hydrating_node = hydration_list[0]; - for (; index < length; index++) { - var fragment = get_hydration_fragment(hydrating_node); - 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 - mismatch = true; - break; - } - item = array[index]; - block = each_item_block(item, null, index, render_fn, flags); - b_blocks[index] = block; + each_block.v = b_blocks; + } else if (a > b) { + // remove items + var remaining = a - b; - hydrating_node = /** @type {import('../../types.js').TemplateNode} */ ( - /** @type {Node} */ (/** @type {Node} */ (fragment[fragment.length - 1]).nextSibling) - .nextSibling - ); + var clear = () => { + for (var i = b; i < a; i += 1) { + var block = a_blocks[i]; + if (block.d) remove(block.d); } - remove_excess_hydration_nodes(hydration_list, hydrating_node); - } + each_block.v = each_block.v.slice(0, b); + }; - for (; index < length; index++) { - if (index >= a) { - // Add block - item = array[index]; - 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); + var check = () => { + if (--remaining === 0) { + clear(); } - } + }; - if (mismatch) { - // Server rendered less nodes than the client -> set empty array so that Svelte continues to operate in hydration mode - set_current_hydration_fragment([]); + for (; i < a; i += 1) { + pause_effect(a_blocks[i].e, check); } } - - each_block.v = b_blocks; } /** @@ -435,252 +312,193 @@ function reconcile_indexed_array( * https://github.com/localvoid/ivi/blob/9f1bd0918f487da5b131941228604763c5d8ef56/packages/ivi/src/client/core.ts#L968 * @template V * @param {Array} array - * @param {import('../../types.js').EachBlock} each_block - * @param {Element | Comment | Text} dom - * @param {boolean} is_controlled - * @param {(anchor: null, item: V, index: number | import('../../types.js').Source) => void} render_fn + * @param {import('#client').EachBlock} each_block + * @param {Element | Comment | Text} anchor + * @param {(anchor: null, item: V, index: number | import('#client').Source) => void} render_fn * @param {number} flags - * @param {boolean} apply_transitions - * @param {Array | null} keys + * @param {any[]} keys * @returns {void} */ -function reconcile_tracked_array( - array, - each_block, - dom, - is_controlled, - render_fn, - flags, - apply_transitions, - keys -) { - // If we are working with an array that isn't proxied or frozen, then remove strict equality and ensure the items - // are treated as reactive, so they get wrapped in a signal. - if ((flags & EACH_IS_STRICT_EQUALS) !== 0 && !is_frozen(array) && !(STATE_SYMBOL in array)) { - flags ^= EACH_IS_STRICT_EQUALS; - // Additionally as we're in an keyed each block, we'll need ensure the itens are all wrapped in signals. - if ((flags & EACH_ITEM_REACTIVE) === 0) { - flags ^= EACH_ITEM_REACTIVE; - } - } +function reconcile_tracked_array(array, each_block, anchor, render_fn, flags, keys) { var a_blocks = each_block.v; - const is_computed_key = keys !== null; - var active_transitions = each_block.s; - /** @type {number | void} */ var a = a_blocks.length; - - /** @type {number} */ var b = array.length; - /** @type {Array} */ - var b_blocks; + /** @type {Array} */ + var b_blocks = Array(b); + + var is_animated = (flags & EACH_IS_ANIMATED) !== 0; + var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0; + var start = 0; var block; - if (active_transitions.length !== 0) { - destroy_active_transition_blocks(active_transitions); - } + /** @type {Array} */ + var to_destroy = []; - if (b === 0) { - b_blocks = []; - // Remove old blocks - if (is_controlled && a !== 0) { - clear_text_content(dom); + // Step 1 — trim common suffix + while (a > 0 && b > 0 && a_blocks[a - 1].k === keys[b - 1]) { + block = b_blocks[--b] = a_blocks[--a]; + anchor = get_first_child(block); + + resume_effect(block.e); + + if (should_update) { + update_block(block, array[b], b, flags); } - while (a > 0) { - block = a_blocks[--a]; - destroy_each_item_block(block, active_transitions, apply_transitions, is_controlled); + } + + // Step 2 — trim common prefix + while (start < a && start < b && a_blocks[start].k === keys[start]) { + block = b_blocks[start] = a_blocks[start]; + + resume_effect(block.e); + + if (should_update) { + update_block(block, array[start], start, flags); } - } else { - var a_end = a - 1; - var b_end = b - 1; - var key; - var item; - var idx; - /** `true` if there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ - let mismatch = false; - b_blocks = Array(b); - if (hydrating) { - // Hydrate block - var fragment; - var hydration_list = /** @type {import('../../types.js').TemplateNode[]} */ ( - current_hydration_fragment - ); - var hydrating_node = hydration_list[0]; - while (b > 0) { - fragment = get_hydration_fragment(hydrating_node); - 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 - mismatch = true; - 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 tag of the next item in the list - // The fragment array can be empty if each block has no content - hydrating_node = /** @type {import('../../types.js').TemplateNode} */ ( - /** @type {Node} */ ((fragment[fragment.length - 1] || hydrating_node).nextSibling) - .nextSibling - ); - } + start += 1; + } - remove_excess_hydration_nodes(hydration_list, hydrating_node); + // Step 3 — update + if (start === a) { + // add only + while (start < b) { + block = create_block(array[start], keys[start], start, render_fn, flags); + b_blocks[start++] = block; + insert_block(block, anchor); + } + } else if (start === b) { + // remove only + while (start < a) { + to_destroy.push(a_blocks[start++]); + } + } else { + // reconcile + var moved = false; + var sources = new Int32Array(b - start); + var indexes = new Map(); + var i; + var index; + var last_block; + var last_sibling; + + // store the indexes of each block in the new world + for (i = start; i < b; i += 1) { + sources[i - start] = NEW_BLOCK; + map_set(indexes, keys[i], i); } - if (a === 0) { - // Create new blocks - while (b > 0) { - 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; - insert_each_item_block(block, dom, is_controlled, null); - } - } else { - var is_animated = (flags & EACH_IS_ANIMATED) !== 0; - var should_update_block = - (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0 || is_animated; - var start = 0; - - /** @type {null | Text | Element | Comment} */ - var sibling = null; - item = array[b_end]; - key = is_computed_key ? keys[b_end] : item; - // Step 1 - outer: while (true) { - // From the end - while (a_blocks[a_end].k === key) { - block = a_blocks[a_end--]; - item = array[b_end]; - if (should_update_block) { - update_each_item_block(block, item, b_end, flags); - } - sibling = get_first_child(block); - b_blocks[b_end] = block; - if (start > --b_end || start > a_end) { - break outer; - } - key = is_computed_key ? keys[b_end] : item; - } - item = array[start]; - key = is_computed_key ? keys[start] : item; - // At the start - while (start <= a_end && start <= b_end && a_blocks[start].k === key) { - item = array[start]; - block = a_blocks[start]; - if (should_update_block) { - update_each_item_block(block, item, start, flags); - } - b_blocks[start] = block; - ++start; - key = is_computed_key ? keys[start] : array[start]; + /** @type {Array} */ + var to_animate = []; + if (is_animated) { + // for all blocks that were in both the old and the new list, + // measure them and store them in `to_animate` so we can + // apply animations once the DOM has been updated + for (i = 0; i < a_blocks.length; i += 1) { + block = a_blocks[i]; + if (indexes.has(block.k)) { + block.a?.measure(); + to_animate.push(block); } - break; } - // Step 2 - if (start > a_end) { - while (b_end >= start) { - item = array[b_end]; - key = is_computed_key ? keys[b_end] : item; - block = each_item_block(item, key, b_end, render_fn, flags); - b_blocks[b_end--] = block; - sibling = insert_each_item_block(block, dom, is_controlled, sibling); - } - } else if (start > b_end) { - b = start; - do { - if ((block = a_blocks[b++]) !== null) { - destroy_each_item_block(block, active_transitions, apply_transitions); - } - } while (b <= a_end); + } + + // populate the `sources` array for each old block with + // its new index, so that we can calculate moves + for (i = start; i < a; i += 1) { + block = a_blocks[i]; + index = map_get(indexes, block.k); + + resume_effect(block.e); + + if (index === undefined) { + to_destroy.push(block); } else { - // Step 3 - var pos = 0; - var b_length = b_end - start + 1; - var sources = new Int32Array(b_length); - var item_index = new Map(); - for (b = 0; b < b_length; ++b) { - a = b + start; - sources[b] = NEW_BLOCK; - item = array[a]; - key = is_computed_key ? keys[a] : item; - map_set(item_index, key, a); - } - // If keys are animated, we need to do updates before actual moves + moved = true; + sources[index - start] = i; + b_blocks[index] = block; + if (is_animated) { - for (b = start; b <= a_end; ++b) { - a = map_get(item_index, /** @type {V} */ (a_blocks[b].k)); - if (a !== undefined) { - item = array[a]; - block = a_blocks[b]; - update_each_item_block(block, item, a, flags); - } - } + to_animate.push(block); } - for (b = start; b <= a_end; ++b) { - a = map_get(item_index, /** @type {V} */ (a_blocks[b].k)); - block = a_blocks[b]; - if (a !== undefined) { - pos = pos < a ? a : MOVED_BLOCK; - sources[a - start] = b; - b_blocks[a] = block; - } else if (block !== null) { - destroy_each_item_block(block, active_transitions, apply_transitions); - } - } - // Step 4 - if (pos === MOVED_BLOCK) { - mark_lis(sources); - } - var last_block; - var last_sibling; - var should_create; - while (b_length-- > 0) { - b_end = b_length + start; - a = sources[b_length]; - should_create = a === -1; - item = array[b_end]; - if (should_create) { - key = is_computed_key ? keys[b_end] : item; - block = each_item_block(item, key, b_end, render_fn, flags); - } else { - block = b_blocks[b_end]; - if (!is_animated && should_update_block) { - update_each_item_block(block, item, b_end, flags); - } - } - if (should_create || (pos === MOVED_BLOCK && a !== LIS_BLOCK)) { - last_sibling = last_block === undefined ? sibling : get_first_child(last_block); - sibling = insert_each_item_block(block, dom, is_controlled, last_sibling); - } - b_blocks[b_end] = block; - last_block = block; + } + } + + // if we need to move blocks (as opposed to just adding/removing), + // figure out how to do so efficiently (I would be lying if I said + // I fully understand this part) + if (moved) { + mark_lis(sources); + } + + // working from the back, insert new or moved blocks + while (b-- > start) { + index = sources[b - start]; + var insert = index === NEW_BLOCK; + + if (insert) { + block = create_block(array[b], keys[b], b, render_fn, flags); + } else { + block = b_blocks[b]; + if (should_update) { + update_block(block, array[b], b, flags); } } + + if (insert || (moved && index !== LIS_BLOCK)) { + last_sibling = last_block === undefined ? anchor : get_first_child(last_block); + anchor = insert_block(block, last_sibling); + } + + last_block = b_blocks[b] = block; } - if (mismatch) { - // Server rendered less nodes than the client -> set empty array so that Svelte continues to operate in hydration mode - set_current_hydration_fragment([]); + if (to_animate.length > 0) { + // TODO we need to briefly take any outroing elements out of the flow, so that + // we can figure out the eventual destination of the animating elements + // - https://github.com/sveltejs/svelte/pull/10798#issuecomment-2013681778 + // - https://svelte.dev/repl/6e891305e9644a7ca7065fa95c79d2d2?version=4.2.9 + user_effect(() => { + untrack(() => { + for (block of to_animate) { + block.a?.apply(); + } + }); + }); } } - each_block.v = b_blocks; + var remaining = to_destroy.length; + if (remaining > 0) { + var clear = () => { + for (block of to_destroy) { + if (block.d) remove(block.d); + } + + each_block.v = b_blocks; + }; + + var check = () => { + if (--remaining === 0) { + clear(); + } + }; + + for (block of to_destroy) { + pause_effect(block.e, check); + } + } else { + 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 + * @param {import('#client').TemplateNode[]} hydration_list + * @param {import('#client').TemplateNode | null} next_node */ function remove_excess_hydration_nodes(hydration_list, next_node) { if (next_node === null) return; @@ -764,28 +582,17 @@ function mark_lis(a) { } /** - * @param {import('../../types.js').Block} block - * @param {Element | Comment | Text} dom - * @param {boolean} is_controlled - * @param {null | Text | Element | Comment} sibling + * @param {import('#client').Block} block + * @param {Text | Element | Comment} sibling * @returns {Text | Element | Comment} */ -function insert_each_item_block(block, dom, is_controlled, sibling) { - var current = /** @type {import('../../types.js').TemplateNode} */ (block.d); - - if (sibling === null) { - if (is_controlled) { - return insert(current, /** @type {Element} */ (dom), null); - } else { - return insert(current, /** @type {Element} */ (dom.parentNode), dom); - } - } - - return insert(current, null, sibling); +function insert_block(block, sibling) { + var current = /** @type {import('#client').TemplateNode} */ (block.d); + return insert(current, sibling); } /** - * @param {import('../../types.js').Block} block + * @param {import('#client').Block} block * @returns {Text | Element | Comment} */ function get_first_child(block) { @@ -799,144 +606,73 @@ function get_first_child(block) { } /** - * @param {Array} active_transitions - * @returns {void} - */ -function destroy_active_transition_blocks(active_transitions) { - var length = active_transitions.length; - - if (length > 0) { - var i = 0; - var block; - var transition; - - for (; i < length; i++) { - block = active_transitions[i]; - transition = block.r; - if (transition !== null) { - block.r = null; - destroy_each_item_block(block, null, false); - } - } - - active_transitions.length = 0; - } -} - -/** - * @param {import('../../types.js').Block} block - * @returns {Text | Element | Comment} - */ -export function get_first_element(block) { - const current = block.d; - - if (is_array(current)) { - for (let i = 0; i < current.length; i++) { - const node = current[i]; - if (node.nodeType !== 8) { - return node; - } - } - } - - return /** @type {Text | Element | Comment} */ (current); -} - -/** - * @param {import('../../types.js').EachItemBlock} block + * @param {import('#client').EachItemBlock} block * @param {any} item * @param {number} index * @param {number} type * @returns {void} */ -function update_each_item_block(block, item, index, type) { - const block_v = block.v; +function update_block(block, item, index, type) { if ((type & EACH_ITEM_REACTIVE) !== 0) { - set(block_v, item); - } - const transitions = block.s; - const index_is_reactive = (type & EACH_INDEX_REACTIVE) !== 0; - // Handle each item animations - const each_animation = block.a; - if (transitions !== null && (type & EACH_KEYED) !== 0 && each_animation !== null) { - each_animation(block, transitions); + set(block.v, item); } - if (index_is_reactive) { - set(/** @type {import('../../types.js').Value} */ (block.i), index); + + if ((type & EACH_INDEX_REACTIVE) !== 0) { + set(/** @type {import('#client').Value} */ (block.i), index); } else { block.i = index; } } -/** - * @param {import('../../types.js').EachItemBlock} block - * @param {null | Array} transition_block - * @param {boolean} apply_transitions - * @param {any} controlled - * @returns {void} - */ -export function destroy_each_item_block( - block, - transition_block, - apply_transitions, - controlled = false -) { - const transitions = block.s; - - if (apply_transitions && transitions !== null) { - // We might have pending key transitions, if so remove them first - for (let other of transitions) { - if (other.r === 'key') { - transitions.delete(other); - } - } - if (transitions.size === 0) { - block.s = null; - } else { - trigger_transitions(transitions, 'out'); - if (transition_block !== null) { - transition_block.push(block); - } - return; - } - } - const dom = block.d; - if (!controlled && dom !== null) { - remove(dom); - } - destroy_effect(/** @type {import('#client').Effect} */ (block.e)); -} - /** * @template V * @param {V} item * @param {unknown} key * @param {number} index - * @param {(anchor: null, item: V, index: number | import('../../types.js').Value) => void} render_fn + * @param {(anchor: null, item: V, index: number | import('#client').Value) => void} render_fn * @param {number} flags - * @returns {import('../../types.js').EachItemBlock} + * @returns {import('#client').EachItemBlock} */ -function each_item_block(item, key, index, render_fn, flags) { - const each_item_not_reactive = (flags & EACH_ITEM_REACTIVE) === 0; +function create_block(item, key, index, render_fn, flags) { + var each_item_not_reactive = (flags & EACH_ITEM_REACTIVE) === 0; - const item_value = each_item_not_reactive + var item_value = each_item_not_reactive ? item : (flags & EACH_IS_STRICT_EQUALS) !== 0 ? source(item) : mutable_source(item); - const index_value = (flags & EACH_INDEX_REACTIVE) === 0 ? index : source(index); - const block = create_each_item_block(item_value, index_value, key); + /** @type {import('#client').EachItemBlock} */ + var block = { + a: null, + // dom + d: null, + // effect + // @ts-expect-error + e: null, + // index + i: (flags & EACH_INDEX_REACTIVE) === 0 ? index : source(index), + // key + k: key, + // item + v: item_value + }; - const effect = render_effect( - /** @param {import('../../types.js').EachItemBlock} block */ - (block) => { - render_fn(null, block.v, block.i); - }, - block, - true - ); + var previous_each_item_block = current_each_item_block; - block.e = effect; - return block; + try { + current_each_item_block = block; + + block.e = render_effect( + () => { + render_fn(null, block.v, block.i); + }, + block, + true + ); + + return block; + } finally { + current_each_item_block = previous_each_item_block; + } } diff --git a/packages/svelte/src/internal/client/dom/blocks/if.js b/packages/svelte/src/internal/client/dom/blocks/if.js index a8292a9f02..82677d0bc4 100644 --- a/packages/svelte/src/internal/client/dom/blocks/if.js +++ b/packages/svelte/src/internal/client/dom/blocks/if.js @@ -1,4 +1,4 @@ -import { IF_BLOCK } from '../../constants.js'; +import { IS_ELSEIF } from '../../constants.js'; import { current_hydration_fragment, hydrate_block_anchor, @@ -6,50 +6,40 @@ import { set_current_hydration_fragment } from '../hydration.js'; import { remove } from '../reconciler.js'; -import { current_block, execute_effect } from '../../runtime.js'; -import { destroy_effect, render_effect } from '../../reactivity/effects.js'; -import { trigger_transitions } from '../elements/transitions.js'; +import { current_block } from '../../runtime.js'; +import { + destroy_effect, + pause_effect, + render_effect, + resume_effect +} from '../../reactivity/effects.js'; /** @returns {import('#client').IfBlock} */ function create_if_block() { return { - // alternate transitions - a: null, - // alternate effect - ae: null, - // consequent transitions - c: null, - // consequent effect - ce: null, // dom d: null, // effect e: null, // parent p: /** @type {import('#client').Block} */ (current_block), - // transition - r: null, - // type - t: IF_BLOCK, // value v: false }; } /** - * @param {Comment} anchor_node - * @param {() => boolean} condition_fn + * @param {Comment} anchor + * @param {() => boolean} get_condition * @param {(anchor: Node) => void} consequent_fn * @param {null | ((anchor: Node) => void)} alternate_fn + * @param {boolean} [elseif] True if this is an `{:else if ...}` block rather than an `{#if ...}`, as that affects which transitions are considered 'local' * @returns {void} */ -export function if_block(anchor_node, condition_fn, consequent_fn, alternate_fn) { +export function if_block(anchor, get_condition, consequent_fn, alternate_fn, elseif = false) { const block = create_if_block(); - hydrate_block_anchor(anchor_node); - - /** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ - let mismatch = false; + hydrate_block_anchor(anchor); /** @type {null | import('#client').TemplateNode | Array} */ let consequent_dom = null; @@ -57,134 +47,118 @@ export function if_block(anchor_node, condition_fn, consequent_fn, alternate_fn) /** @type {null | import('#client').TemplateNode | Array} */ let alternate_dom = null; - let has_mounted = false; + /** @type {import('#client').Effect | null} */ + let consequent_effect = null; - /** - * @type {import('#client').Effect | null} - */ - let current_branch_effect = null; + /** @type {import('#client').Effect | null} */ + let alternate_effect = null; - /** @type {import('#client').Effect} */ - let consequent_effect; - - /** @type {import('#client').Effect} */ - let alternate_effect; + /** @type {boolean | null} */ + let condition = null; const if_effect = render_effect(() => { - const result = !!condition_fn(); - - if (block.v !== result || !has_mounted) { - block.v = result; - - if (has_mounted) { - const consequent_transitions = block.c; - const alternate_transitions = block.a; - - if (result) { - if (alternate_transitions === null || alternate_transitions.size === 0) { - execute_effect(alternate_effect); - } else { - trigger_transitions(alternate_transitions, 'out'); - } - - if (consequent_transitions === null || consequent_transitions.size === 0) { - execute_effect(consequent_effect); - } else { - trigger_transitions(consequent_transitions, 'in'); - } - } else { - if (consequent_transitions === null || consequent_transitions.size === 0) { - execute_effect(consequent_effect); - } else { - trigger_transitions(consequent_transitions, 'out'); - } - - if (alternate_transitions === null || alternate_transitions.size === 0) { - execute_effect(alternate_effect); - } else { - trigger_transitions(alternate_transitions, 'in'); - } - } - } else if (hydrating) { - const comment_text = /** @type {Comment} */ (current_hydration_fragment?.[0])?.data; - - if ( - !comment_text || - (comment_text === 'ssr:if:true' && !result) || - (comment_text === 'ssr:if:false' && result) - ) { - // Hydration mismatch: remove everything inside the anchor and start fresh. - // This could happen using when `{#if browser} .. {/if}` in SvelteKit. - remove(current_hydration_fragment); - set_current_hydration_fragment(null); - mismatch = true; - } else { - // Remove the ssr:if comment node or else it will confuse the subsequent hydration algorithm - current_hydration_fragment.shift(); - } + if (condition === (condition = !!get_condition())) return; + + /** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ + let mismatch = false; + + if (hydrating) { + const comment_text = /** @type {Comment} */ (current_hydration_fragment?.[0])?.data; + + if ( + !comment_text || + (comment_text === 'ssr:if:true' && !condition) || + (comment_text === 'ssr:if:false' && condition) + ) { + // Hydration mismatch: remove everything inside the anchor and start fresh. + // This could happen using when `{#if browser} .. {/if}` in SvelteKit. + remove(current_hydration_fragment); + set_current_hydration_fragment(null); + mismatch = true; + } else { + // Remove the ssr:if comment node or else it will confuse the subsequent hydration algorithm + current_hydration_fragment.shift(); } - - has_mounted = true; } - // create these here so they have the correct parent/child relationship - consequent_effect ??= render_effect( - (/** @type {any} */ _, /** @type {import('#client').Effect | null} */ consequent_effect) => { - const result = block.v; - - if (!result && consequent_dom !== null) { - remove(consequent_dom); - consequent_dom = null; - } - - if (result && current_branch_effect !== consequent_effect) { - consequent_fn(anchor_node); - if (mismatch && current_branch_effect === null) { - // Set fragment so that Svelte continues to operate in hydration mode - set_current_hydration_fragment([]); - } - current_branch_effect = consequent_effect; - consequent_dom = block.d; - } - - block.d = null; - }, - block, - true - ); - block.ce = consequent_effect; - - alternate_effect ??= render_effect( - (/** @type {any} */ _, /** @type {import('#client').Effect | null} */ alternate_effect) => { - const result = block.v; - - if (result && alternate_dom !== null) { - remove(alternate_dom); - alternate_dom = null; - } - - if (!result && current_branch_effect !== alternate_effect) { - if (alternate_fn !== null) { - alternate_fn(anchor_node); - } - - if (mismatch && current_branch_effect === null) { - // Set fragment so that Svelte continues to operate in hydration mode - set_current_hydration_fragment([]); - } - - current_branch_effect = alternate_effect; - alternate_dom = block.d; - } - block.d = null; - }, - block, - true - ); - block.ae = alternate_effect; + if (condition) { + if (consequent_effect) { + resume_effect(consequent_effect); + } else { + consequent_effect = render_effect( + () => { + consequent_fn(anchor); + consequent_dom = block.d; + + if (mismatch) { + // Set fragment so that Svelte continues to operate in hydration mode + set_current_hydration_fragment([]); + } + + return () => { + // TODO make this unnecessary by linking the dom to the effect, + // and removing automatically on teardown + if (consequent_dom !== null) { + remove(consequent_dom); + consequent_dom = null; + } + }; + }, + block, + true + ); + } + + if (alternate_effect) { + pause_effect(alternate_effect, () => { + alternate_effect = null; + if (alternate_dom) remove(alternate_dom); + }); + } + } else { + if (alternate_effect) { + resume_effect(alternate_effect); + } else if (alternate_fn) { + alternate_effect = render_effect( + () => { + alternate_fn(anchor); + alternate_dom = block.d; + + if (mismatch) { + // Set fragment so that Svelte continues to operate in hydration mode + set_current_hydration_fragment([]); + } + + return () => { + // TODO make this unnecessary by linking the dom to the effect, + // and removing automatically on teardown + if (alternate_dom !== null) { + remove(alternate_dom); + alternate_dom = null; + } + }; + }, + block, + true + ); + } + + if (consequent_effect) { + pause_effect(consequent_effect, () => { + consequent_effect = null; + if (consequent_dom) remove(consequent_dom); + }); + } + } }, block); + if (elseif) { + if_effect.f |= IS_ELSEIF; + } + if_effect.ondestroy = () => { + // TODO make this unnecessary by linking the dom to the effect, + // and removing automatically on teardown if (consequent_dom !== null) { remove(consequent_dom); } @@ -193,8 +167,12 @@ export function if_block(anchor_node, condition_fn, consequent_fn, alternate_fn) remove(alternate_dom); } - destroy_effect(consequent_effect); - destroy_effect(alternate_effect); + if (consequent_effect) { + destroy_effect(consequent_effect); + } + if (alternate_effect) { + destroy_effect(alternate_effect); + } }; block.e = if_effect; diff --git a/packages/svelte/src/internal/client/dom/blocks/key.js b/packages/svelte/src/internal/client/dom/blocks/key.js index 4267b96546..7410c5b640 100644 --- a/packages/svelte/src/internal/client/dom/blocks/key.js +++ b/packages/svelte/src/internal/client/dom/blocks/key.js @@ -1,140 +1,76 @@ -import { UNINITIALIZED, KEY_BLOCK } from '../../constants.js'; +import { UNINITIALIZED } from '../../constants.js'; import { hydrate_block_anchor } from '../hydration.js'; import { remove } from '../reconciler.js'; -import { current_block, execute_effect } from '../../runtime.js'; -import { destroy_effect, render_effect } from '../../reactivity/effects.js'; -import { trigger_transitions } from '../elements/transitions.js'; +import { pause_effect, render_effect } from '../../reactivity/effects.js'; import { safe_not_equal } from '../../reactivity/equality.js'; -/** @returns {import('../../types.js').KeyBlock} */ -function create_key_block() { - return { - // dom - d: null, - // effect - e: null, - // parent - p: /** @type {import('../../types.js').Block} */ (current_block), - // transition - r: null, - // type - t: KEY_BLOCK - }; -} - /** * @template V - * @param {Comment} anchor_node - * @param {() => V} key + * @param {Comment} anchor + * @param {() => V} get_key * @param {(anchor: Node) => void} render_fn * @returns {void} */ -export function key_block(anchor_node, key, render_fn) { - const block = create_key_block(); +export function key_block(anchor, get_key, render_fn) { + const block = {}; - /** @type {null | import('../../types.js').Render} */ - let current_render = null; - hydrate_block_anchor(anchor_node); + hydrate_block_anchor(anchor); /** @type {V | typeof UNINITIALIZED} */ - let key_value = UNINITIALIZED; - let mounted = false; - block.r = - /** - * @param {import('../../types.js').Transition} transition - * @returns {void} - */ - (transition) => { - const render = /** @type {import('../../types.js').Render} */ (current_render); - const transitions = render.s; - transitions.add(transition); - transition.f(() => { - transitions.delete(transition); - if (transitions.size === 0) { - // If the current render has changed since, then we can remove the old render - // effect as it's stale. - if (current_render !== render && render.e !== null) { - if (render.d !== null) { - remove(render.d); - render.d = null; - } - destroy_effect(render.e); - render.e = null; - } - } - }); - }; - const create_render_effect = () => { - /** @type {import('../../types.js').Render} */ - const render = { - d: null, - e: null, - s: new Set(), - p: current_render - }; - const effect = render_effect( - () => { - render_fn(anchor_node); - render.d = block.d; - block.d = null; - }, - block, - true, - true - ); - render.e = effect; - current_render = render; - }; - const render = () => { - const render = current_render; - if (render === null) { - create_render_effect(); - return; - } - const transitions = render.s; - if (transitions.size === 0) { - if (render.d !== null) { - remove(render.d); - render.d = null; - } - if (render.e) { - execute_effect(render.e); - } else { - create_render_effect(); - } - } else { - trigger_transitions(transitions, 'out'); - create_render_effect(); - } - }; + let key = UNINITIALIZED; + + /** @type {import('#client').Effect} */ + let effect; + + /** + * Every time `key` changes, we create a new effect. Old effects are + * removed from this set when they have fully transitioned out + * @type {Set} + */ + let effects = new Set(); + const key_effect = render_effect( () => { - const prev_key_value = key_value; - key_value = key(); - if (mounted && safe_not_equal(prev_key_value, key_value)) { - render(); + if (safe_not_equal(key, (key = get_key()))) { + if (effect) { + var e = effect; + pause_effect(e, () => { + effects.delete(e); + }); + } + + effect = render_effect( + () => { + render_fn(anchor); + + // @ts-expect-error TODO this should be unnecessary + const dom = block.d; + + return () => { + if (dom !== null) { + remove(dom); + } + }; + }, + block, + true, + true + ); + + // @ts-expect-error TODO tidy up + effect.d = block.d; + + effects.add(effect); } }, block, false ); - // To ensure topological ordering of the key effect to the render effect, - // we trigger the effect after. - render(); - mounted = true; + key_effect.ondestroy = () => { - let render = current_render; - while (render !== null) { - const dom = render.d; - if (dom !== null) { - remove(dom); - } - const effect = render.e; - if (effect !== null) { - destroy_effect(effect); - } - render = render.p; + for (const e of effects) { + // @ts-expect-error TODO tidy up. ondestroy should be totally unnecessary + if (e.d) remove(e.d); } }; - block.e = key_effect; } diff --git a/packages/svelte/src/internal/client/dom/blocks/snippet.js b/packages/svelte/src/internal/client/dom/blocks/snippet.js index c07eca4d44..27d3011668 100644 --- a/packages/svelte/src/internal/client/dom/blocks/snippet.js +++ b/packages/svelte/src/internal/client/dom/blocks/snippet.js @@ -1,4 +1,3 @@ -import { SNIPPET_BLOCK } from '../../constants.js'; import { render_effect } from '../../reactivity/effects.js'; import { remove } from '../reconciler.js'; import { current_block, untrack } from '../../runtime.js'; @@ -17,11 +16,7 @@ export function snippet(get_snippet, node, ...args) { // parent p: /** @type {import('#client').Block} */ (current_block), // effect - e: null, - // transition - r: null, - // type - t: SNIPPET_BLOCK + e: null }; render_effect(() => { diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js index 3f170462ef..a99eb4ce43 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js @@ -1,127 +1,69 @@ -import { DYNAMIC_COMPONENT_BLOCK } from '../../constants.js'; import { hydrate_block_anchor } from '../hydration.js'; -import { destroy_effect, render_effect } from '../../reactivity/effects.js'; +import { pause_effect, render_effect } from '../../reactivity/effects.js'; import { remove } from '../reconciler.js'; -import { current_block, execute_effect } from '../../runtime.js'; -import { trigger_transitions } from '../elements/transitions.js'; + +// TODO this is very similar to `key`, can we deduplicate? /** * @template P - * @param {Comment} anchor_node - * @param {() => (props: P) => void} component_fn - * @param {(component: (props: P) => void) => void} render_fn + * @template {(props: P) => void} C + * @param {Comment} anchor + * @param {() => C} get_component + * @param {(component: C) => void} render_fn * @returns {void} */ -export function component(anchor_node, component_fn, render_fn) { - /** @type {import('#client').DynamicComponentBlock} */ - const block = { - // dom - d: null, - // effect - e: null, - // parent - p: /** @type {import('#client').Block} */ (current_block), - // transition - r: null, - // type - t: DYNAMIC_COMPONENT_BLOCK - }; +export function component(anchor, get_component, render_fn) { + const block = {}; - /** @type {null | import('#client').Render} */ - let current_render = null; - hydrate_block_anchor(anchor_node); + hydrate_block_anchor(anchor); - /** @type {null | ((props: P) => void)} */ - let component = null; + /** @type {C} */ + let component; - block.r = - /** - * @param {import('#client').Transition} transition - * @returns {void} - */ - (transition) => { - const render = /** @type {import('#client').Render} */ (current_render); - const transitions = render.s; - transitions.add(transition); - transition.f(() => { - transitions.delete(transition); - if (transitions.size === 0) { - // If the current render has changed since, then we can remove the old render - // effect as it's stale. - if (current_render !== render && render.e !== null) { - if (render.d !== null) { - remove(render.d); - render.d = null; - } - destroy_effect(render.e); - render.e = null; - } - } - }); - }; + /** @type {import('#client').Effect} */ + let effect; - const create_render_effect = () => { - /** @type {import('#client').Render} */ - const render = { - d: null, - e: null, - s: new Set(), - p: current_render - }; + /** + * Every time `component` changes, we create a new effect. Old effects are + * removed from this set when they have fully transitioned out + * @type {Set} + */ + let effects = new Set(); - // Managed effect - render.e = render_effect( - () => { - const current = block.d; - if (current !== null) { - remove(current); - block.d = null; - } - if (component) { - render_fn(component); - } - render.d = block.d; - block.d = null; - }, - block, - true - ); + const component_effect = render_effect( + () => { + if (component === (component = get_component())) return; - current_render = render; - }; + if (effect) { + var e = effect; + pause_effect(e, () => { + effects.delete(e); + }); + } - const render = () => { - const render = current_render; + if (component) { + effect = render_effect( + () => { + render_fn(component); - if (render === null) { - create_render_effect(); - return; - } + // @ts-expect-error TODO this should be unnecessary + const dom = block.d; - const transitions = render.s; + return () => { + if (dom !== null) { + remove(dom); + } + }; + }, + block, + true, + true + ); - if (transitions.size === 0) { - if (render.d !== null) { - remove(render.d); - render.d = null; - } - if (render.e) { - execute_effect(render.e); - } else { - create_render_effect(); - } - } else { - create_render_effect(); - trigger_transitions(transitions, 'out'); - } - }; + // @ts-expect-error TODO tidy up + effect.d = block.d; - const component_effect = render_effect( - () => { - const next_component = component_fn(); - if (component !== next_component) { - component = next_component; - render(); + effects.add(effect); } }, block, @@ -129,19 +71,9 @@ export function component(anchor_node, component_fn, render_fn) { ); component_effect.ondestroy = () => { - let render = current_render; - while (render !== null) { - const dom = render.d; - if (dom !== null) { - remove(dom); - } - const effect = render.e; - if (effect !== null) { - destroy_effect(effect); - } - render = render.p; + for (const e of effects) { + // @ts-expect-error TODO tidy up. ondestroy should be totally unnecessary + if (e.d) remove(e.d); } }; - - block.e = component_effect; } diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js index 98950fd317..8e63fcc61b 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js @@ -1,11 +1,17 @@ import { namespace_svg } from '../../../../constants.js'; -import { DYNAMIC_ELEMENT_BLOCK } from '../../constants.js'; import { current_hydration_fragment, hydrate_block_anchor, hydrating } from '../hydration.js'; import { empty } from '../operations.js'; -import { destroy_effect, render_effect } from '../../reactivity/effects.js'; -import { insert, remove } from '../reconciler.js'; -import { current_block, execute_effect } from '../../runtime.js'; +import { + destroy_effect, + pause_effect, + render_effect, + resume_effect +} from '../../reactivity/effects.js'; +import { remove } from '../reconciler.js'; +import { current_block } from '../../runtime.js'; import { is_array } from '../../utils.js'; +import { set_should_intro } from '../../render.js'; +import { current_each_item_block, set_current_each_item_block } from './each.js'; /** * @param {import('#client').Block} block @@ -28,13 +34,13 @@ function swap_block_dom(block, from, to) { } /** - * @param {Comment} anchor_node - * @param {() => string} tag_fn + * @param {Comment} anchor + * @param {() => string} get_tag * @param {boolean | null} is_svg `null` == not statically known * @param {undefined | ((element: Element, anchor: Node) => void)} render_fn * @returns {void} */ -export function element(anchor_node, tag_fn, is_svg, render_fn) { +export function element(anchor, get_tag, is_svg, render_fn) { /** @type {import('#client').DynamicElementBlock} */ const block = { // dom @@ -42,98 +48,119 @@ export function element(anchor_node, tag_fn, is_svg, render_fn) { // effect e: null, // parent - p: /** @type {import('#client').Block} */ (current_block), - // transition - r: null, - // type - t: DYNAMIC_ELEMENT_BLOCK + p: /** @type {import('#client').Block} */ (current_block) }; - hydrate_block_anchor(anchor_node); - let has_mounted = false; + hydrate_block_anchor(anchor); - /** @type {string} */ + /** @type {string | null} */ let tag; + /** @type {string | null} */ + let current_tag; + /** @type {null | Element} */ let element = null; - const element_effect = render_effect( - () => { - tag = tag_fn(); - if (has_mounted) { - execute_effect(render_effect_signal); - } - has_mounted = true; - }, - block, - false - ); - - // Managed effect - const render_effect_signal = render_effect( - () => { - // We try our best infering the namespace in case it's not possible to determine statically, - // but on the first render on the client (without hydration) the parent will be undefined, - // since the anchor is not attached to its parent / the dom yet. - const ns = - is_svg || tag === 'svg' - ? namespace_svg - : is_svg === false || anchor_node.parentElement?.tagName === 'foreignObject' - ? null - : anchor_node.parentElement?.namespaceURI ?? null; - - const next_element = tag - ? hydrating - ? /** @type {Element} */ (current_hydration_fragment[0]) - : ns - ? document.createElementNS(ns, tag) - : document.createElement(tag) - : null; - - const prev_element = element; - if (prev_element !== null) { - block.d = null; - } + /** @type {import('#client').Effect | null} */ + let effect; - element = next_element; - if (element !== null && render_fn !== undefined) { - let anchor; - if (hydrating) { - // Use the existing ssr comment as the anchor so that the inner open and close - // methods can pick up the existing nodes correctly - anchor = /** @type {Comment} */ (element.firstChild); - } else { - anchor = empty(); - element.appendChild(anchor); - } - render_fn(element, anchor); - } + /** + * The keyed `{#each ...}` item block, if any, that this element is inside. + * We track this so we can set it when changing the element, allowing any + * `animate:` directive to bind itself to the correct block + */ + let each_item_block = current_each_item_block; - const has_prev_element = prev_element !== null; - if (has_prev_element) { - remove(prev_element); - } - if (element !== null) { - insert(element, null, anchor_node); - if (has_prev_element) { - const parent_block = block.p; - swap_block_dom(parent_block, prev_element, element); - } + const wrapper = render_effect(() => { + const next_tag = get_tag() || null; + if (next_tag === tag) return; + + // See explanation of `each_item_block` above + var previous_each_item_block = current_each_item_block; + set_current_each_item_block(each_item_block); + + // We try our best infering the namespace in case it's not possible to determine statically, + // but on the first render on the client (without hydration) the parent will be undefined, + // since the anchor is not attached to its parent / the dom yet. + const ns = + is_svg || next_tag === 'svg' + ? namespace_svg + : is_svg === false || anchor.parentElement?.tagName === 'foreignObject' + ? null + : anchor.parentElement?.namespaceURI ?? null; + + if (effect) { + if (next_tag === null) { + // start outro + pause_effect(effect, () => { + effect = null; + current_tag = null; + element?.remove(); // TODO this should be unnecessary + }); + } else if (next_tag === current_tag) { + // same tag as is currently rendered — abort outro + resume_effect(effect); + } else { + // tag is changing — destroy immediately, render contents without intro transitions + destroy_effect(effect); + set_should_intro(false); } - }, - block, - true - ); + } - element_effect.ondestroy = () => { + if (next_tag && next_tag !== current_tag) { + effect = render_effect( + () => { + const prev_element = element; + element = hydrating + ? /** @type {Element} */ (current_hydration_fragment[0]) + : ns + ? document.createElementNS(ns, next_tag) + : document.createElement(next_tag); + + if (render_fn) { + let anchor; + if (hydrating) { + // Use the existing ssr comment as the anchor so that the inner open and close + // methods can pick up the existing nodes correctly + anchor = /** @type {Comment} */ (element.firstChild); + } else { + anchor = empty(); + element.appendChild(anchor); + } + render_fn(element, anchor); + } + + anchor.before(element); + + if (prev_element) { + swap_block_dom(block.p, prev_element, element); + prev_element.remove(); + } + }, + block, + true + ); + } + + tag = next_tag; + if (tag) current_tag = tag; + set_should_intro(true); + + set_current_each_item_block(previous_each_item_block); + }, block); + + wrapper.ondestroy = () => { if (element !== null) { remove(element); block.d = null; element = null; } - destroy_effect(render_effect_signal); + + if (effect) { + destroy_effect(effect); + } }; - block.e = element_effect; + block.e = wrapper; } diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js index 0147a5fe88..c0916e7a29 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js @@ -1,4 +1,3 @@ -import { HEAD_BLOCK } from '../../constants.js'; import { current_hydration_fragment, get_hydration_fragment, @@ -22,11 +21,7 @@ export function head(render_fn) { // effect e: null, // parent - p: /** @type {import('#client').Block} */ (current_block), - // transition - r: null, - // type - t: HEAD_BLOCK + p: /** @type {import('#client').Block} */ (current_block) }; // The head function may be called after the first hydration pass and ssr comment nodes may still be present, diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js index fcf56b7e89..86d75948ee 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js @@ -1,5 +1,5 @@ import { DEV } from 'esm-env'; -import { render_effect } from '../../../reactivity/effects.js'; +import { render_effect, user_effect } from '../../../reactivity/effects.js'; import { stringify } from '../../../render.js'; /** @@ -96,6 +96,11 @@ export function bind_group(inputs, group_index, input, get_value, update) { } }); + user_effect(() => { + // necessary to maintain binding group order in all insertion scenarios. TODO optimise + binding_group.sort((a, b) => (a.compareDocumentPosition(b) === 4 ? -1 : 1)); + }); + render_effect(() => { return () => { var index = binding_group.indexOf(input); diff --git a/packages/svelte/src/internal/client/dom/elements/transitions.js b/packages/svelte/src/internal/client/dom/elements/transitions.js index 4a9615d387..387089f262 100644 --- a/packages/svelte/src/internal/client/dom/elements/transitions.js +++ b/packages/svelte/src/internal/client/dom/elements/transitions.js @@ -1,85 +1,13 @@ -import { EACH_IS_ANIMATED, EACH_IS_CONTROLLED } from '../../../../constants.js'; -import { run_all } from '../../../common.js'; -import { - AWAIT_BLOCK, - DYNAMIC_COMPONENT_BLOCK, - EACH_BLOCK, - EACH_ITEM_BLOCK, - IF_BLOCK, - KEY_BLOCK, - ROOT_BLOCK -} from '../../constants.js'; -import { destroy_each_item_block, get_first_element } from '../blocks/each.js'; -import { schedule_raf_task } from '../task.js'; -import { append_child, empty } from '../operations.js'; -import { - destroy_effect, - effect, - managed_effect, - managed_pre_effect -} from '../../reactivity/effects.js'; -import { - current_block, - current_effect, - execute_effect, - mark_subtree_inert, - untrack -} from '../../runtime.js'; +import { noop } from '../../../common.js'; +import { user_effect } from '../../reactivity/effects.js'; +import { current_effect, untrack } from '../../runtime.js'; import { raf } from '../../timing.js'; - -const active_tick_animations = new Set(); -const DELAY_NEXT_TICK = Number.MIN_SAFE_INTEGER; - -/** @type {undefined | number} */ -let active_tick_ref = undefined; - -/** - * @template P - * @param {HTMLElement} dom - * @param {() => import('#client').TransitionFn

} get_transition_fn - * @param {(() => P) | null} props - * @param {any} global - * @returns {void} - */ -export function transition(dom, get_transition_fn, props, global = false) { - bind_transition(dom, get_transition_fn, props, 'both', global); -} - -/** - * @template P - * @param {HTMLElement} dom - * @param {() => import('#client').TransitionFn

} get_transition_fn - * @param {(() => P) | null} props - * @returns {void} - */ -export function animate(dom, get_transition_fn, props) { - bind_transition(dom, get_transition_fn, props, 'key', false); -} - -/** - * @template P - * @param {HTMLElement} dom - * @param {() => import('#client').TransitionFn

} get_transition_fn - * @param {(() => P) | null} props - * @param {any} global - * @returns {void} - */ -function in_fn(dom, get_transition_fn, props, global = false) { - bind_transition(dom, get_transition_fn, props, 'in', global); -} -export { in_fn as in }; - -/** - * @template P - * @param {HTMLElement} dom - * @param {() => import('#client').TransitionFn

} get_transition_fn - * @param {(() => P) | null} props - * @param {any} global - * @returns {void} - */ -export function out(dom, get_transition_fn, props, global = false) { - bind_transition(dom, get_transition_fn, props, 'out', global); -} +import { loop } from '../../loop.js'; +import { should_intro } from '../../render.js'; +import { is_function } from '../../utils.js'; +import { current_each_item_block } from '../blocks/each.js'; +import { TRANSITION_GLOBAL, TRANSITION_IN, TRANSITION_OUT } from '../../../../constants.js'; +import { EFFECT_RAN } from '../../constants.js'; /** * @template T @@ -95,7 +23,7 @@ function custom_event(type, detail, { bubbles = false, cancelable = false } = {} } /** - * @param {HTMLElement} dom + * @param {Element} dom * @param {'introstart' | 'introend' | 'outrostart' | 'outroend'} type * @returns {void} */ @@ -137,685 +65,281 @@ function css_to_keyframe(css) { return keyframe; } -class TickAnimation { - /** @type {null | (() => void)} */ - onfinish; - - /** @type {(t: number, u: number) => string} */ - #tick_fn; - - /** @type {number} */ - #duration; - - /** @type {number} */ - #current; - - /** @type {number} */ - #delay; - - /** @type {number} */ - #previous; - - /** @type {boolean} */ - paused; - - /** @type {boolean} */ - #reversed; - - /** @type {number} */ - #delay_current; - - /** @type {boolean} */ - #delayed_reverse; - - /** - * @param {(t: number, u: number) => string} tick_fn - * @param {number} duration - * @param {number} delay - * @param {boolean} out - */ - constructor(tick_fn, duration, delay, out) { - this.#duration = duration; - this.#delay = delay; - this.paused = false; - this.#tick_fn = tick_fn; - this.#reversed = out; - this.#delay_current = delay; - this.#current = out ? duration : 0; - this.#previous = 0; - this.#delayed_reverse = false; - this.onfinish = null; - if (this.#delay) { - if (!out) { - this.#tick_fn(0, 1); - } - } - } - - pause() { - this.paused = true; - } - - play() { - this.paused = false; - if (!active_tick_animations.has(this)) { - this.#previous = raf.now(); - if (active_tick_ref === undefined) { - active_tick_ref = raf.tick(handle_raf); - } - active_tick_animations.add(this); - } - } - - #reverse() { - this.#reversed = !this.#reversed; - if (this.paused) { - if (this.#current === 0) { - this.#current = this.#duration; - } - this.play(); - } - } - - reverse() { - if (this.#delay === 0) { - this.#reverse(); - } else { - this.#delay_current = this.#delay; - this.#delayed_reverse = true; - } - } +/** @param {number} t */ +const linear = (t) => t; - cancel() { - active_tick_animations.delete(this); - const current = this.#current / this.#duration; - if (current > 0 && current < 1) { - const t = this.#reversed ? 1 : 0; - this.#tick_fn(t, 1 - t); - } - } +/** + * Called inside keyed `{#each ...}` blocks (as `$.animation(...)`). This creates an animation manager + * and attaches it to the block, so that moves can be animated following reconciliation. + * @template P + * @param {Element} element + * @param {() => import('#client').AnimateFn

} get_fn + * @param {(() => P) | null} get_params + */ +export function animation(element, get_fn, get_params) { + var block = /** @type {import('#client').EachItemBlock} */ (current_each_item_block); - finish() { - active_tick_animations.delete(this); - if (this.onfinish) { - this.onfinish(); - } - } + /** @type {DOMRect} */ + var from; - /** @param {number} time */ - _update(time) { - let diff = time - this.#previous; - this.#previous = time; - if (this.#delay_current !== 0) { - const is_delayed = this.#delay_current === DELAY_NEXT_TICK; - let cancel = !this.#delayed_reverse; - this.#delay_current -= diff; - if (this.#delay_current < 0 || is_delayed || (this.#delay_current === 0 && this.#reversed)) { - const delay_diff = is_delayed ? 0 : -this.#delay_current; - this.#delay_current = 0; - - if (this.#delayed_reverse) { - this.#delayed_reverse = false; - this.#reverse(); - } else if (delay_diff !== 0 || this.#reversed) { - diff = delay_diff; - } - cancel = false; - } else if (this.#delay_current === 0) { - this.#delay_current = DELAY_NEXT_TICK; - } - if (cancel) { - return; - } - } - this.#current += this.#reversed ? -diff : diff; - let t = this.#current / this.#duration; + /** @type {DOMRect} */ + var to; - if (t < 0) { - t = 0; - } else if (t > 1) { - t = 1; - } + /** @type {import('#client').Animation | undefined} */ + var animation; - if ((this.#reversed && t <= 0) || (!this.#reversed && t >= 1)) { - t = this.#reversed ? 0 : 1; - if (this.#delay_current === 0) { - active_tick_animations.delete(this); - if (this.onfinish) { - this.paused = true; - this.onfinish(); - } + block.a ??= { + element, + measure() { + from = this.element.getBoundingClientRect(); + }, + apply() { + animation?.abort(); + + to = this.element.getBoundingClientRect(); + + const options = get_fn()(this.element, { from, to }, get_params?.()); + + if ( + from.left !== to.left || + from.right !== to.right || + from.top !== to.top || + from.bottom !== to.bottom + ) { + animation = animate(this.element, options, undefined, 1, () => { + animation?.abort(); + animation = undefined; + }); } } - this.#tick_fn(t, 1 - t); - } -} + }; -/** @param {number} time */ -function handle_raf(time) { - for (const animation of active_tick_animations) { - if (!animation.paused) { - animation._update(time); - } - } - if (active_tick_animations.size !== 0) { - active_tick_ref = raf.tick(handle_raf); - } else { - active_tick_ref = undefined; - } + // in the case of a ``, it's possible for `$.animation(...)` to be called + // when an animation manager already exists, if the tag changes. in that case, we need to + // swap out the element rather than creating a new manager, in case it happened at the same + // moment as a reconciliation + block.a.element = element; } /** - * @param {{(t: number): number;(t: number): number;(arg0: number): any;}} easing_fn - * @param {((t: number, u: number) => string)} css_fn - * @param {number} duration - * @param {string} direction - * @param {boolean} reverse + * Called inside block effects as `$.transition(...)`. This creates a transition manager and + * attaches it to the current effect — later, inside `pause_effect` and `resume_effect`, we + * use this to create `intro` and `outro` transitions. + * @template P + * @param {number} flags + * @param {HTMLElement} element + * @param {() => import('#client').TransitionFn

} get_fn + * @param {(() => P) | null} get_params + * @returns {void} */ -function create_keyframes(easing_fn, css_fn, duration, direction, reverse) { - /** @type {Keyframe[]} */ - const keyframes = []; - // We need at least two frames - const frame_time = 16.666; - const max_duration = Math.max(duration, frame_time); - // Have a keyframe every fame for 60 FPS - for (let i = 0; i <= max_duration; i += frame_time) { - let time; - if (i + frame_time > max_duration) { - time = 1; - } else if (i === 0) { - time = 0; - } else { - time = i / max_duration; - } - let t = easing_fn(time); - if (reverse) { - t = 1 - t; - } - keyframes.push(css_to_keyframe(css_fn(t, 1 - t))); - } - if (direction === 'out' || reverse) { - keyframes.reverse(); - } - return keyframes; -} +export function transition(flags, element, get_fn, get_params) { + var is_intro = (flags & TRANSITION_IN) !== 0; + var is_outro = (flags & TRANSITION_OUT) !== 0; + var is_global = (flags & TRANSITION_GLOBAL) !== 0; -/** @param {number} t */ -const linear = (t) => t; + /** @type {'in' | 'out' | 'both'} */ + var direction = is_intro && is_outro ? 'both' : is_intro ? 'in' : 'out'; -/** - * @param {HTMLElement} dom - * @param {() => import('../../types.js').TransitionPayload} init - * @param {'in' | 'out' | 'both' | 'key'} direction - * @param {import('../../types.js').Effect} effect - * @returns {import('../../types.js').Transition} - */ -function create_transition(dom, init, direction, effect) { - let curr_direction = 'in'; + /** @type {import('#client').AnimationConfig | ((opts: { direction: 'in' | 'out' }) => import('#client').AnimationConfig) | undefined} */ + var current_options; - /** @type {Array<() => void>} */ - let subs = []; + var inert = element.inert; - /** @type {null | Animation | TickAnimation} */ - let animation = null; - let cancelled = false; + /** @type {import('#client').Animation | undefined} */ + var intro; - const create_animation = () => { - let payload = /** @type {import('../../types.js').TransitionPayload} */ (transition.p); - if (typeof payload === 'function') { - // @ts-ignore - payload = payload({ direction: curr_direction }); - } - if (payload == null) { - return; - } - const duration = payload.duration ?? 300; - const delay = payload.delay ?? 0; - const css_fn = payload.css; - const tick_fn = payload.tick; - const easing_fn = payload.easing || linear; - - if (typeof tick_fn === 'function') { - animation = new TickAnimation(tick_fn, duration, delay, direction === 'out'); - } else { - const keyframes = - typeof css_fn === 'function' - ? create_keyframes(easing_fn, css_fn, duration, direction, false) - : []; - animation = dom.animate(keyframes, { - duration, - endDelay: delay, - delay, - fill: 'both' - }); - } - animation.pause(); + /** @type {import('#client').Animation | undefined} */ + var outro; - animation.onfinish = () => { - const is_outro = curr_direction === 'out'; - /** @type {Animation | TickAnimation} */ (animation).cancel(); - if (is_outro) { - run_all(subs); - subs = []; - } - dispatch_event(dom, is_outro ? 'outroend' : 'introend'); - }; - }; + /** @type {(() => void) | undefined} */ + var reset; - /** @type {import('../../types.js').Transition} */ - const transition = { - e: effect, - i: init, - // payload - p: null, - - // finished - /** @param {() => void} fn */ - f(fn) { - subs.push(fn); - }, + function get_options() { + // If a transition is still ongoing, we use the existing options rather than generating + // new ones. This ensures that reversible transitions reverse smoothly, rather than + // jumping to a new spot because (for example) a different `duration` was used + return (current_options ??= get_fn()(element, get_params?.(), { direction })); + } + + /** @type {import('#client').TransitionManager} */ + var transition = { + is_global, in() { - const needs_reverse = curr_direction !== 'in'; - curr_direction = 'in'; - if (animation === null || cancelled) { - cancelled = false; - create_animation(); - } - if (animation === null) { - transition.x(); + element.inert = inert; + + if (is_intro) { + dispatch_event(element, 'introstart'); + intro = animate(element, get_options(), outro, 1, () => { + dispatch_event(element, 'introend'); + intro = current_options = undefined; + }); } else { - dispatch_event(dom, 'introstart'); - if (needs_reverse) { - /** @type {Animation | TickAnimation} */ (animation).reverse(); - } - /** @type {Animation | TickAnimation} */ (animation).play(); + outro?.abort(); + reset?.(); } }, - // out - o() { - // @ts-ignore - const has_keyed_transition = dom.__animate; - // If we're outroing an element that has an animation, then we need to fix - // its position to ensure it behaves nicely without causing layout shift. - if (has_keyed_transition) { - const style = getComputedStyle(dom); - const position = style.position; - - if (position !== 'absolute' && position !== 'fixed') { - const { width, height } = style; - const a = dom.getBoundingClientRect(); - dom.style.position = 'absolute'; - - dom.style.width = width; - dom.style.height = height; - const b = dom.getBoundingClientRect(); - if (a.left !== b.left || a.top !== b.top) { - const translate = `translate(${a.left - b.left}px, ${a.top - b.top}px)`; - const existing_transform = style.transform; - if (existing_transform === 'none') { - dom.style.transform = translate; - } else { - // Previously, in the Svelte 4, we'd just apply the transform the the DOM element. However, - // because we're now using Web Animations, we can't do that as it won't work properly if the - // animation is also making use of the same transformations. So instead, we apply an - // instantaneous animation and pause it on the first frame, just applying the same behavior. - // We also need to take into consideration matrix transforms and how they might combine with - // an existing behavior that is already in progress (such as scale). - // > Follow the white rabbit. - const transform = existing_transform.startsWith('matrix(1,') - ? translate - : `matrix(1,0,0,1,0,0)`; - const frame = { - transform - }; - const animation = dom.animate([frame, frame], { duration: 1 }); - animation.pause(); - } - } - } - } - const needs_reverse = direction === 'both' && curr_direction !== 'out'; - curr_direction = 'out'; - if (animation === null || cancelled) { - cancelled = false; - create_animation(); - } - if (animation === null) { - transition.x(); + out(fn) { + if (is_outro) { + element.inert = true; + + dispatch_event(element, 'outrostart'); + outro = animate(element, get_options(), intro, 0, () => { + dispatch_event(element, 'outroend'); + outro = current_options = undefined; + fn?.(); + }); + + // TODO arguably the outro should never null itself out until _all_ outros for this effect have completed... + // in that case we wouldn't need to store `reset` separately + reset = outro.reset; } else { - dispatch_event(dom, 'outrostart'); - if (needs_reverse) { - const payload = transition.p; - const current_animation = /** @type {Animation} */ (animation); - // If we are working with CSS animations, then before we call reverse, we also need to ensure - // that we reverse the easing logic. To do this we need to re-create the keyframes so they're - // in reverse with easing properly reversed too. - if ( - payload !== null && - payload.css !== undefined && - current_animation.playState === 'idle' - ) { - const duration = payload.duration ?? 300; - const css_fn = payload.css; - const easing_fn = payload.easing || linear; - const keyframes = create_keyframes(easing_fn, css_fn, duration, direction, true); - const effect = current_animation.effect; - if (effect !== null) { - // @ts-ignore - effect.setKeyframes(keyframes); - } - } - /** @type {Animation | TickAnimation} */ (animation).reverse(); - } else { - /** @type {Animation | TickAnimation} */ (animation).play(); - } + fn?.(); } }, - // cancel - c() { - if (animation !== null) { - /** @type {Animation | TickAnimation} */ (animation).cancel(); - } - cancelled = true; - }, - // cleanup - x() { - run_all(subs); - subs = []; - }, - r: direction, - d: dom + stop: () => { + intro?.abort(); + outro?.abort(); + } }; - return transition; -} -/** - * @param {import('../../types.js').Block} block - * @returns {boolean} - */ -function is_transition_block(block) { - const type = block.t; - return ( - type === IF_BLOCK || - type === EACH_ITEM_BLOCK || - type === KEY_BLOCK || - type === AWAIT_BLOCK || - type === DYNAMIC_COMPONENT_BLOCK || - (type === EACH_BLOCK && block.v.length === 0) - ); + var effect = /** @type {import('#client').Effect} */ (current_effect); + + (effect.transitions ??= []).push(transition); + + // if this is a local transition, we only want to run it if the parent (block) effect's + // parent (branch) effect is where the state change happened. we can determine that by + // looking at whether the branch effect is currently initializing + if (is_intro && should_intro) { + var parent = /** @type {import('#client').Effect} */ (effect.parent); + + if (is_global || (parent.f & EFFECT_RAN) !== 0) { + user_effect(() => { + untrack(() => transition.in()); + }); + } + } } /** - * @template P - * @param {HTMLElement} dom - * @param {() => import('../../types.js').TransitionFn

| import('../../types.js').AnimateFn

} get_transition_fn - * @param {(() => P) | null} props_fn - * @param {'in' | 'out' | 'both' | 'key'} direction - * @param {boolean} global - * @returns {void} + * Animates an element, according to the provided configuration + * @param {Element} element + * @param {import('#client').AnimationConfig | ((opts: { direction: 'in' | 'out' }) => import('#client').AnimationConfig)} options + * @param {import('#client').Animation | undefined} counterpart The corresponding intro/outro to this outro/intro + * @param {number} t2 The target `t` value — `1` for intro, `0` for outro + * @param {(() => void) | undefined} callback + * @returns {import('#client').Animation} */ -function bind_transition(dom, get_transition_fn, props_fn, direction, global) { - const transition_effect = /** @type {import('../../types.js').Effect} */ (current_effect); - const block = current_block; - const is_keyed_transition = direction === 'key'; - - let can_show_intro_on_mount = true; - let can_apply_lazy_transitions = false; +function animate(element, options, counterpart, t2, callback) { + if (is_function(options)) { + // In the case of a deferred transition (such as `crossfade`), `option` will be + // a function rather than an `AnimationConfig`. We need to call this function + // once DOM has been updated... + /** @type {import('#client').Animation} */ + var a; + + user_effect(() => { + var o = untrack(() => options({ direction: t2 === 1 ? 'in' : 'out' })); + a = animate(element, o, counterpart, t2, callback); + }); - if (is_keyed_transition) { - // @ts-ignore - dom.__animate = true; + // ...but we want to do so without using `async`/`await` everywhere, so + // we return a facade that allows everything to remain synchronous + return { + abort: () => a.abort(), + deactivate: () => a.deactivate(), + reset: () => a.reset(), + t: (now) => a.t(now) + }; } - /** @type {import('../../types.js').Block | null} */ - let transition_block = block; - main: while (transition_block !== null) { - if (is_transition_block(transition_block)) { - if (transition_block.t === EACH_ITEM_BLOCK) { - // Lazily apply the each block transition - transition_block.r = each_item_transition; - transition_block.a = each_item_animate; - transition_block = transition_block.p; - } else if (transition_block.t === AWAIT_BLOCK && transition_block.n /* pending */) { - can_show_intro_on_mount = true; - } else if (transition_block.t === IF_BLOCK) { - transition_block.r = if_block_transition; - if (can_show_intro_on_mount) { - /** @type {import('../../types.js').Block | null} */ - let if_block = transition_block; - while (if_block.t === IF_BLOCK) { - // If we have an if block parent that is currently falsy then - // we can show the intro on mount as long as that block is mounted - if (if_block.e !== null && !if_block.v) { - can_show_intro_on_mount = true; - break main; - } - if_block = if_block.p; - } - } - } - if (!can_apply_lazy_transitions && can_show_intro_on_mount) { - can_show_intro_on_mount = transition_block.e !== null; - } - if (can_show_intro_on_mount || !global) { - can_apply_lazy_transitions = true; - } - } else if (transition_block.t === ROOT_BLOCK && !can_apply_lazy_transitions) { - can_show_intro_on_mount = transition_block.e !== null || transition_block.i; - } - transition_block = transition_block.p; + + counterpart?.deactivate(); + + if (!options?.duration) { + callback?.(); + return { + abort: noop, + deactivate: noop, + reset: noop, + t: () => t2 + }; } - /** @type {import('../../types.js').Transition} */ - let transition; + var { delay = 0, duration, css, tick, easing = linear } = options; - effect(() => { - let already_mounted = false; - if (transition !== undefined) { - already_mounted = true; - // Destroy any existing transitions first - transition.x(); - } - const transition_fn = get_transition_fn(); - /** @param {DOMRect} [from] */ - const init = (from) => - untrack(() => { - const props = props_fn === null ? {} : props_fn(); - return is_keyed_transition - ? /** @type {import('../../types.js').AnimateFn} */ (transition_fn)( - dom, - { from: /** @type {DOMRect} */ (from), to: dom.getBoundingClientRect() }, - props, - {} - ) - : /** @type {import('../../types.js').TransitionFn} */ (transition_fn)(dom, props, { - direction - }); - }); + var start = raf.now() + delay; + var t1 = counterpart?.t(start) ?? 1 - t2; + var delta = t2 - t1; - transition = create_transition(dom, init, direction, transition_effect); - const is_intro = direction === 'in'; - const show_intro = can_show_intro_on_mount && (is_intro || direction === 'both'); + duration *= Math.abs(delta); + var end = start + duration; - if (show_intro && !already_mounted) { - transition.p = transition.i(); - } + /** @type {Animation} */ + var animation; - const effect = managed_pre_effect(() => { - destroy_effect(effect); - dom.inert = false; + /** @type {import('#client').Task} */ + var task; - if (show_intro && !already_mounted) { - transition.in(); - } + if (css) { + // WAAPI + var keyframes = []; + var n = duration / (1000 / 60); - /** @type {import('../../types.js').Block | null} */ - let transition_block = block; - while (!is_intro && transition_block !== null) { - const parent = transition_block.p; - if (is_transition_block(transition_block)) { - if (transition_block.r !== null) { - transition_block.r(transition); - } - if ( - parent === null || - (!global && (transition_block.t !== IF_BLOCK || parent.t !== IF_BLOCK || parent.v)) - ) { - break; - } - } - transition_block = parent; - } - }); - }); + for (var i = 0; i <= n; i += 1) { + var t = t1 + delta * easing(i / n); + var styles = css(t, 1 - t); + keyframes.push(css_to_keyframe(styles)); + } - if (direction === 'key') { - effect(() => { - return () => { - transition.x(); - }; + animation = element.animate(keyframes, { + delay, + duration, + easing: 'linear', + fill: 'forwards' }); - } -} -/** - * @param {Set} transitions - * @param {'in' | 'out' | 'key'} target_direction - * @param {DOMRect} [from] - * @returns {void} - */ -export function trigger_transitions(transitions, target_direction, from) { - /** @type {Array<() => void>} */ - const outros = []; - for (const transition of transitions) { - const direction = transition.r; - const effect = transition.e; - if (target_direction === 'in') { - if (direction === 'in' || direction === 'both') { - transition.in(); - } else { - transition.c(); - } - transition.d.inert = false; - mark_subtree_inert(effect, false); - } else if (target_direction === 'key') { - if (direction === 'key') { - if (!transition.p) { - transition.p = transition.i(/** @type {DOMRect} */ (from)); - } - transition.in(); - } - } else { - if (direction === 'out' || direction === 'both') { - if (!transition.p) { - transition.p = transition.i(); - } - outros.push(transition.o); - } - transition.d.inert = true; - mark_subtree_inert(effect, true); + animation.finished + .then(() => { + callback?.(); + }) + .catch(noop); + } else { + // Timer + if (t1 === 0) { + tick?.(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes? } - } - if (outros.length > 0) { - // Defer the outros to a microtask - const e = managed_pre_effect(() => { - destroy_effect(e); - const e2 = managed_effect(() => { - destroy_effect(e2); - run_all(outros); - }); - }); - } -} -/** - * @this {import('../../types.js').IfBlock} - * @param {import('../../types.js').Transition} transition - * @returns {void} - */ -function if_block_transition(transition) { - const block = this; - // block.value === true - if (block.v) { - const consequent_transitions = (block.c ??= new Set()); - consequent_transitions.add(transition); - transition.f(() => { - const c = /** @type {Set} */ (consequent_transitions); - c.delete(transition); - // If the block has changed to falsy and has transitions - if (!block.v && c.size === 0) { - const consequent_effect = block.ce; - execute_effect(/** @type {import('../../types.js').Effect} */ (consequent_effect)); + task = loop((now) => { + if (now >= end) { + tick?.(t2, 1 - t2); + callback?.(); + return false; } - }); - } else { - const alternate_transitions = (block.a ??= new Set()); - alternate_transitions.add(transition); - transition.f(() => { - const a = /** @type {Set} */ (alternate_transitions); - a.delete(transition); - // If the block has changed to truthy and has transitions - if (block.v && a.size === 0) { - const alternate_effect = block.ae; - execute_effect(/** @type {import('../../types.js').Effect} */ (alternate_effect)); + + if (now >= start) { + var p = t1 + delta * easing((now - start) / duration); + tick?.(p, 1 - p); } + + return true; }); } -} -/** - * @this {import('../../types.js').EachItemBlock} - * @param {import('../../types.js').Transition} transition - * @returns {void} - */ -function each_item_transition(transition) { - const block = this; - const each_block = block.p; - const is_controlled = (each_block.f & EACH_IS_CONTROLLED) !== 0; - // Disable optimization - if (is_controlled) { - const anchor = empty(); - each_block.f ^= EACH_IS_CONTROLLED; - append_child(/** @type {Element} */ (each_block.a), anchor); - each_block.a = anchor; - } - if (transition.r === 'key' && (each_block.f & EACH_IS_ANIMATED) === 0) { - each_block.f |= EACH_IS_ANIMATED; - } - const transitions = (block.s ??= new Set()); - transition.f(() => { - transitions.delete(transition); - if (transition.r !== 'key') { - for (let other of transitions) { - const type = other.r; - if (type === 'key' || type === 'in') { - transitions.delete(other); - } - } - if (transitions.size === 0) { - block.s = null; - destroy_each_item_block(block, null, true); + return { + abort: () => { + animation?.cancel(); + task?.abort(); + }, + deactivate: () => { + callback = undefined; + }, + reset: () => { + if (t2 === 0) { + tick?.(1, 0); } + }, + t: (now) => { + var t = t1 + delta * easing((now - start) / duration); + return Math.min(1, Math.max(0, t)); } - }); - transitions.add(transition); -} - -/** - * - * @param {import('../../types.js').EachItemBlock} block - * @param {Set} transitions - */ -function each_item_animate(block, transitions) { - const from_dom = /** @type {Element} */ (get_first_element(block)); - const from = from_dom.getBoundingClientRect(); - // Cancel any existing key transitions - for (const transition of transitions) { - const type = transition.r; - if (type === 'key') { - transition.c(); - } - } - schedule_raf_task(() => { - trigger_transitions(transitions, 'key', from); - }); + }; } diff --git a/packages/svelte/src/internal/client/dom/hydration.js b/packages/svelte/src/internal/client/dom/hydration.js index 1b15a9ddbb..c896454659 100644 --- a/packages/svelte/src/internal/client/dom/hydration.js +++ b/packages/svelte/src/internal/client/dom/hydration.js @@ -73,33 +73,26 @@ export function get_hydration_fragment(node, insert_text = false) { } /** - * @param {Text | Comment | Element} anchor_node - * @param {boolean} [is_controlled] + * @param {Node} node * @returns {void} */ -export function hydrate_block_anchor(anchor_node, is_controlled) { - if (hydrating) { - /** @type {Node} */ - let target_node = anchor_node; +export function hydrate_block_anchor(node) { + if (!hydrating) return; - if (is_controlled) { - target_node = /** @type {Node} */ (target_node.firstChild); - } - if (target_node.nodeType === 8) { - // @ts-ignore - let fragment = target_node.$$fragment; - if (fragment === undefined) { - fragment = get_hydration_fragment(target_node); - } else { - schedule_task(() => { - // @ts-expect-error clean up memory - target_node.$$fragment = undefined; - }); - } - set_current_hydration_fragment(fragment); + if (node.nodeType === 8) { + // @ts-ignore + let fragment = node.$$fragment; + if (fragment === undefined) { + fragment = get_hydration_fragment(node); } else { - const first_child = /** @type {Element | null} */ (target_node.firstChild); - set_current_hydration_fragment(first_child === null ? [] : [first_child]); + schedule_task(() => { + // @ts-expect-error clean up memory + node.$$fragment = undefined; + }); } + set_current_hydration_fragment(fragment); + } else { + const first_child = /** @type {Element | null} */ (node.firstChild); + set_current_hydration_fragment(first_child === null ? [] : [first_child]); } } diff --git a/packages/svelte/src/internal/client/dom/reconciler.js b/packages/svelte/src/internal/client/dom/reconciler.js index becdac20d9..ebd06796bf 100644 --- a/packages/svelte/src/internal/client/dom/reconciler.js +++ b/packages/svelte/src/internal/client/dom/reconciler.js @@ -30,30 +30,21 @@ export function create_fragment_with_script_from_html(html) { /** * @param {Array | import('../types.js').TemplateNode} current - * @param {null | Element} parent_element - * @param {null | Text | Element | Comment} sibling + * @param {Text | Element | Comment} sibling * @returns {Text | Element | Comment} */ -export function insert(current, parent_element, sibling) { +export function insert(current, sibling) { + if (!current) return sibling; + if (is_array(current)) { - var i = 0; - var node; - for (; i < current.length; i++) { - node = current[i]; - if (sibling === null) { - append_child(/** @type {Element} */ (parent_element), /** @type {Node} */ (node)); - } else { - sibling.before(/** @type {Node} */ (node)); - } + for (var i = 0; i < current.length; i++) { + sibling.before(/** @type {Node} */ (current[i])); } + return current[0]; - } else if (current !== null) { - if (sibling === null) { - append_child(/** @type {Element} */ (parent_element), /** @type {Node} */ (current)); - } else { - sibling.before(/** @type {Node} */ (current)); - } } + + sibling.before(/** @type {Node} */ (current)); return /** @type {Text | Element | Comment} */ (current); } diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js index 6ec040a317..24a0ebb6e0 100644 --- a/packages/svelte/src/internal/client/dom/template.js +++ b/packages/svelte/src/internal/client/dom/template.js @@ -90,7 +90,7 @@ export function svg_template_with_script(svg, return_fragment) { function open_template(is_fragment, use_clone_node, anchor, template_element_fn) { if (hydrating) { if (anchor !== null) { - hydrate_block_anchor(anchor, false); + hydrate_block_anchor(anchor); } // In ssr+hydration optimization mode, we might remove the template_element, // so we need to is_fragment flag to properly handle hydrated content accordingly. @@ -181,18 +181,18 @@ export function comment(anchor) { * @returns {void} */ function close_template(dom, is_fragment, anchor) { - const block = /** @type {import('#client').Block} */ (current_block); - /** @type {import('#client').TemplateNode | Array} */ - const current = is_fragment + var current = is_fragment ? is_array(dom) ? dom : /** @type {import('#client').TemplateNode[]} */ (Array.from(dom.childNodes)) : dom; + if (!hydrating && anchor !== null) { - insert(current, null, anchor); + insert(current, anchor); } - block.d = current; + + /** @type {import('#client').Block} */ (current_block).d = current; } /** diff --git a/packages/svelte/src/internal/client/loop.js b/packages/svelte/src/internal/client/loop.js index 26bd3bece3..1d3aec1be4 100644 --- a/packages/svelte/src/internal/client/loop.js +++ b/packages/svelte/src/internal/client/loop.js @@ -1,19 +1,22 @@ import { raf } from './timing.js'; -const tasks = new Set(); +// TODO move this into timing.js where it probably belongs /** * @param {number} now * @returns {void} */ function run_tasks(now) { - tasks.forEach((task) => { + raf.tasks.forEach((task) => { if (!task.c(now)) { - tasks.delete(task); + raf.tasks.delete(task); task.f(); } }); - if (tasks.size !== 0) raf.tick(run_tasks); + + if (raf.tasks.size !== 0) { + raf.tick(run_tasks); + } } /** @@ -25,13 +28,17 @@ function run_tasks(now) { export function loop(callback) { /** @type {import('./types.js').TaskEntry} */ let task; - if (tasks.size === 0) raf.tick(run_tasks); + + if (raf.tasks.size === 0) { + raf.tick(run_tasks); + } + return { promise: new Promise((fulfill) => { - tasks.add((task = { c: callback, f: fulfill })); + raf.tasks.add((task = { c: callback, f: fulfill })); }), abort() { - tasks.delete(task); + raf.tasks.delete(task); } }; } diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 4aef848b32..ebe1a422cb 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -1,22 +1,34 @@ import { DEV } from 'esm-env'; import { + check_dirtiness, current_block, current_component_context, current_effect, current_reaction, destroy_children, + execute_effect, get, remove_reactions, schedule_effect, set_signal_status, untrack } from '../runtime.js'; -import { DIRTY, MANAGED, RENDER_EFFECT, EFFECT, PRE_EFFECT, DESTROYED } from '../constants.js'; +import { + DIRTY, + MANAGED, + RENDER_EFFECT, + EFFECT, + PRE_EFFECT, + DESTROYED, + INERT, + IS_ELSEIF +} from '../constants.js'; import { set } from './sources.js'; +import { noop } from '../../common.js'; /** * @param {import('./types.js').EffectType} type - * @param {(() => void | (() => void)) | ((b: import('#client').Block) => void | (() => void))} fn + * @param {(() => void | (() => void))} fn * @param {boolean} sync * @param {null | import('#client').Block} block * @param {boolean} init @@ -25,6 +37,7 @@ import { set } from './sources.js'; function create_effect(type, fn, sync, block = current_block, init = true) { /** @type {import('#client').Effect} */ const signal = { + parent: current_effect, block, deps: null, f: type | DIRTY, @@ -34,7 +47,8 @@ function create_effect(type, fn, sync, block = current_block, init = true) { deriveds: null, teardown: null, ctx: current_component_context, - ondestroy: null + ondestroy: null, + transitions: null }; if (current_effect !== null) { @@ -201,8 +215,7 @@ export function invalidate_effect(fn) { } /** - * @template {import('#client').Block} B - * @param {(block: B) => void | (() => void)} fn + * @param {(() => void)} fn * @param {any} block * @param {any} managed * @param {any} sync @@ -217,22 +230,128 @@ export function render_effect(fn, block = current_block, managed = false, sync = } /** - * @param {import('#client').Effect} signal + * @param {import('#client').Effect} effect * @returns {void} */ -export function destroy_effect(signal) { - destroy_children(signal); - remove_reactions(signal, 0); - set_signal_status(signal, DESTROYED); - - signal.teardown?.(); - signal.ondestroy?.(); - signal.fn = - signal.effects = - signal.teardown = - signal.ondestroy = - signal.ctx = - signal.block = - signal.deps = +export function destroy_effect(effect) { + destroy_children(effect); + remove_reactions(effect, 0); + set_signal_status(effect, DESTROYED); + + if (effect.transitions) { + for (const transition of effect.transitions) { + transition.stop(); + } + } + + effect.teardown?.(); + effect.ondestroy?.(); + + // @ts-expect-error + effect.fn = + effect.effects = + effect.teardown = + effect.ondestroy = + effect.ctx = + effect.block = + effect.deps = null; } + +/** + * When a block effect is removed, we don't immediately destroy it or yank it + * out of the DOM, because it might have transitions. Instead, we 'pause' it. + * It stays around (in memory, and in the DOM) until outro transitions have + * completed, and if the state change is reversed then we _resume_ it. + * A paused effect does not update, and the DOM subtree becomes inert. + * @param {import('#client').Effect} effect + * @param {() => void} callback + */ +export function pause_effect(effect, callback = noop) { + /** @type {import('#client').TransitionManager[]} */ + const transitions = []; + + pause_children(effect, transitions, true); + + let remaining = transitions.length; + + if (remaining > 0) { + const check = () => { + if (!--remaining) { + destroy_effect(effect); + callback(); + } + }; + + for (const transition of transitions) { + transition.out(check); + } + } else { + destroy_effect(effect); + callback(); + } +} + +/** + * @param {import('#client').Effect} effect + * @param {import('#client').TransitionManager[]} transitions + * @param {boolean} local + */ +function pause_children(effect, transitions, local) { + if ((effect.f & INERT) !== 0) return; + effect.f ^= INERT; + + if (effect.transitions !== null) { + for (const transition of effect.transitions) { + if (transition.is_global || local) { + transitions.push(transition); + } + } + } + + if (effect.effects !== null) { + for (const child of effect.effects) { + var transparent = (child.f & IS_ELSEIF) !== 0 || (child.f & MANAGED) !== 0; + pause_children(child, transitions, transparent ? local : false); + } + } +} + +/** + * The opposite of `pause_effect`. We call this if (for example) + * `x` becomes falsy then truthy: `{#if x}...{/if}` + * @param {import('#client').Effect} effect + */ +export function resume_effect(effect) { + resume_children(effect, true); +} + +/** + * @param {import('#client').Effect} effect + * @param {boolean} local + */ +function resume_children(effect, local) { + if ((effect.f & INERT) === 0) return; + effect.f ^= INERT; + + // If a dependency of this effect changed while it was paused, + // apply the change now + if (check_dirtiness(effect)) { + execute_effect(effect); + } + + if (effect.effects !== null) { + for (const child of effect.effects) { + var transparent = (child.f & IS_ELSEIF) !== 0 || (child.f & MANAGED) !== 0; + resume_children(child, transparent ? local : false); + } + } + + if (effect.transitions !== null) { + for (const transition of effect.transitions) { + if (transition.is_global || local) { + transition.in(); + } + } + } +} diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 7006a7a71f..c0aceff874 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -1,4 +1,4 @@ -import type { Block, ComponentContext, Equals } from '#client'; +import type { Block, ComponentContext, Equals, TransitionManager } from '#client'; import type { EFFECT, PRE_EFFECT, RENDER_EFFECT } from '../constants'; export type EffectType = typeof EFFECT | typeof PRE_EFFECT | typeof RENDER_EFFECT; @@ -21,7 +21,7 @@ export interface Value extends Signal { export interface Reaction extends Signal { /** The reaction function */ - fn: null | Function; + fn: Function; /** Signals that this signal reads from */ deps: null | Value[]; /** Effects created inside this signal */ @@ -36,6 +36,7 @@ export interface Derived extends Value, Reaction { } export interface Effect extends Reaction { + parent: Effect | null; /** The block associated with this effect */ block: null | Block; /** The associated component context */ @@ -43,11 +44,13 @@ export interface Effect extends Reaction { /** Stuff to do when the effect is destroyed */ ondestroy: null | (() => void); /** The effect function */ - fn: null | (() => void | (() => void)) | ((b: Block, s: Signal) => void | (() => void)); + fn: () => void | (() => void); /** The teardown function returned from the effect function */ teardown: null | (() => void); /** The depth from the root signal, used for ordering render/pre-effects topologically **/ l: number; + /** Transition managers created with `$.transition` */ + transitions: null | TransitionManager[]; } export interface ValueDebug extends Value { diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index 70d8ba31a1..c6d20d4be7 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -12,7 +12,6 @@ import { set_current_hydration_fragment } from './dom/hydration.js'; import { array_from } from './utils.js'; -import { ROOT_BLOCK } from './constants.js'; import { handle_event_propagation } from './dom/elements/events.js'; /** @type {Set} */ @@ -21,6 +20,18 @@ export const all_registered_events = new Set(); /** @type {Set<(events: Array) => void>} */ export const root_event_handles = new Set(); +/** + * This is normally true — block effects should run their intro transitions — + * but is false during hydration and mounting (unless `options.intro` is `true`) + * and when creating the children of a `` that just changed tag + */ +export let should_intro = true; + +/** @param {boolean} value */ +export function set_should_intro(value) { + should_intro = value; +} + /** * @param {Element} dom * @param {() => string} value @@ -51,19 +62,19 @@ export function text(dom, value) { } /** - * @param {Comment} anchor_node + * @param {Comment} anchor * @param {void | ((anchor: Comment, slot_props: Record) => void)} slot_fn * @param {Record} slot_props * @param {null | ((anchor: Comment) => void)} fallback_fn */ -export function slot(anchor_node, slot_fn, slot_props, fallback_fn) { - hydrate_block_anchor(anchor_node); +export function slot(anchor, slot_fn, slot_props, fallback_fn) { + hydrate_block_anchor(anchor); if (slot_fn === undefined) { if (fallback_fn !== null) { - fallback_fn(anchor_node); + fallback_fn(anchor); } } else { - slot_fn(anchor_node, slot_props); + slot_fn(anchor, slot_props); } } @@ -198,6 +209,8 @@ function _mount(Component, options) { const registered_events = new Set(); const container = options.target; + should_intro = options.intro ?? false; + /** @type {import('#client').RootBlock} */ const block = { // dom @@ -207,11 +220,7 @@ function _mount(Component, options) { // intro i: options.intro || false, // parent - p: null, - // transition - r: null, - // type - t: ROOT_BLOCK + p: null }; /** @type {Exports} */ @@ -246,6 +255,8 @@ function _mount(Component, options) { const bound_event_listener = handle_event_propagation.bind(null, container); const bound_document_event_listener = handle_event_propagation.bind(null, document); + should_intro = true; + /** @param {Array} events */ const event_handle = (events) => { for (let i = 0; i < events.length; i++) { diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 745342bf6e..fe2c323c32 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -21,7 +21,8 @@ import { DESTROYED, INERT, MANAGED, - STATE_SYMBOL + STATE_SYMBOL, + EFFECT_RAN } from './constants.js'; import { flush_tasks } from './dom/task.js'; import { add_owner } from './dev/ownership.js'; @@ -54,9 +55,19 @@ let flush_count = 0; /** @type {null | import('./types.js').Reaction} */ export let current_reaction = null; +/** @param {null | import('./types.js').Reaction} reaction */ +export function set_current_reaction(reaction) { + current_reaction = reaction; +} + /** @type {null | import('./types.js').Effect} */ export let current_effect = null; +/** @param {null | import('./types.js').Effect} effect */ +export function set_current_effect(effect) { + current_effect = effect; +} + /** @type {null | import('./types.js').Value[]} */ export let current_dependencies = null; let current_dependencies_index = 0; @@ -104,6 +115,11 @@ export let current_block = null; /** @type {import('./types.js').ComponentContext | null} */ export let current_component_context = null; +/** @param {import('./types.js').ComponentContext | null} context */ +export function set_current_component_context(context) { + current_component_context = context; +} + /** @returns {boolean} */ export function is_runes() { return current_component_context !== null && current_component_context.r; @@ -147,7 +163,7 @@ export function batch_inspect(target, prop, receiver) { * @param {import('./types.js').Reaction} reaction * @returns {boolean} */ -function check_dirtiness(reaction) { +export function check_dirtiness(reaction) { var flags = reaction.f; if ((flags & DIRTY) !== 0) { @@ -200,7 +216,6 @@ function check_dirtiness(reaction) { export function execute_reaction_fn(signal) { const fn = signal.fn; const flags = signal.f; - const is_render_effect = (flags & RENDER_EFFECT) !== 0; const previous_dependencies = current_dependencies; const previous_dependencies_index = current_dependencies_index; @@ -217,19 +232,7 @@ export function execute_reaction_fn(signal) { current_untracking = false; try { - let res; - if (is_render_effect) { - res = /** @type {(block: import('#client').Block, signal: import('#client').Signal) => V} */ ( - fn - )( - /** @type {import('#client').Block} */ ( - /** @type {import('#client').Effect} */ (signal).block - ), - /** @type {import('#client').Signal} */ (signal) - ); - } else { - res = /** @type {() => V} */ (fn)(); - } + let res = fn(); let dependencies = /** @type {import('./types.js').Value[]} **/ (signal.deps); if (current_dependencies !== null) { let i; @@ -548,6 +551,8 @@ export function schedule_effect(signal, sync) { } } } + + signal.f |= EFFECT_RAN; } /** diff --git a/packages/svelte/src/internal/client/timing.js b/packages/svelte/src/internal/client/timing.js index 161e92a733..241fdcec73 100644 --- a/packages/svelte/src/internal/client/timing.js +++ b/packages/svelte/src/internal/client/timing.js @@ -9,5 +9,6 @@ const now = is_client ? () => performance.now() : () => Date.now(); /** @type {import('./types.js').Raf} */ export const raf = { tick: /** @param {any} _ */ (_) => request_animation_frame(_), - now: () => now() + now: () => now(), + tasks: new Set() }; diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index e4f776715a..489f786b23 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -1,16 +1,4 @@ -import { - ROOT_BLOCK, - EACH_BLOCK, - EACH_ITEM_BLOCK, - IF_BLOCK, - AWAIT_BLOCK, - KEY_BLOCK, - HEAD_BLOCK, - DYNAMIC_COMPONENT_BLOCK, - DYNAMIC_ELEMENT_BLOCK, - SNIPPET_BLOCK, - STATE_SYMBOL -} from './constants.js'; +import { STATE_SYMBOL } from './constants.js'; import type { Effect, Source, Value } from './reactivity/types.js'; type EventCallback = (event: Event) => boolean; @@ -59,42 +47,8 @@ export type ComponentContext = { export type Equals = (this: Value, value: unknown) => boolean; -export type BlockType = - | typeof ROOT_BLOCK - | typeof EACH_BLOCK - | typeof EACH_ITEM_BLOCK - | typeof IF_BLOCK - | typeof AWAIT_BLOCK - | typeof KEY_BLOCK - | typeof SNIPPET_BLOCK - | typeof HEAD_BLOCK - | typeof DYNAMIC_COMPONENT_BLOCK - | typeof DYNAMIC_ELEMENT_BLOCK; - export type TemplateNode = Text | Element | Comment; -export type Transition = { - /** effect */ - e: Effect; - /** payload */ - p: null | TransitionPayload; - /** init */ - i: (from?: DOMRect) => TransitionPayload; - /** finished */ - f: (fn: () => void) => void; - in: () => void; - /** out */ - o: () => void; - /** cancel */ - c: () => void; - /** cleanup */ - x: () => void; - /** direction */ - r: 'in' | 'out' | 'both' | 'key'; - /** dom */ - d: HTMLElement; -}; - export type RootBlock = { /** dom */ d: null | TemplateNode | Array; @@ -104,10 +58,6 @@ export type RootBlock = { i: boolean; /** parent */ p: null; - /** transition */ - r: null | ((transition: Transition) => void); - /** type */ - t: typeof ROOT_BLOCK; }; export type IfBlock = { @@ -119,31 +69,6 @@ export type IfBlock = { e: null | Effect; /** parent */ p: Block; - /** transition */ - r: null | ((transition: Transition) => void); - /** consequent transitions */ - c: null | Set; - /** alternate transitions */ - a: null | Set; - /** effect */ - ce: null | Effect; - /** effect */ - ae: null | Effect; - /** type */ - t: typeof IF_BLOCK; -}; - -export type KeyBlock = { - /** dom */ - d: null | TemplateNode | Array; - /** effect */ - e: null | Effect; - /** parent */ - p: Block; - /** transition */ - r: null | ((transition: Transition) => void); - /** type */ - t: typeof KEY_BLOCK; }; export type HeadBlock = { @@ -153,10 +78,6 @@ export type HeadBlock = { e: null | Effect; /** parent */ p: Block; - /** transition */ - r: null | ((transition: Transition) => void); - /** type */ - t: typeof HEAD_BLOCK; }; export type DynamicElementBlock = { @@ -166,10 +87,6 @@ export type DynamicElementBlock = { e: null | Effect; /** parent */ p: Block; - /** transition */ - r: null | ((transition: Transition) => void); - /** type */ - t: typeof DYNAMIC_ELEMENT_BLOCK; }; export type DynamicComponentBlock = { @@ -179,10 +96,6 @@ export type DynamicComponentBlock = { e: null | Effect; /** parent */ p: Block; - /** transition */ - r: null | ((transition: Transition) => void); - /** type */ - t: typeof DYNAMIC_COMPONENT_BLOCK; }; export type AwaitBlock = { @@ -194,15 +107,9 @@ export type AwaitBlock = { p: Block; /** pending */ n: boolean; - /** transition */ - r: null | ((transition: Transition) => void); - /** type */ - t: typeof AWAIT_BLOCK; }; export type EachBlock = { - /** anchor */ - a: Element | Comment; /** flags */ f: number; /** dom */ @@ -213,35 +120,21 @@ export type EachBlock = { e: null | Effect; /** parent */ p: Block; - /** transition */ - r: null | ((transition: Transition) => void); - /** transitions */ - s: Array; - /** type */ - t: typeof EACH_BLOCK; }; export type EachItemBlock = { - /** transition */ - a: null | ((block: EachItemBlock, transitions: Set) => void); + /** animation manager */ + a: AnimationManager | null; /** dom */ d: null | TemplateNode | Array; /** effect */ - e: null | Effect; + e: Effect; /** item */ v: any | Source; /** index */ i: number | Source; /** key */ k: unknown; - /** parent */ - p: EachBlock; - /** transition */ - r: null | ((transition: Transition) => void); - /** transitions */ - s: null | Set; - /** type */ - t: typeof EACH_ITEM_BLOCK; }; export type SnippetBlock = { @@ -251,10 +144,6 @@ export type SnippetBlock = { p: Block; /** effect */ e: null | Effect; - /** transition */ - r: null; - /** type */ - t: typeof SNIPPET_BLOCK; }; export type Block = @@ -264,25 +153,54 @@ export type Block = | DynamicElementBlock | DynamicComponentBlock | HeadBlock - | KeyBlock | EachBlock | EachItemBlock | SnippetBlock; +export interface TransitionManager { + /** Whether the `global` modifier was used (i.e. `transition:fade|global`) */ + is_global: boolean; + /** Called inside `resume_effect` */ + in: () => void; + /** Called inside `pause_effect` */ + out: (callback?: () => void) => void; + /** Called inside `destroy_effect` */ + stop: () => void; +} + +export interface AnimationManager { + /** An element with an `animate:` directive */ + element: Element; + /** Called during keyed each block reconciliation, before updates */ + measure: () => void; + /** Called during keyed each block reconciliation, after updates — this triggers the animation */ + apply: () => void; +} + +export interface Animation { + /** Abort the animation */ + abort: () => void; + /** Allow the animation to continue running, but remove any callback. This prevents the removal of an outroing block if the corresponding intro has a `delay` */ + deactivate: () => void; + /** Resets an animation to its starting state, if it uses `tick`. Exposed as a separate method so that an aborted `out:` can still reset even if the `outro` had already completed */ + reset: () => void; + /** Get the `t` value (between `0` and `1`) of the animation, so that its counterpart can start from the right place */ + t: (now: number) => number; +} + export type TransitionFn

= ( element: Element, props: P, options: { direction?: 'in' | 'out' | 'both' } -) => TransitionPayload; +) => AnimationConfig | ((options: { direction?: 'in' | 'out' }) => AnimationConfig); export type AnimateFn

= ( element: Element, rects: { from: DOMRect; to: DOMRect }, - props: P, - options: {} -) => TransitionPayload; + props: P +) => AnimationConfig; -export type TransitionPayload = { +export type AnimationConfig = { delay?: number; duration?: number; easing?: (t: number) => number; @@ -307,15 +225,17 @@ export type Render = { d: null | TemplateNode | Array; /** effect */ e: null | Effect; - /** transitions */ - s: Set; /** prev */ p: Render | null; }; export type Raf = { + /** Alias for `requestAnimationFrame`, exposed in such a way that we can override in tests */ tick: (callback: (time: DOMHighResTimeStamp) => void) => any; + /** Alias for `performance.now()`, exposed in such a way that we can override in tests */ now: () => number; + /** A set of tasks that will run to completion, unless aborted */ + tasks: Set; }; export interface Task { diff --git a/packages/svelte/tests/animation-helpers.js b/packages/svelte/tests/animation-helpers.js index 94bb1839a0..a941a643cc 100644 --- a/packages/svelte/tests/animation-helpers.js +++ b/packages/svelte/tests/animation-helpers.js @@ -14,6 +14,7 @@ export const raf = { raf.ticks.add(f); }; svelte_raf.now = () => raf.time; + svelte_raf.tasks.clear(); } }; @@ -31,125 +32,144 @@ function tick(time) { } class Animation { + #target; #keyframes; #duration; - #timeline_offset; - #reversed; - #target; - #paused; + + #offset = raf.time; + + #finished = () => {}; + #cancelled = () => {}; + + currentTime = 0; /** * @param {HTMLElement} target * @param {Keyframe[]} keyframes - * @param {{duration?: number}} options + * @param {{ duration: number }} options // TODO add delay */ - constructor(target, keyframes, options = {}) { + constructor(target, keyframes, { duration }) { this.#target = target; this.#keyframes = keyframes; - this.#duration = options.duration || 0; - this.#timeline_offset = 0; - this.#reversed = false; - this.#paused = false; - this.onfinish = () => {}; - this.pending = true; - this.currentTime = 0; - this.playState = 'running'; - this.effect = { - setKeyframes: (/** @type {Keyframe[]} */ keyframes) => { - this.#keyframes = keyframes; + this.#duration = duration; + + // Promise-like semantics, but call callbacks immediately on raf.tick + this.finished = { + /** @param {() => void} callback */ + then: (callback) => { + this.#finished = callback; + + return { + /** @param {() => void} callback */ + catch: (callback) => { + this.#cancelled = callback; + } + }; } }; - } - play() { - this.#paused = false; - raf.animations.add(this); - this.playState = 'running'; this._update(); } _update() { - if (this.#reversed) { - if (this.#timeline_offset === 0) { - this.currentTime = this.#duration - raf.time; - } else { - this.currentTime = this.#timeline_offset + (this.#timeline_offset - raf.time); - } - } else { - this.currentTime = raf.time - this.#timeline_offset; - } + this.currentTime = raf.time - this.#offset; const target_frame = this.currentTime / this.#duration; - this._applyKeyFrame(target_frame); + this.#apply_keyframe(target_frame); + + if (this.currentTime >= this.#duration) { + this.#finished(); + raf.animations.delete(this); + } } /** - * @param {number} target_frame + * @param {number} t */ - _applyKeyFrame(target_frame) { - const keyframes = this.#keyframes; - const keyframes_size = keyframes.length - 1; - const frame = keyframes[Math.min(keyframes_size, Math.floor(keyframes.length * target_frame))]; + #apply_keyframe(t) { + const n = Math.min(1, Math.max(0, t)) * (this.#keyframes.length - 1); + + const lower = this.#keyframes[Math.floor(n)]; + const upper = this.#keyframes[Math.ceil(n)]; + + let frame = lower; + if (lower !== upper) { + frame = {}; + + for (const key in lower) { + frame[key] = interpolate( + /** @type {string} */ (lower[key]), + /** @type {string} */ (upper[key]), + n % 1 + ); + } + } + for (let prop in frame) { // @ts-ignore this.#target.style[prop] = frame[prop]; } - if (this.#reversed) { - if (this.currentTime <= 0) { - this.finish(); - for (let prop in frame) { - // @ts-ignore - this.#target.style[prop] = null; - } - } - } else { - if (this.currentTime >= this.#duration) { - this.finish(); - for (let prop in frame) { - // @ts-ignore - this.#target.style[prop] = null; - } - } - } - } - finish() { - this.onfinish(); - this.currentTime = this.#reversed ? 0 : this.#duration; - if (this.#reversed) { - raf.animations.delete(this); + if (this.currentTime >= this.#duration) { + this.currentTime = this.#duration; + for (let prop in frame) { + // @ts-ignore + this.#target.style[prop] = null; + } } - this.playState = 'idle'; } cancel() { - this.#paused = true; if (this.currentTime > 0 && this.currentTime < this.#duration) { - this._applyKeyFrame(this.#reversed ? this.#keyframes.length - 1 : 0); + this.#apply_keyframe(0); } - } - pause() { - this.#paused = true; - this.playState = 'paused'; + this.#cancelled(); + raf.animations.delete(this); } +} + +/** + * @param {string} a + * @param {string} b + * @param {number} p + */ +function interpolate(a, b, p) { + if (a === b) return a; + + const fallback = p < 0.5 ? a : b; + + const a_match = a.match(/[\d.]+|[^\d.]+/g); + const b_match = b.match(/[\d.]+|[^\d.]+/g); + + if (!a_match || !b_match) return fallback; + if (a_match.length !== b_match.length) return fallback; - reverse() { - if (this.#paused && !raf.animations.has(this)) { - raf.animations.add(this); + let result = ''; + + for (let i = 0; i < a_match.length; i += 2) { + const a_num = parseFloat(a_match[i]); + const b_num = parseFloat(b_match[i]); + result += a_num + (b_num - a_num) * p; + + if (a_match[i + 1] !== b_match[i + 1]) { + // bail + return fallback; } - this.#timeline_offset = this.currentTime; - this.#reversed = !this.#reversed; - this.playState = 'running'; + + result += a_match[i + 1] ?? ''; } + + return result; } /** * @param {Keyframe[]} keyframes - * @param {{duration?: number}} options + * @param {{duration: number}} options * @returns {globalThis.Animation} */ HTMLElement.prototype.animate = function (keyframes, options) { const animation = new Animation(this, keyframes, options); + raf.animations.add(animation); // @ts-ignore return animation; }; diff --git a/packages/svelte/tests/runtime-legacy/samples/await-mount-and-unmount-immediately/Component.svelte b/packages/svelte/tests/runtime-legacy/samples/await-mount-and-unmount-immediately/Component.svelte index be0c7e36b0..f1f5971a06 100644 --- a/packages/svelte/tests/runtime-legacy/samples/await-mount-and-unmount-immediately/Component.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/await-mount-and-unmount-immediately/Component.svelte @@ -1,11 +1,13 @@ diff --git a/packages/svelte/tests/runtime-legacy/samples/await-mount-and-unmount-immediately/main.svelte b/packages/svelte/tests/runtime-legacy/samples/await-mount-and-unmount-immediately/main.svelte index 245304be83..d8dc75288b 100644 --- a/packages/svelte/tests/runtime-legacy/samples/await-mount-and-unmount-immediately/main.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/await-mount-and-unmount-immediately/main.svelte @@ -6,7 +6,7 @@ let resolve; let value = 0; export let logs = []; - + async function new_promise() { promise = new Promise(r => { resolve = r; @@ -20,7 +20,7 @@ export async function test() { resolve_promise(); - await Promise.resolve(); + await tick(); new_promise(); resolve_promise(); return tick(); @@ -33,4 +33,4 @@ Loading... {:then state} -{/await} \ No newline at end of file +{/await} diff --git a/packages/svelte/tests/runtime-legacy/samples/class-shortcut-with-transition/_config.js b/packages/svelte/tests/runtime-legacy/samples/class-shortcut-with-transition/_config.js index 3df138e99a..6e3f7a3cb7 100644 --- a/packages/svelte/tests/runtime-legacy/samples/class-shortcut-with-transition/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/class-shortcut-with-transition/_config.js @@ -18,7 +18,7 @@ export default test({ raf.tick(150); assert.htmlEqual( target.innerHTML, - '

foo

bar

' + '

foo

bar

' ); component.open = true; raf.tick(250); diff --git a/packages/svelte/tests/runtime-legacy/samples/dynamic-element-animation/_config.js b/packages/svelte/tests/runtime-legacy/samples/dynamic-element-animation/_config.js index 6315a32d86..3d127f1375 100644 --- a/packages/svelte/tests/runtime-legacy/samples/dynamic-element-animation/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/dynamic-element-animation/_config.js @@ -23,7 +23,6 @@ export default test({ `, test({ assert, component, target, raf }) { - raf.tick(0); component.tag = 'p'; assert.equal(target.querySelectorAll('p').length, 5); @@ -52,11 +51,11 @@ export default test({ ]; divs = target.querySelectorAll('div'); - assert.ok(~divs[0].style.transform); - assert.equal(divs[1].style.transform, 'translate(1px, 0px)'); - assert.equal(divs[2].style.transform, 'translate(1px, 0px)'); - assert.equal(divs[3].style.transform, 'translate(1px, 0px)'); - assert.ok(~divs[4].style.transform); + assert.equal(divs[0].style.transform, 'translate(0px, 120px)'); + assert.equal(divs[1].style.transform, ''); + assert.equal(divs[2].style.transform, ''); + assert.equal(divs[3].style.transform, ''); + assert.equal(divs[4].style.transform, 'translate(0px, -120px)'); raf.tick(100); assert.deepEqual([divs[0].style.transform, divs[4].style.transform], ['', '']); diff --git a/packages/svelte/tests/runtime-legacy/samples/dynamic-element-animation/main.svelte b/packages/svelte/tests/runtime-legacy/samples/dynamic-element-animation/main.svelte index 596d12c77a..0fa9fecf30 100644 --- a/packages/svelte/tests/runtime-legacy/samples/dynamic-element-animation/main.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/dynamic-element-animation/main.svelte @@ -8,11 +8,11 @@ return { duration: 100, - css: (t, u) => `transform: translate(${u + dx}px, ${u * dy}px)` + css: (t, u) => `transform: translate(${u * dx}px, ${u * dy}px)` }; } {#each things as thing (thing.id)} {thing.name} -{/each} \ No newline at end of file +{/each} diff --git a/packages/svelte/tests/runtime-legacy/samples/dynamic-element-transition/_config.js b/packages/svelte/tests/runtime-legacy/samples/dynamic-element-transition/_config.js index 842a3f8f5f..6b55bcf44c 100644 --- a/packages/svelte/tests/runtime-legacy/samples/dynamic-element-transition/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/dynamic-element-transition/_config.js @@ -15,8 +15,9 @@ export default test({ assert.equal(h1.style.opacity, ''); assert.equal(h2.style.opacity, ''); - raf.tick(50); + raf.tick(200); component.visible = false; - assert.equal(h2.style.opacity, '0.49998000000000004'); + raf.tick(250); + assert.equal(h2.style.opacity, '0.5'); } }); diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-css-deferred-removal/_config.js b/packages/svelte/tests/runtime-legacy/samples/transition-css-deferred-removal/_config.js index 99f60053d4..909c93d969 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-css-deferred-removal/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/transition-css-deferred-removal/_config.js @@ -1,4 +1,4 @@ -import { ok, test } from '../../test'; +import { test } from '../../test'; export default test({ get props() { @@ -8,14 +8,11 @@ export default test({ test({ assert, component, target, raf }) { component.visible = false; + raf.tick(150); + const outer = /** @type {HTMLSpanElement} */ (target.querySelector('.outer')); const inner = /** @type {HTMLSpanElement} */ (target.querySelector('.inner')); - raf.tick(150); - - assert.deepEqual( - [outer.style.cssText, inner.style.cssText], - ['opacity: 0.24999000000000002;', ''] - ); + assert.deepEqual([outer.style.cssText, inner.style.cssText], ['opacity: 0.25;', '']); } }); diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-css-duration/_config.js b/packages/svelte/tests/runtime-legacy/samples/transition-css-duration/_config.js index d681f74c7c..6567c552e1 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-css-duration/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/transition-css-duration/_config.js @@ -6,14 +6,12 @@ export default test({ const div = target.querySelector('div'); ok(div); - raf.tick(25); - - assert.equal(div.style.opacity, '0.16666'); + raf.tick(50); + assert.equal(div.style.opacity, '0.5'); component.visible = false; - raf.tick(40); - - assert.ok(div.style.opacity === '0'); + raf.tick(75); + assert.equal(div.style.opacity, '0.25'); } }); diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/Foo.svelte b/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/Foo.svelte index c79672f21b..bca4e93daa 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/Foo.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/Foo.svelte @@ -13,4 +13,4 @@ {#if visible}
-{/if} \ No newline at end of file +{/if} diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/_config.js b/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/_config.js index 271ae50883..255de7dca7 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/_config.js @@ -5,6 +5,7 @@ export default test({ async test({ assert, component, target, raf }) { const frame = /** @type {HTMLIFrameElement} */ (target.querySelector('iframe')); await tick(); + await tick(); // TODO investigate why this second tick is necessary. without it, `Foo.svelte` initializes with `visible = true`, incorrectly component.visible = true; const div = frame.contentDocument?.querySelector('div'); @@ -14,8 +15,10 @@ export default test({ component.visible = false; - raf.tick(26); - // The exact number doesn't matter here, this test is about ensuring that transitions work in iframes - assert.equal(Number(div.style.opacity).toFixed(4), '0.8333'); + raf.tick(25); + assert.equal(div.style.opacity, '0.25'); + + raf.tick(35); + assert.equal(div.style.opacity, '0.18333333333333335'); } }); diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-css-in-out-in/_config.js b/packages/svelte/tests/runtime-legacy/samples/transition-css-in-out-in/_config.js index 7a48af8b5b..3d87dc6e10 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-css-in-out-in/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/transition-css-in-out-in/_config.js @@ -6,18 +6,27 @@ export default test({ const div = target.querySelector('div'); ok(div); - assert.equal(div.style.opacity, '0'); + assert.equal(div.style.scale, '0'); raf.tick(50); component.visible = false; // both in and out styles - assert.equal(div.style.opacity, '0.49998000000000004'); + assert.equal(div.style.scale, '0.5'); + assert.equal(div.style.opacity, '1'); + assert.equal(div.style.rotate, '360deg'); raf.tick(75); + + assert.equal(div.style.scale, '0.75'); // intro continues while outro plays + assert.equal(div.style.opacity, '0.75'); + assert.equal(div.style.rotate, '270deg'); + component.visible = true; // reset original styles + assert.equal(div.style.scale, '0'); assert.equal(div.style.opacity, '1'); + assert.equal(div.style.rotate, '360deg'); } }); diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-css-in-out-in/main.svelte b/packages/svelte/tests/runtime-legacy/samples/transition-css-in-out-in/main.svelte index 5108452a39..b82c6da774 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-css-in-out-in/main.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/transition-css-in-out-in/main.svelte @@ -5,7 +5,7 @@ return { duration: 100, css: t => { - return `opacity: ${t}`; + return `scale: ${t}`; } }; } @@ -14,7 +14,7 @@ return { duration: 100, css: t => { - return `opacity: ${t}`; + return `rotate: ${t * 360}deg; opacity: ${t}`; } }; } @@ -22,4 +22,4 @@ {#if visible}
-{/if} \ No newline at end of file +{/if} diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-inert/_config.js b/packages/svelte/tests/runtime-legacy/samples/transition-inert/_config.js index 8679b2fe7f..a474fae9d4 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-inert/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/transition-inert/_config.js @@ -6,29 +6,26 @@ export default test({ const b = target.querySelector('button.b'); ok(a); ok(b); - // jsdom doesn't set the inert attribute, and the transition checks if it exists, so set it manually to trigger the inert logic - a.inert = false; - b.inert = false; // check and abort halfway through the outro transition component.visible = false; raf.tick(50); - assert.strictEqual(target.querySelector('button.a')?.inert, true); - assert.strictEqual(target.querySelector('button.b')?.inert, true); + assert.ok(target.querySelector('button.a')?.inert); + assert.ok(target.querySelector('button.b')?.inert); component.visible = true; - assert.strictEqual(target.querySelector('button.a')?.inert, false); - assert.strictEqual(target.querySelector('button.b')?.inert, false); + assert.ok(!target.querySelector('button.a')?.inert); + assert.ok(!target.querySelector('button.b')?.inert); // let it transition out completely and then back in component.visible = false; raf.tick(101); component.visible = true; raf.tick(150); - assert.strictEqual(target.querySelector('button.a')?.inert, false); - assert.strictEqual(target.querySelector('button.b')?.inert, false); + assert.ok(!target.querySelector('button.a')?.inert); + assert.ok(!target.querySelector('button.b')?.inert); raf.tick(151); - assert.strictEqual(target.querySelector('button.a')?.inert, false); - assert.strictEqual(target.querySelector('button.b')?.inert, false); + assert.ok(!target.querySelector('button.a')?.inert); + assert.ok(!target.querySelector('button.b')?.inert); } }); diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-js-aborted-outro-in-each/_config.js b/packages/svelte/tests/runtime-legacy/samples/transition-js-aborted-outro-in-each/_config.js index a333fe2da5..3216f2a898 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-js-aborted-outro-in-each/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/transition-js-aborted-outro-in-each/_config.js @@ -23,8 +23,6 @@ export default test({ assert.equal(spans[1].foo, 0.25); assert.equal(spans[2].foo, 0.75); - raf.tick(7); - component.things = things; raf.tick(225); @@ -42,8 +40,8 @@ export default test({ target.querySelectorAll('span') ); - assert.equal(spans[0].foo, undefined); - assert.equal(spans[1].foo, undefined); - assert.equal(spans[2].foo, undefined); + assert.equal(spans[0].foo, 1); + assert.equal(spans[1].foo, 1); + assert.equal(spans[2].foo, 1); } }); diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-js-await-block-outros/_config.js b/packages/svelte/tests/runtime-legacy/samples/transition-js-await-block-outros/_config.js index b578ef1431..29537d6ff5 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-js-await-block-outros/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/transition-js-await-block-outros/_config.js @@ -14,8 +14,6 @@ export default test({ intro: true, async test({ assert, target, component, raf }) { - raf.tick(0); - assert.htmlEqual(target.innerHTML, '

loading...

'); let time = 0; @@ -24,10 +22,6 @@ export default test({ assert.htmlEqual(target.innerHTML, '

loading...

'); await fulfil(42); - await Promise.resolve(); - await Promise.resolve(); - - raf.tick(time); assert.htmlEqual( target.innerHTML, @@ -61,9 +55,6 @@ export default test({ fulfil = f; }); await Promise.resolve(); - await Promise.resolve(); - - raf.tick(time); assert.htmlEqual( target.innerHTML, @@ -83,11 +74,6 @@ export default test({ ); await fulfil(43); - await Promise.resolve(); - await Promise.resolve(); - - raf.tick(time); - assert.htmlEqual( target.innerHTML, ` @@ -109,9 +95,6 @@ export default test({ fulfil = f; }); await Promise.resolve(); - await Promise.resolve(); - - raf.tick(time); assert.htmlEqual( target.innerHTML, @@ -132,11 +115,6 @@ export default test({ ); await fulfil(44); - await Promise.resolve(); - await Promise.resolve(); - - raf.tick(time); - assert.htmlEqual( target.innerHTML, ` @@ -159,10 +137,6 @@ export default test({ fulfil = f; }); await Promise.resolve(); - await Promise.resolve(); - - raf.tick(time); - assert.htmlEqual( target.innerHTML, ` @@ -172,7 +146,6 @@ export default test({ ); raf.tick((time += 40)); - assert.htmlEqual( target.innerHTML, ` @@ -182,10 +155,6 @@ export default test({ ); await fulfil(45); - await Promise.resolve(); - await Promise.resolve(); - - raf.tick(time); assert.htmlEqual( target.innerHTML, @@ -197,7 +166,6 @@ export default test({ ); raf.tick((time += 20)); - assert.htmlEqual( target.innerHTML, ` @@ -211,9 +179,6 @@ export default test({ fulfil = f; }); await Promise.resolve(); - await Promise.resolve(); - - raf.tick(time); assert.htmlEqual( target.innerHTML, @@ -238,10 +203,6 @@ export default test({ ); await fulfil(46); - await Promise.resolve(); - await Promise.resolve(); - - raf.tick(time); assert.htmlEqual( target.innerHTML, @@ -255,7 +216,6 @@ export default test({ ); raf.tick((time += 10)); - assert.htmlEqual( target.innerHTML, ` @@ -265,7 +225,6 @@ export default test({ ); raf.tick((time += 20)); - assert.htmlEqual( target.innerHTML, ` @@ -274,7 +233,6 @@ export default test({ ); raf.tick((time += 70)); - assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-js-await-block/_config.js b/packages/svelte/tests/runtime-legacy/samples/transition-js-await-block/_config.js index 9ec5ba476b..706b306ebb 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-js-await-block/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/transition-js-await-block/_config.js @@ -1,4 +1,4 @@ -import { ok, test } from '../../test'; +import { test } from '../../test'; /** @type {(value: any) => void} */ let fulfil; @@ -36,8 +36,8 @@ export default test({ ); assert.equal(ps[0].className, 'pending'); assert.equal(ps[1].className, 'then'); - assert.equal(ps[0].foo, 0.8); - assert.equal(ps[1].foo, undefined); + assert.equal(ps[0].foo, 0.2); + assert.equal(ps[1].foo, 0.3); raf.tick(100); }); } diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-js-each-block-intro-outro/_config.js b/packages/svelte/tests/runtime-legacy/samples/transition-js-each-block-intro-outro/_config.js index 144dcf03bc..58de002579 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-js-each-block-intro-outro/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/transition-js-each-block-intro-outro/_config.js @@ -35,9 +35,9 @@ export default test({ component.visible = true; raf.tick(100); - assert.equal(divs[0].foo, 1); - assert.equal(divs[1].foo, 1); - assert.equal(divs[2].foo, 1); + assert.equal(divs[0].foo, 0.3); + assert.equal(divs[1].foo, 0.3); + assert.equal(divs[2].foo, 0.3); assert.equal(divs[0].bar, 1); assert.equal(divs[1].bar, 1); diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-js-if-else-block-intro/_config.js b/packages/svelte/tests/runtime-legacy/samples/transition-js-if-else-block-intro/_config.js index 130cfb9dde..0e8d3022d3 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-js-if-else-block-intro/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/transition-js-if-else-block-intro/_config.js @@ -15,7 +15,7 @@ export default test({ raf.tick(500); component.x = true; assert.equal(component.no, null); - assert.equal(component.yes.foo, undefined); + assert.equal(component.yes.foo, 0); raf.tick(700); assert.equal(component.yes.foo, 0.5); diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-transition/_config.js b/packages/svelte/tests/runtime-runes/samples/dynamic-component-transition/_config.js index f80737e8eb..9c741d2b8c 100644 --- a/packages/svelte/tests/runtime-runes/samples/dynamic-component-transition/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-transition/_config.js @@ -11,7 +11,7 @@ export default test({ btn?.click(); }); - assert.htmlEqual(target.innerHTML, `

Outside

`); + assert.htmlEqual(target.innerHTML, `

Outside

`); raf.tick(100); diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-if-component-transition/_config.js b/packages/svelte/tests/runtime-runes/samples/dynamic-if-component-transition/_config.js index f80737e8eb..9c741d2b8c 100644 --- a/packages/svelte/tests/runtime-runes/samples/dynamic-if-component-transition/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-if-component-transition/_config.js @@ -11,7 +11,7 @@ export default test({ btn?.click(); }); - assert.htmlEqual(target.innerHTML, `

Outside

`); + assert.htmlEqual(target.innerHTML, `

Outside

`); raf.tick(100);