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>
pull/10870/head
Rich Harris 5 months ago committed by GitHub
parent f6eca83b7c
commit e8ce41815a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -11,3 +11,5 @@
/motion.d.ts /motion.d.ts
/store.d.ts /store.d.ts
/transition.d.ts /transition.d.ts
/scripts/_bundle.js

@ -74,7 +74,7 @@ for (const key in pkg.exports) {
} }
const client_main = path.resolve(pkg.exports['.'].browser); 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 // Use all features which contain hydration code to ensure it's treeshakeable
compile( compile(
` `
@ -109,15 +109,17 @@ const without_hydration = await bundle_code(
).js.code ).js.code
); );
if (!without_hydration.includes('current_hydration_fragment')) { if (!bundle.includes('current_hydration_fragment')) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`✅ Hydration code treeshakeable`); console.error(`✅ Hydration code treeshakeable`);
} else { } else {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(without_hydration); console.error(bundle);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`❌ Hydration code not treeshakeable`); console.error(`❌ Hydration code not treeshakeable`);
failed = true; failed = true;
fs.writeFileSync('scripts/_bundle.js', bundle);
} }
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

@ -28,10 +28,14 @@ import {
AttributeAliases, AttributeAliases,
DOMBooleanAttributes, DOMBooleanAttributes,
EACH_INDEX_REACTIVE, EACH_INDEX_REACTIVE,
EACH_IS_ANIMATED,
EACH_IS_CONTROLLED, EACH_IS_CONTROLLED,
EACH_IS_STRICT_EQUALS, EACH_IS_STRICT_EQUALS,
EACH_ITEM_REACTIVE, EACH_ITEM_REACTIVE,
EACH_KEYED EACH_KEYED,
TRANSITION_GLOBAL,
TRANSITION_IN,
TRANSITION_OUT
} from '../../../../../constants.js'; } from '../../../../../constants.js';
import { regex_is_valid_identifier } from '../../../patterns.js'; import { regex_is_valid_identifier } from '../../../patterns.js';
import { javascript_visitors_runes } from './javascript-runes.js'; import { javascript_visitors_runes } from './javascript-runes.js';
@ -1922,7 +1926,7 @@ export const template_visitors = {
state.init.push( state.init.push(
b.stmt( b.stmt(
b.call( b.call(
'$.animate', '$.animation',
state.node, state.node,
b.thunk( b.thunk(
/** @type {import('estree').Expression} */ (visit(parse_directive_name(node.name))) /** @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'); error(node, 'INTERNAL', 'Node should have been handled elsewhere');
}, },
TransitionDirective(node, { state, visit }) { TransitionDirective(node, { state, visit }) {
const type = node.intro && node.outro ? '$.transition' : node.intro ? '$.in' : '$.out'; let flags = node.modifiers.includes('global') ? TRANSITION_GLOBAL : 0;
const expression = if (node.intro) flags |= TRANSITION_IN;
node.expression === null if (node.outro) flags |= TRANSITION_OUT;
? b.literal(null)
: b.thunk(/** @type {import('estree').Expression} */ (visit(node.expression)));
state.init.push( const args = [
b.stmt( b.literal(flags),
b.call( state.node,
type, b.thunk(/** @type {import('estree').Expression} */ (visit(parse_directive_name(node.name))))
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))));
expression, }
node.modifiers.includes('global') ? b.true : b.false
) state.init.push(b.stmt(b.call('$.transition', ...args)));
)
);
}, },
RegularElement(node, context) { RegularElement(node, context) {
if (node.name === 'noscript') { if (node.name === 'noscript') {
@ -2345,6 +2345,19 @@ export const template_visitors = {
each_type |= EACH_ITEM_REACTIVE; 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) { if (each_node_meta.is_controlled) {
each_type |= EACH_IS_CONTROLLED; each_type |= EACH_IS_CONTROLLED;
} }
@ -2557,22 +2570,44 @@ export const template_visitors = {
context.visit(node.consequent) context.visit(node.consequent)
); );
context.state.after_update.push( const args = [
b.stmt( context.state.node,
b.call( b.thunk(/** @type {import('estree').Expression} */ (context.visit(node.test))),
'$.if', b.arrow([b.id('$$anchor')], consequent),
context.state.node, node.alternate
b.thunk(/** @type {import('estree').Expression} */ (context.visit(node.test))), ? b.arrow(
b.arrow([b.id('$$anchor')], consequent), [b.id('$$anchor')],
node.alternate /** @type {import('estree').BlockStatement} */ (context.visit(node.alternate))
? b.arrow( )
[b.id('$$anchor')], : b.literal(null)
/** @type {import('estree').BlockStatement} */ (context.visit(node.alternate)) ];
)
: b.literal(null) if (node.elseif) {
) // We treat this...
) //
); // {#if x}
// ...
// {:else}
// {#if y}
// <div transition:foo>...</div>
// {/if}
// {/if}
//
// ...slightly differently to this...
//
// {#if x}
// ...
// {:else if y}
// <div transition:foo>...</div>
// {/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) { AwaitBlock(node, context) {
context.state.template.push('<!>'); context.state.template.push('<!>');

@ -12,6 +12,10 @@ export const PROPS_IS_RUNES = 1 << 1;
export const PROPS_IS_UPDATED = 1 << 2; export const PROPS_IS_UPDATED = 1 << 2;
export const PROPS_IS_LAZY_INITIAL = 1 << 3; 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 */ /** List of Element events that will be delegated */
export const DelegatedEvents = [ export const DelegatedEvents = [
'beforeinput', 'beforeinput',

@ -9,17 +9,8 @@ export const DIRTY = 1 << 9;
export const MAYBE_DIRTY = 1 << 10; export const MAYBE_DIRTY = 1 << 10;
export const INERT = 1 << 11; export const INERT = 1 << 11;
export const DESTROYED = 1 << 12; export const DESTROYED = 1 << 12;
export const IS_ELSEIF = 1 << 13;
export const ROOT_BLOCK = 0; export const EFFECT_RAN = 1 << 14;
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 UNINITIALIZED = Symbol(); export const UNINITIALIZED = Symbol();
export const STATE_SYMBOL = Symbol('$state'); export const STATE_SYMBOL = Symbol('$state');

@ -1,10 +1,16 @@
import { is_promise } from '../../../common.js'; import { is_promise } from '../../../common.js';
import { hydrate_block_anchor } from '../hydration.js'; import { hydrate_block_anchor } from '../hydration.js';
import { remove } from '../reconciler.js'; import { remove } from '../reconciler.js';
import { current_block, execute_effect, flushSync } from '../../runtime.js'; import {
import { destroy_effect, render_effect } from '../../reactivity/effects.js'; current_block,
import { trigger_transitions } from '../elements/transitions.js'; current_component_context,
import { AWAIT_BLOCK, UNINITIALIZED } from '../../constants.js'; 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} */ /** @returns {import('../../types.js').AwaitBlock} */
export function create_await_block() { export function create_await_block() {
@ -16,179 +22,125 @@ export function create_await_block() {
// parent // parent
p: /** @type {import('../../types.js').Block} */ (current_block), p: /** @type {import('../../types.js').Block} */ (current_block),
// pending // pending
n: true, n: true
// transition
r: null,
// type
t: AWAIT_BLOCK
}; };
} }
/** /**
* @template V * @template V
* @param {Comment} anchor_node * @param {Comment} anchor
* @param {(() => Promise<V>)} input * @param {(() => Promise<V>)} get_input
* @param {null | ((anchor: Node) => void)} pending_fn * @param {null | ((anchor: Node) => void)} pending_fn
* @param {null | ((anchor: Node, value: V) => void)} then_fn * @param {null | ((anchor: Node, value: V) => void)} then_fn
* @param {null | ((anchor: Node, error: unknown) => void)} catch_fn * @param {null | ((anchor: Node, error: unknown) => void)} catch_fn
* @returns {void} * @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(); const block = create_await_block();
/** @type {null | import('../../types.js').Render} */ const component_context = current_component_context;
let current_render = null;
hydrate_block_anchor(anchor_node); hydrate_block_anchor(anchor);
/** @type {{}} */ /** @type {any} */
let latest_token; let input;
/** @type {typeof UNINITIALIZED | V} */ /** @type {import('#client').Effect | null} */
let resolved_value = UNINITIALIZED; let pending_effect;
/** @type {unknown} */ /** @type {import('#client').Effect | null} */
let error = UNINITIALIZED; let then_effect;
let pending = false;
block.r = /** @type {import('#client').Effect | null} */
/** let catch_effect;
* @param {import('../../types.js').Transition} transition
* @returns {void} /**
*/ * @param {(anchor: Comment, value: any) => void} fn
(transition) => { * @param {any} value
const render = /** @type {import('../../types.js').Render} */ (current_render); */
const transitions = render.s; function create_effect(fn, value) {
transitions.add(transition); set_current_effect(branch);
transition.f(() => { set_current_reaction(branch); // TODO do we need both?
transitions.delete(transition); set_current_component_context(component_context);
if (transitions.size === 0) { var effect = render_effect(() => fn(anchor, value), {}, true);
// If the current render has changed since, then we can remove the old render set_current_component_context(null);
// effect as it's stale. set_current_reaction(null);
if (current_render !== render && render.e !== null) { set_current_effect(null);
if (render.d !== null) {
remove(render.d); // without this, the DOM does not update until two ticks after the promise,
render.d = null; // resolves which is unexpected behaviour (and somewhat irksome to test)
} flushSync();
destroy_effect(render.e);
render.e = null; return effect;
} }
}
}); /** @param {import('#client').Effect} effect */
}; function pause(effect) {
const create_render_effect = () => { if ((effect.f & DESTROYED) !== 0) return;
/** @type {import('../../types.js').Render} */ const block = effect.block;
const render = {
d: null, pause_effect(effect, () => {
e: null, // TODO make this unnecessary
s: new Set(), const dom = block?.d;
p: current_render if (dom) remove(dom);
}; });
const effect = render_effect( }
() => {
if (error === UNINITIALIZED) { const branch = render_effect(() => {
if (resolved_value === UNINITIALIZED) { if (input === (input = get_input())) return;
// pending = true
block.n = true; if (is_promise(input)) {
if (pending_fn !== null) { const promise = /** @type {Promise<any>} */ (input);
pending_fn(anchor_node);
} if (pending_fn) {
} else if (then_fn !== null) { if (pending_effect && (pending_effect.f & INERT) === 0) {
// pending = false if (pending_effect.block?.d) remove(pending_effect.block.d);
block.n = false; destroy_effect(pending_effect);
then_fn(anchor_node, resolved_value);
}
} else if (catch_fn !== null) {
// pending = false
block.n = false;
catch_fn(anchor_node, error);
} }
render.d = block.d;
block.d = null; pending_effect = render_effect(() => pending_fn(anchor), {}, true);
},
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 {
create_render_effect(); if (then_effect) pause(then_effect);
trigger_transitions(transitions, 'out'); if (catch_effect) pause(catch_effect);
}
}; promise.then(
const await_effect = render_effect( (value) => {
() => { if (promise !== input) return;
const token = {}; if (pending_effect) pause(pending_effect);
latest_token = token;
const promise = input(); if (then_fn) {
if (is_promise(promise)) { then_effect = create_effect(then_fn, value);
promise.then( }
/** @param {V} v */ },
(v) => { (error) => {
if (latest_token === token) { if (promise !== input) return;
// Ensure UI is in sync before resolving value. if (pending_effect) pause(pending_effect);
flushSync();
resolved_value = v; if (catch_fn) {
pending = false; catch_effect = create_effect(catch_fn, error);
render();
}
},
/** @param {unknown} _error */
(_error) => {
error = _error;
pending = false;
render();
} }
);
if (resolved_value !== UNINITIALIZED || error !== UNINITIALIZED) {
error = UNINITIALIZED;
resolved_value = UNINITIALIZED;
} }
if (!pending) { );
pending = true; } else {
render(); 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; then_effect = render_effect(() => then_fn(anchor, input), {}, true);
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);
} }
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;
} }

@ -2,7 +2,7 @@ import { namespace_svg } from '../../../../constants.js';
import { current_hydration_fragment, hydrate_block_anchor, hydrating } from '../hydration.js'; import { current_hydration_fragment, hydrate_block_anchor, hydrating } from '../hydration.js';
import { empty } from '../operations.js'; import { empty } from '../operations.js';
import { render_effect } from '../../reactivity/effects.js'; import { render_effect } from '../../reactivity/effects.js';
import { insert, remove } from '../reconciler.js'; import { remove } from '../reconciler.js';
/** /**
* @param {Element | Text | Comment} anchor * @param {Element | Text | Comment} anchor
@ -33,7 +33,7 @@ export function css_props(anchor, is_html, props, component) {
tag = document.createElementNS(namespace_svg, 'g'); tag = document.createElementNS(namespace_svg, 'g');
} }
insert(tag, null, anchor); anchor.before(tag);
component_anchor = empty(); component_anchor = empty();
tag.appendChild(component_anchor); tag.appendChild(component_anchor);
} }

File diff suppressed because it is too large Load Diff

@ -1,4 +1,4 @@
import { IF_BLOCK } from '../../constants.js'; import { IS_ELSEIF } from '../../constants.js';
import { import {
current_hydration_fragment, current_hydration_fragment,
hydrate_block_anchor, hydrate_block_anchor,
@ -6,50 +6,40 @@ import {
set_current_hydration_fragment set_current_hydration_fragment
} from '../hydration.js'; } from '../hydration.js';
import { remove } from '../reconciler.js'; import { remove } from '../reconciler.js';
import { current_block, execute_effect } from '../../runtime.js'; import { current_block } from '../../runtime.js';
import { destroy_effect, render_effect } from '../../reactivity/effects.js'; import {
import { trigger_transitions } from '../elements/transitions.js'; destroy_effect,
pause_effect,
render_effect,
resume_effect
} from '../../reactivity/effects.js';
/** @returns {import('#client').IfBlock} */ /** @returns {import('#client').IfBlock} */
function create_if_block() { function create_if_block() {
return { return {
// alternate transitions
a: null,
// alternate effect
ae: null,
// consequent transitions
c: null,
// consequent effect
ce: null,
// dom // dom
d: null, d: null,
// effect // effect
e: null, e: null,
// parent // parent
p: /** @type {import('#client').Block} */ (current_block), p: /** @type {import('#client').Block} */ (current_block),
// transition
r: null,
// type
t: IF_BLOCK,
// value // value
v: false v: false
}; };
} }
/** /**
* @param {Comment} anchor_node * @param {Comment} anchor
* @param {() => boolean} condition_fn * @param {() => boolean} get_condition
* @param {(anchor: Node) => void} consequent_fn * @param {(anchor: Node) => void} consequent_fn
* @param {null | ((anchor: Node) => void)} alternate_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} * @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(); const block = create_if_block();
hydrate_block_anchor(anchor_node); hydrate_block_anchor(anchor);
/** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */
let mismatch = false;
/** @type {null | import('#client').TemplateNode | Array<import('#client').TemplateNode>} */ /** @type {null | import('#client').TemplateNode | Array<import('#client').TemplateNode>} */
let consequent_dom = null; 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<import('#client').TemplateNode>} */ /** @type {null | import('#client').TemplateNode | Array<import('#client').TemplateNode>} */
let alternate_dom = null; let alternate_dom = null;
let has_mounted = false; /** @type {import('#client').Effect | null} */
let consequent_effect = null;
/** /** @type {import('#client').Effect | null} */
* @type {import('#client').Effect | null} let alternate_effect = null;
*/
let current_branch_effect = null;
/** @type {import('#client').Effect} */ /** @type {boolean | null} */
let consequent_effect; let condition = null;
/** @type {import('#client').Effect} */
let alternate_effect;
const if_effect = render_effect(() => { const if_effect = render_effect(() => {
const result = !!condition_fn(); if (condition === (condition = !!get_condition())) return;
if (block.v !== result || !has_mounted) { /** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */
block.v = result; let mismatch = false;
if (has_mounted) { if (hydrating) {
const consequent_transitions = block.c; const comment_text = /** @type {Comment} */ (current_hydration_fragment?.[0])?.data;
const alternate_transitions = block.a;
if (
if (result) { !comment_text ||
if (alternate_transitions === null || alternate_transitions.size === 0) { (comment_text === 'ssr:if:true' && !condition) ||
execute_effect(alternate_effect); (comment_text === 'ssr:if:false' && condition)
} else { ) {
trigger_transitions(alternate_transitions, 'out'); // Hydration mismatch: remove everything inside the anchor and start fresh.
} // This could happen using when `{#if browser} .. {/if}` in SvelteKit.
remove(current_hydration_fragment);
if (consequent_transitions === null || consequent_transitions.size === 0) { set_current_hydration_fragment(null);
execute_effect(consequent_effect); mismatch = true;
} else { } else {
trigger_transitions(consequent_transitions, 'in'); // Remove the ssr:if comment node or else it will confuse the subsequent hydration algorithm
} current_hydration_fragment.shift();
} 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();
}
} }
has_mounted = true;
} }
// create these here so they have the correct parent/child relationship if (condition) {
consequent_effect ??= render_effect( if (consequent_effect) {
(/** @type {any} */ _, /** @type {import('#client').Effect | null} */ consequent_effect) => { resume_effect(consequent_effect);
const result = block.v; } else {
consequent_effect = render_effect(
if (!result && consequent_dom !== null) { () => {
remove(consequent_dom); consequent_fn(anchor);
consequent_dom = null; consequent_dom = block.d;
}
if (mismatch) {
if (result && current_branch_effect !== consequent_effect) { // Set fragment so that Svelte continues to operate in hydration mode
consequent_fn(anchor_node); set_current_hydration_fragment([]);
if (mismatch && current_branch_effect === null) { }
// 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,
current_branch_effect = consequent_effect; // and removing automatically on teardown
consequent_dom = block.d; if (consequent_dom !== null) {
} remove(consequent_dom);
consequent_dom = null;
block.d = null; }
}, };
block, },
true block,
); true
block.ce = consequent_effect; );
}
alternate_effect ??= render_effect(
(/** @type {any} */ _, /** @type {import('#client').Effect | null} */ alternate_effect) => { if (alternate_effect) {
const result = block.v; pause_effect(alternate_effect, () => {
alternate_effect = null;
if (result && alternate_dom !== null) { if (alternate_dom) remove(alternate_dom);
remove(alternate_dom); });
alternate_dom = null; }
} } else {
if (alternate_effect) {
if (!result && current_branch_effect !== alternate_effect) { resume_effect(alternate_effect);
if (alternate_fn !== null) { } else if (alternate_fn) {
alternate_fn(anchor_node); alternate_effect = render_effect(
} () => {
alternate_fn(anchor);
if (mismatch && current_branch_effect === null) { alternate_dom = block.d;
// Set fragment so that Svelte continues to operate in hydration mode
set_current_hydration_fragment([]); if (mismatch) {
} // Set fragment so that Svelte continues to operate in hydration mode
set_current_hydration_fragment([]);
current_branch_effect = alternate_effect; }
alternate_dom = block.d;
} return () => {
block.d = null; // TODO make this unnecessary by linking the dom to the effect,
}, // and removing automatically on teardown
block, if (alternate_dom !== null) {
true remove(alternate_dom);
); alternate_dom = null;
block.ae = alternate_effect; }
};
},
block,
true
);
}
if (consequent_effect) {
pause_effect(consequent_effect, () => {
consequent_effect = null;
if (consequent_dom) remove(consequent_dom);
});
}
}
}, block); }, block);
if (elseif) {
if_effect.f |= IS_ELSEIF;
}
if_effect.ondestroy = () => { if_effect.ondestroy = () => {
// TODO make this unnecessary by linking the dom to the effect,
// and removing automatically on teardown
if (consequent_dom !== null) { if (consequent_dom !== null) {
remove(consequent_dom); remove(consequent_dom);
} }
@ -193,8 +167,12 @@ export function if_block(anchor_node, condition_fn, consequent_fn, alternate_fn)
remove(alternate_dom); remove(alternate_dom);
} }
destroy_effect(consequent_effect); if (consequent_effect) {
destroy_effect(alternate_effect); destroy_effect(consequent_effect);
}
if (alternate_effect) {
destroy_effect(alternate_effect);
}
}; };
block.e = if_effect; block.e = if_effect;

@ -1,140 +1,76 @@
import { UNINITIALIZED, KEY_BLOCK } from '../../constants.js'; import { UNINITIALIZED } from '../../constants.js';
import { hydrate_block_anchor } from '../hydration.js'; import { hydrate_block_anchor } from '../hydration.js';
import { remove } from '../reconciler.js'; import { remove } from '../reconciler.js';
import { current_block, execute_effect } from '../../runtime.js'; import { pause_effect, render_effect } from '../../reactivity/effects.js';
import { destroy_effect, render_effect } from '../../reactivity/effects.js';
import { trigger_transitions } from '../elements/transitions.js';
import { safe_not_equal } from '../../reactivity/equality.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 * @template V
* @param {Comment} anchor_node * @param {Comment} anchor
* @param {() => V} key * @param {() => V} get_key
* @param {(anchor: Node) => void} render_fn * @param {(anchor: Node) => void} render_fn
* @returns {void} * @returns {void}
*/ */
export function key_block(anchor_node, key, render_fn) { export function key_block(anchor, get_key, render_fn) {
const block = create_key_block(); const block = {};
/** @type {null | import('../../types.js').Render} */ hydrate_block_anchor(anchor);
let current_render = null;
hydrate_block_anchor(anchor_node);
/** @type {V | typeof UNINITIALIZED} */ /** @type {V | typeof UNINITIALIZED} */
let key_value = UNINITIALIZED; let key = UNINITIALIZED;
let mounted = false;
block.r = /** @type {import('#client').Effect} */
/** let effect;
* @param {import('../../types.js').Transition} transition
* @returns {void} /**
*/ * Every time `key` changes, we create a new effect. Old effects are
(transition) => { * removed from this set when they have fully transitioned out
const render = /** @type {import('../../types.js').Render} */ (current_render); * @type {Set<import('#client').Effect>}
const transitions = render.s; */
transitions.add(transition); let effects = new Set();
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();
}
};
const key_effect = render_effect( const key_effect = render_effect(
() => { () => {
const prev_key_value = key_value; if (safe_not_equal(key, (key = get_key()))) {
key_value = key(); if (effect) {
if (mounted && safe_not_equal(prev_key_value, key_value)) { var e = effect;
render(); 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, block,
false false
); );
// To ensure topological ordering of the key effect to the render effect,
// we trigger the effect after.
render();
mounted = true;
key_effect.ondestroy = () => { key_effect.ondestroy = () => {
let render = current_render; for (const e of effects) {
while (render !== null) { // @ts-expect-error TODO tidy up. ondestroy should be totally unnecessary
const dom = render.d; if (e.d) remove(e.d);
if (dom !== null) {
remove(dom);
}
const effect = render.e;
if (effect !== null) {
destroy_effect(effect);
}
render = render.p;
} }
}; };
block.e = key_effect;
} }

@ -1,4 +1,3 @@
import { SNIPPET_BLOCK } from '../../constants.js';
import { render_effect } from '../../reactivity/effects.js'; import { render_effect } from '../../reactivity/effects.js';
import { remove } from '../reconciler.js'; import { remove } from '../reconciler.js';
import { current_block, untrack } from '../../runtime.js'; import { current_block, untrack } from '../../runtime.js';
@ -17,11 +16,7 @@ export function snippet(get_snippet, node, ...args) {
// parent // parent
p: /** @type {import('#client').Block} */ (current_block), p: /** @type {import('#client').Block} */ (current_block),
// effect // effect
e: null, e: null
// transition
r: null,
// type
t: SNIPPET_BLOCK
}; };
render_effect(() => { render_effect(() => {

@ -1,127 +1,69 @@
import { DYNAMIC_COMPONENT_BLOCK } from '../../constants.js';
import { hydrate_block_anchor } from '../hydration.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 { 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 * @template P
* @param {Comment} anchor_node * @template {(props: P) => void} C
* @param {() => (props: P) => void} component_fn * @param {Comment} anchor
* @param {(component: (props: P) => void) => void} render_fn * @param {() => C} get_component
* @param {(component: C) => void} render_fn
* @returns {void} * @returns {void}
*/ */
export function component(anchor_node, component_fn, render_fn) { export function component(anchor, get_component, render_fn) {
/** @type {import('#client').DynamicComponentBlock} */ const block = {};
const block = {
// dom
d: null,
// effect
e: null,
// parent
p: /** @type {import('#client').Block} */ (current_block),
// transition
r: null,
// type
t: DYNAMIC_COMPONENT_BLOCK
};
/** @type {null | import('#client').Render} */ hydrate_block_anchor(anchor);
let current_render = null;
hydrate_block_anchor(anchor_node);
/** @type {null | ((props: P) => void)} */ /** @type {C} */
let component = null; let component;
block.r = /** @type {import('#client').Effect} */
/** let effect;
* @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;
}
}
});
};
const create_render_effect = () => { /**
/** @type {import('#client').Render} */ * Every time `component` changes, we create a new effect. Old effects are
const render = { * removed from this set when they have fully transitioned out
d: null, * @type {Set<import('#client').Effect>}
e: null, */
s: new Set(), let effects = new Set();
p: current_render
};
// Managed effect const component_effect = render_effect(
render.e = render_effect( () => {
() => { if (component === (component = get_component())) return;
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
);
current_render = render; if (effect) {
}; var e = effect;
pause_effect(e, () => {
effects.delete(e);
});
}
const render = () => { if (component) {
const render = current_render; effect = render_effect(
() => {
render_fn(component);
if (render === null) { // @ts-expect-error TODO this should be unnecessary
create_render_effect(); const dom = block.d;
return;
}
const transitions = render.s; return () => {
if (dom !== null) {
remove(dom);
}
};
},
block,
true,
true
);
if (transitions.size === 0) { // @ts-expect-error TODO tidy up
if (render.d !== null) { effect.d = block.d;
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');
}
};
const component_effect = render_effect( effects.add(effect);
() => {
const next_component = component_fn();
if (component !== next_component) {
component = next_component;
render();
} }
}, },
block, block,
@ -129,19 +71,9 @@ export function component(anchor_node, component_fn, render_fn) {
); );
component_effect.ondestroy = () => { component_effect.ondestroy = () => {
let render = current_render; for (const e of effects) {
while (render !== null) { // @ts-expect-error TODO tidy up. ondestroy should be totally unnecessary
const dom = render.d; if (e.d) remove(e.d);
if (dom !== null) {
remove(dom);
}
const effect = render.e;
if (effect !== null) {
destroy_effect(effect);
}
render = render.p;
} }
}; };
block.e = component_effect;
} }

@ -1,11 +1,17 @@
import { namespace_svg } from '../../../../constants.js'; 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 { current_hydration_fragment, hydrate_block_anchor, hydrating } from '../hydration.js';
import { empty } from '../operations.js'; import { empty } from '../operations.js';
import { destroy_effect, render_effect } from '../../reactivity/effects.js'; import {
import { insert, remove } from '../reconciler.js'; destroy_effect,
import { current_block, execute_effect } from '../../runtime.js'; 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 { 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 * @param {import('#client').Block} block
@ -28,13 +34,13 @@ function swap_block_dom(block, from, to) {
} }
/** /**
* @param {Comment} anchor_node * @param {Comment} anchor
* @param {() => string} tag_fn * @param {() => string} get_tag
* @param {boolean | null} is_svg `null` == not statically known * @param {boolean | null} is_svg `null` == not statically known
* @param {undefined | ((element: Element, anchor: Node) => void)} render_fn * @param {undefined | ((element: Element, anchor: Node) => void)} render_fn
* @returns {void} * @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} */ /** @type {import('#client').DynamicElementBlock} */
const block = { const block = {
// dom // dom
@ -42,98 +48,119 @@ export function element(anchor_node, tag_fn, is_svg, render_fn) {
// effect // effect
e: null, e: null,
// parent // parent
p: /** @type {import('#client').Block} */ (current_block), p: /** @type {import('#client').Block} */ (current_block)
// transition
r: null,
// type
t: DYNAMIC_ELEMENT_BLOCK
}; };
hydrate_block_anchor(anchor_node); hydrate_block_anchor(anchor);
let has_mounted = false;
/** @type {string} */ /** @type {string | null} */
let tag; let tag;
/** @type {string | null} */
let current_tag;
/** @type {null | Element} */ /** @type {null | Element} */
let element = null; let element = null;
const element_effect = render_effect( /** @type {import('#client').Effect | null} */
() => { let 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;
}
element = next_element; /**
if (element !== null && render_fn !== undefined) { * The keyed `{#each ...}` item block, if any, that this element is inside.
let anchor; * We track this so we can set it when changing the element, allowing any
if (hydrating) { * `animate:` directive to bind itself to the correct block
// Use the existing ssr comment as the anchor so that the inner open and close */
// methods can pick up the existing nodes correctly let each_item_block = current_each_item_block;
anchor = /** @type {Comment} */ (element.firstChild);
} else {
anchor = empty();
element.appendChild(anchor);
}
render_fn(element, anchor);
}
const has_prev_element = prev_element !== null; const wrapper = render_effect(() => {
if (has_prev_element) { const next_tag = get_tag() || null;
remove(prev_element); if (next_tag === tag) return;
}
if (element !== null) { // See explanation of `each_item_block` above
insert(element, null, anchor_node); var previous_each_item_block = current_each_item_block;
if (has_prev_element) { set_current_each_item_block(each_item_block);
const parent_block = block.p;
swap_block_dom(parent_block, prev_element, element); // 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) { if (element !== null) {
remove(element); remove(element);
block.d = null; block.d = null;
element = null; element = null;
} }
destroy_effect(render_effect_signal);
if (effect) {
destroy_effect(effect);
}
}; };
block.e = element_effect; block.e = wrapper;
} }

@ -1,4 +1,3 @@
import { HEAD_BLOCK } from '../../constants.js';
import { import {
current_hydration_fragment, current_hydration_fragment,
get_hydration_fragment, get_hydration_fragment,
@ -22,11 +21,7 @@ export function head(render_fn) {
// effect // effect
e: null, e: null,
// parent // parent
p: /** @type {import('#client').Block} */ (current_block), p: /** @type {import('#client').Block} */ (current_block)
// transition
r: null,
// type
t: HEAD_BLOCK
}; };
// The head function may be called after the first hydration pass and ssr comment nodes may still be present, // The head function may be called after the first hydration pass and ssr comment nodes may still be present,

@ -1,5 +1,5 @@
import { DEV } from 'esm-env'; 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'; 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(() => { render_effect(() => {
return () => { return () => {
var index = binding_group.indexOf(input); var index = binding_group.indexOf(input);

@ -73,33 +73,26 @@ export function get_hydration_fragment(node, insert_text = false) {
} }
/** /**
* @param {Text | Comment | Element} anchor_node * @param {Node} node
* @param {boolean} [is_controlled]
* @returns {void} * @returns {void}
*/ */
export function hydrate_block_anchor(anchor_node, is_controlled) { export function hydrate_block_anchor(node) {
if (hydrating) { if (!hydrating) return;
/** @type {Node} */
let target_node = anchor_node;
if (is_controlled) { if (node.nodeType === 8) {
target_node = /** @type {Node} */ (target_node.firstChild); // @ts-ignore
} let fragment = node.$$fragment;
if (target_node.nodeType === 8) { if (fragment === undefined) {
// @ts-ignore fragment = get_hydration_fragment(node);
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);
} else { } else {
const first_child = /** @type {Element | null} */ (target_node.firstChild); schedule_task(() => {
set_current_hydration_fragment(first_child === null ? [] : [first_child]); // @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]);
} }
} }

@ -30,30 +30,21 @@ export function create_fragment_with_script_from_html(html) {
/** /**
* @param {Array<import('../types.js').TemplateNode> | import('../types.js').TemplateNode} current * @param {Array<import('../types.js').TemplateNode> | import('../types.js').TemplateNode} current
* @param {null | Element} parent_element * @param {Text | Element | Comment} sibling
* @param {null | Text | Element | Comment} sibling
* @returns {Text | Element | Comment} * @returns {Text | Element | Comment}
*/ */
export function insert(current, parent_element, sibling) { export function insert(current, sibling) {
if (!current) return sibling;
if (is_array(current)) { if (is_array(current)) {
var i = 0; for (var i = 0; i < current.length; i++) {
var node; sibling.before(/** @type {Node} */ (current[i]));
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));
}
} }
return current[0]; 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); return /** @type {Text | Element | Comment} */ (current);
} }

@ -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) { function open_template(is_fragment, use_clone_node, anchor, template_element_fn) {
if (hydrating) { if (hydrating) {
if (anchor !== null) { if (anchor !== null) {
hydrate_block_anchor(anchor, false); hydrate_block_anchor(anchor);
} }
// In ssr+hydration optimization mode, we might remove the template_element, // In ssr+hydration optimization mode, we might remove the template_element,
// so we need to is_fragment flag to properly handle hydrated content accordingly. // so we need to is_fragment flag to properly handle hydrated content accordingly.
@ -181,18 +181,18 @@ export function comment(anchor) {
* @returns {void} * @returns {void}
*/ */
function close_template(dom, is_fragment, anchor) { function close_template(dom, is_fragment, anchor) {
const block = /** @type {import('#client').Block} */ (current_block);
/** @type {import('#client').TemplateNode | Array<import('#client').TemplateNode>} */ /** @type {import('#client').TemplateNode | Array<import('#client').TemplateNode>} */
const current = is_fragment var current = is_fragment
? is_array(dom) ? is_array(dom)
? dom ? dom
: /** @type {import('#client').TemplateNode[]} */ (Array.from(dom.childNodes)) : /** @type {import('#client').TemplateNode[]} */ (Array.from(dom.childNodes))
: dom; : dom;
if (!hydrating && anchor !== null) { if (!hydrating && anchor !== null) {
insert(current, null, anchor); insert(current, anchor);
} }
block.d = current;
/** @type {import('#client').Block} */ (current_block).d = current;
} }
/** /**

@ -1,19 +1,22 @@
import { raf } from './timing.js'; import { raf } from './timing.js';
const tasks = new Set(); // TODO move this into timing.js where it probably belongs
/** /**
* @param {number} now * @param {number} now
* @returns {void} * @returns {void}
*/ */
function run_tasks(now) { function run_tasks(now) {
tasks.forEach((task) => { raf.tasks.forEach((task) => {
if (!task.c(now)) { if (!task.c(now)) {
tasks.delete(task); raf.tasks.delete(task);
task.f(); 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) { export function loop(callback) {
/** @type {import('./types.js').TaskEntry} */ /** @type {import('./types.js').TaskEntry} */
let task; let task;
if (tasks.size === 0) raf.tick(run_tasks);
if (raf.tasks.size === 0) {
raf.tick(run_tasks);
}
return { return {
promise: new Promise((fulfill) => { promise: new Promise((fulfill) => {
tasks.add((task = { c: callback, f: fulfill })); raf.tasks.add((task = { c: callback, f: fulfill }));
}), }),
abort() { abort() {
tasks.delete(task); raf.tasks.delete(task);
} }
}; };
} }

@ -1,22 +1,34 @@
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { import {
check_dirtiness,
current_block, current_block,
current_component_context, current_component_context,
current_effect, current_effect,
current_reaction, current_reaction,
destroy_children, destroy_children,
execute_effect,
get, get,
remove_reactions, remove_reactions,
schedule_effect, schedule_effect,
set_signal_status, set_signal_status,
untrack untrack
} from '../runtime.js'; } 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 { set } from './sources.js';
import { noop } from '../../common.js';
/** /**
* @param {import('./types.js').EffectType} type * @param {import('./types.js').EffectType} type
* @param {(() => void | (() => void)) | ((b: import('#client').Block) => void | (() => void))} fn * @param {(() => void | (() => void))} fn
* @param {boolean} sync * @param {boolean} sync
* @param {null | import('#client').Block} block * @param {null | import('#client').Block} block
* @param {boolean} init * @param {boolean} init
@ -25,6 +37,7 @@ import { set } from './sources.js';
function create_effect(type, fn, sync, block = current_block, init = true) { function create_effect(type, fn, sync, block = current_block, init = true) {
/** @type {import('#client').Effect} */ /** @type {import('#client').Effect} */
const signal = { const signal = {
parent: current_effect,
block, block,
deps: null, deps: null,
f: type | DIRTY, f: type | DIRTY,
@ -34,7 +47,8 @@ function create_effect(type, fn, sync, block = current_block, init = true) {
deriveds: null, deriveds: null,
teardown: null, teardown: null,
ctx: current_component_context, ctx: current_component_context,
ondestroy: null ondestroy: null,
transitions: null
}; };
if (current_effect !== null) { if (current_effect !== null) {
@ -201,8 +215,7 @@ export function invalidate_effect(fn) {
} }
/** /**
* @template {import('#client').Block} B * @param {(() => void)} fn
* @param {(block: B) => void | (() => void)} fn
* @param {any} block * @param {any} block
* @param {any} managed * @param {any} managed
* @param {any} sync * @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} * @returns {void}
*/ */
export function destroy_effect(signal) { export function destroy_effect(effect) {
destroy_children(signal); destroy_children(effect);
remove_reactions(signal, 0); remove_reactions(effect, 0);
set_signal_status(signal, DESTROYED); set_signal_status(effect, DESTROYED);
signal.teardown?.(); if (effect.transitions) {
signal.ondestroy?.(); for (const transition of effect.transitions) {
signal.fn = transition.stop();
signal.effects = }
signal.teardown = }
signal.ondestroy =
signal.ctx = effect.teardown?.();
signal.block = effect.ondestroy?.();
signal.deps =
// @ts-expect-error
effect.fn =
effect.effects =
effect.teardown =
effect.ondestroy =
effect.ctx =
effect.block =
effect.deps =
null; 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();
}
}
}
}

@ -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'; import type { EFFECT, PRE_EFFECT, RENDER_EFFECT } from '../constants';
export type EffectType = typeof EFFECT | typeof PRE_EFFECT | typeof RENDER_EFFECT; export type EffectType = typeof EFFECT | typeof PRE_EFFECT | typeof RENDER_EFFECT;
@ -21,7 +21,7 @@ export interface Value<V = unknown> extends Signal {
export interface Reaction extends Signal { export interface Reaction extends Signal {
/** The reaction function */ /** The reaction function */
fn: null | Function; fn: Function;
/** Signals that this signal reads from */ /** Signals that this signal reads from */
deps: null | Value[]; deps: null | Value[];
/** Effects created inside this signal */ /** Effects created inside this signal */
@ -36,6 +36,7 @@ export interface Derived<V = unknown> extends Value<V>, Reaction {
} }
export interface Effect extends Reaction { export interface Effect extends Reaction {
parent: Effect | null;
/** The block associated with this effect */ /** The block associated with this effect */
block: null | Block; block: null | Block;
/** The associated component context */ /** The associated component context */
@ -43,11 +44,13 @@ export interface Effect extends Reaction {
/** Stuff to do when the effect is destroyed */ /** Stuff to do when the effect is destroyed */
ondestroy: null | (() => void); ondestroy: null | (() => void);
/** The effect function */ /** The effect function */
fn: null | (() => void | (() => void)) | ((b: Block, s: Signal) => void | (() => void)); fn: () => void | (() => void);
/** The teardown function returned from the effect function */ /** The teardown function returned from the effect function */
teardown: null | (() => void); teardown: null | (() => void);
/** The depth from the root signal, used for ordering render/pre-effects topologically **/ /** The depth from the root signal, used for ordering render/pre-effects topologically **/
l: number; l: number;
/** Transition managers created with `$.transition` */
transitions: null | TransitionManager[];
} }
export interface ValueDebug<V = unknown> extends Value<V> { export interface ValueDebug<V = unknown> extends Value<V> {

@ -12,7 +12,6 @@ import {
set_current_hydration_fragment set_current_hydration_fragment
} from './dom/hydration.js'; } from './dom/hydration.js';
import { array_from } from './utils.js'; import { array_from } from './utils.js';
import { ROOT_BLOCK } from './constants.js';
import { handle_event_propagation } from './dom/elements/events.js'; import { handle_event_propagation } from './dom/elements/events.js';
/** @type {Set<string>} */ /** @type {Set<string>} */
@ -21,6 +20,18 @@ export const all_registered_events = new Set();
/** @type {Set<(events: Array<string>) => void>} */ /** @type {Set<(events: Array<string>) => void>} */
export const root_event_handles = new Set(); 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 `<svelte:element>` 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 {Element} dom
* @param {() => string} value * @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<string, unknown>) => void)} slot_fn * @param {void | ((anchor: Comment, slot_props: Record<string, unknown>) => void)} slot_fn
* @param {Record<string, unknown>} slot_props * @param {Record<string, unknown>} slot_props
* @param {null | ((anchor: Comment) => void)} fallback_fn * @param {null | ((anchor: Comment) => void)} fallback_fn
*/ */
export function slot(anchor_node, slot_fn, slot_props, fallback_fn) { export function slot(anchor, slot_fn, slot_props, fallback_fn) {
hydrate_block_anchor(anchor_node); hydrate_block_anchor(anchor);
if (slot_fn === undefined) { if (slot_fn === undefined) {
if (fallback_fn !== null) { if (fallback_fn !== null) {
fallback_fn(anchor_node); fallback_fn(anchor);
} }
} else { } 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 registered_events = new Set();
const container = options.target; const container = options.target;
should_intro = options.intro ?? false;
/** @type {import('#client').RootBlock} */ /** @type {import('#client').RootBlock} */
const block = { const block = {
// dom // dom
@ -207,11 +220,7 @@ function _mount(Component, options) {
// intro // intro
i: options.intro || false, i: options.intro || false,
// parent // parent
p: null, p: null
// transition
r: null,
// type
t: ROOT_BLOCK
}; };
/** @type {Exports} */ /** @type {Exports} */
@ -246,6 +255,8 @@ function _mount(Component, options) {
const bound_event_listener = handle_event_propagation.bind(null, container); const bound_event_listener = handle_event_propagation.bind(null, container);
const bound_document_event_listener = handle_event_propagation.bind(null, document); const bound_document_event_listener = handle_event_propagation.bind(null, document);
should_intro = true;
/** @param {Array<string>} events */ /** @param {Array<string>} events */
const event_handle = (events) => { const event_handle = (events) => {
for (let i = 0; i < events.length; i++) { for (let i = 0; i < events.length; i++) {

@ -21,7 +21,8 @@ import {
DESTROYED, DESTROYED,
INERT, INERT,
MANAGED, MANAGED,
STATE_SYMBOL STATE_SYMBOL,
EFFECT_RAN
} from './constants.js'; } from './constants.js';
import { flush_tasks } from './dom/task.js'; import { flush_tasks } from './dom/task.js';
import { add_owner } from './dev/ownership.js'; import { add_owner } from './dev/ownership.js';
@ -54,9 +55,19 @@ let flush_count = 0;
/** @type {null | import('./types.js').Reaction} */ /** @type {null | import('./types.js').Reaction} */
export let current_reaction = null; 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} */ /** @type {null | import('./types.js').Effect} */
export let current_effect = null; 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[]} */ /** @type {null | import('./types.js').Value[]} */
export let current_dependencies = null; export let current_dependencies = null;
let current_dependencies_index = 0; let current_dependencies_index = 0;
@ -104,6 +115,11 @@ export let current_block = null;
/** @type {import('./types.js').ComponentContext | null} */ /** @type {import('./types.js').ComponentContext | null} */
export let current_component_context = 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} */ /** @returns {boolean} */
export function is_runes() { export function is_runes() {
return current_component_context !== null && current_component_context.r; 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 * @param {import('./types.js').Reaction} reaction
* @returns {boolean} * @returns {boolean}
*/ */
function check_dirtiness(reaction) { export function check_dirtiness(reaction) {
var flags = reaction.f; var flags = reaction.f;
if ((flags & DIRTY) !== 0) { if ((flags & DIRTY) !== 0) {
@ -200,7 +216,6 @@ function check_dirtiness(reaction) {
export function execute_reaction_fn(signal) { export function execute_reaction_fn(signal) {
const fn = signal.fn; const fn = signal.fn;
const flags = signal.f; const flags = signal.f;
const is_render_effect = (flags & RENDER_EFFECT) !== 0;
const previous_dependencies = current_dependencies; const previous_dependencies = current_dependencies;
const previous_dependencies_index = current_dependencies_index; const previous_dependencies_index = current_dependencies_index;
@ -217,19 +232,7 @@ export function execute_reaction_fn(signal) {
current_untracking = false; current_untracking = false;
try { try {
let res; let res = fn();
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 dependencies = /** @type {import('./types.js').Value<unknown>[]} **/ (signal.deps); let dependencies = /** @type {import('./types.js').Value<unknown>[]} **/ (signal.deps);
if (current_dependencies !== null) { if (current_dependencies !== null) {
let i; let i;
@ -548,6 +551,8 @@ export function schedule_effect(signal, sync) {
} }
} }
} }
signal.f |= EFFECT_RAN;
} }
/** /**

@ -9,5 +9,6 @@ const now = is_client ? () => performance.now() : () => Date.now();
/** @type {import('./types.js').Raf} */ /** @type {import('./types.js').Raf} */
export const raf = { export const raf = {
tick: /** @param {any} _ */ (_) => request_animation_frame(_), tick: /** @param {any} _ */ (_) => request_animation_frame(_),
now: () => now() now: () => now(),
tasks: new Set()
}; };

@ -1,16 +1,4 @@
import { import { STATE_SYMBOL } from './constants.js';
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 type { Effect, Source, Value } from './reactivity/types.js'; import type { Effect, Source, Value } from './reactivity/types.js';
type EventCallback = (event: Event) => boolean; type EventCallback = (event: Event) => boolean;
@ -59,42 +47,8 @@ export type ComponentContext = {
export type Equals = (this: Value, value: unknown) => boolean; 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 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 = { export type RootBlock = {
/** dom */ /** dom */
d: null | TemplateNode | Array<TemplateNode>; d: null | TemplateNode | Array<TemplateNode>;
@ -104,10 +58,6 @@ export type RootBlock = {
i: boolean; i: boolean;
/** parent */ /** parent */
p: null; p: null;
/** transition */
r: null | ((transition: Transition) => void);
/** type */
t: typeof ROOT_BLOCK;
}; };
export type IfBlock = { export type IfBlock = {
@ -119,31 +69,6 @@ export type IfBlock = {
e: null | Effect; e: null | Effect;
/** parent */ /** parent */
p: Block; p: Block;
/** transition */
r: null | ((transition: Transition) => void);
/** consequent transitions */
c: null | Set<Transition>;
/** alternate transitions */
a: null | Set<Transition>;
/** effect */
ce: null | Effect;
/** effect */
ae: null | Effect;
/** type */
t: typeof IF_BLOCK;
};
export type KeyBlock = {
/** dom */
d: null | TemplateNode | Array<TemplateNode>;
/** effect */
e: null | Effect;
/** parent */
p: Block;
/** transition */
r: null | ((transition: Transition) => void);
/** type */
t: typeof KEY_BLOCK;
}; };
export type HeadBlock = { export type HeadBlock = {
@ -153,10 +78,6 @@ export type HeadBlock = {
e: null | Effect; e: null | Effect;
/** parent */ /** parent */
p: Block; p: Block;
/** transition */
r: null | ((transition: Transition) => void);
/** type */
t: typeof HEAD_BLOCK;
}; };
export type DynamicElementBlock = { export type DynamicElementBlock = {
@ -166,10 +87,6 @@ export type DynamicElementBlock = {
e: null | Effect; e: null | Effect;
/** parent */ /** parent */
p: Block; p: Block;
/** transition */
r: null | ((transition: Transition) => void);
/** type */
t: typeof DYNAMIC_ELEMENT_BLOCK;
}; };
export type DynamicComponentBlock = { export type DynamicComponentBlock = {
@ -179,10 +96,6 @@ export type DynamicComponentBlock = {
e: null | Effect; e: null | Effect;
/** parent */ /** parent */
p: Block; p: Block;
/** transition */
r: null | ((transition: Transition) => void);
/** type */
t: typeof DYNAMIC_COMPONENT_BLOCK;
}; };
export type AwaitBlock = { export type AwaitBlock = {
@ -194,15 +107,9 @@ export type AwaitBlock = {
p: Block; p: Block;
/** pending */ /** pending */
n: boolean; n: boolean;
/** transition */
r: null | ((transition: Transition) => void);
/** type */
t: typeof AWAIT_BLOCK;
}; };
export type EachBlock = { export type EachBlock = {
/** anchor */
a: Element | Comment;
/** flags */ /** flags */
f: number; f: number;
/** dom */ /** dom */
@ -213,35 +120,21 @@ export type EachBlock = {
e: null | Effect; e: null | Effect;
/** parent */ /** parent */
p: Block; p: Block;
/** transition */
r: null | ((transition: Transition) => void);
/** transitions */
s: Array<EachItemBlock>;
/** type */
t: typeof EACH_BLOCK;
}; };
export type EachItemBlock = { export type EachItemBlock = {
/** transition */ /** animation manager */
a: null | ((block: EachItemBlock, transitions: Set<Transition>) => void); a: AnimationManager | null;
/** dom */ /** dom */
d: null | TemplateNode | Array<TemplateNode>; d: null | TemplateNode | Array<TemplateNode>;
/** effect */ /** effect */
e: null | Effect; e: Effect;
/** item */ /** item */
v: any | Source<any>; v: any | Source<any>;
/** index */ /** index */
i: number | Source<number>; i: number | Source<number>;
/** key */ /** key */
k: unknown; k: unknown;
/** parent */
p: EachBlock;
/** transition */
r: null | ((transition: Transition) => void);
/** transitions */
s: null | Set<Transition>;
/** type */
t: typeof EACH_ITEM_BLOCK;
}; };
export type SnippetBlock = { export type SnippetBlock = {
@ -251,10 +144,6 @@ export type SnippetBlock = {
p: Block; p: Block;
/** effect */ /** effect */
e: null | Effect; e: null | Effect;
/** transition */
r: null;
/** type */
t: typeof SNIPPET_BLOCK;
}; };
export type Block = export type Block =
@ -264,25 +153,54 @@ export type Block =
| DynamicElementBlock | DynamicElementBlock
| DynamicComponentBlock | DynamicComponentBlock
| HeadBlock | HeadBlock
| KeyBlock
| EachBlock | EachBlock
| EachItemBlock | EachItemBlock
| SnippetBlock; | 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<P> = ( export type TransitionFn<P> = (
element: Element, element: Element,
props: P, props: P,
options: { direction?: 'in' | 'out' | 'both' } options: { direction?: 'in' | 'out' | 'both' }
) => TransitionPayload; ) => AnimationConfig | ((options: { direction?: 'in' | 'out' }) => AnimationConfig);
export type AnimateFn<P> = ( export type AnimateFn<P> = (
element: Element, element: Element,
rects: { from: DOMRect; to: DOMRect }, rects: { from: DOMRect; to: DOMRect },
props: P, props: P
options: {} ) => AnimationConfig;
) => TransitionPayload;
export type TransitionPayload = { export type AnimationConfig = {
delay?: number; delay?: number;
duration?: number; duration?: number;
easing?: (t: number) => number; easing?: (t: number) => number;
@ -307,15 +225,17 @@ export type Render = {
d: null | TemplateNode | Array<TemplateNode>; d: null | TemplateNode | Array<TemplateNode>;
/** effect */ /** effect */
e: null | Effect; e: null | Effect;
/** transitions */
s: Set<Transition>;
/** prev */ /** prev */
p: Render | null; p: Render | null;
}; };
export type Raf = { export type Raf = {
/** Alias for `requestAnimationFrame`, exposed in such a way that we can override in tests */
tick: (callback: (time: DOMHighResTimeStamp) => void) => any; tick: (callback: (time: DOMHighResTimeStamp) => void) => any;
/** Alias for `performance.now()`, exposed in such a way that we can override in tests */
now: () => number; now: () => number;
/** A set of tasks that will run to completion, unless aborted */
tasks: Set<TaskEntry>;
}; };
export interface Task { export interface Task {

@ -14,6 +14,7 @@ export const raf = {
raf.ticks.add(f); raf.ticks.add(f);
}; };
svelte_raf.now = () => raf.time; svelte_raf.now = () => raf.time;
svelte_raf.tasks.clear();
} }
}; };
@ -31,125 +32,144 @@ function tick(time) {
} }
class Animation { class Animation {
#target;
#keyframes; #keyframes;
#duration; #duration;
#timeline_offset;
#reversed; #offset = raf.time;
#target;
#paused; #finished = () => {};
#cancelled = () => {};
currentTime = 0;
/** /**
* @param {HTMLElement} target * @param {HTMLElement} target
* @param {Keyframe[]} keyframes * @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.#target = target;
this.#keyframes = keyframes; this.#keyframes = keyframes;
this.#duration = options.duration || 0; this.#duration = duration;
this.#timeline_offset = 0;
this.#reversed = false; // Promise-like semantics, but call callbacks immediately on raf.tick
this.#paused = false; this.finished = {
this.onfinish = () => {}; /** @param {() => void} callback */
this.pending = true; then: (callback) => {
this.currentTime = 0; this.#finished = callback;
this.playState = 'running';
this.effect = { return {
setKeyframes: (/** @type {Keyframe[]} */ keyframes) => { /** @param {() => void} callback */
this.#keyframes = keyframes; catch: (callback) => {
this.#cancelled = callback;
}
};
} }
}; };
}
play() {
this.#paused = false;
raf.animations.add(this);
this.playState = 'running';
this._update(); this._update();
} }
_update() { _update() {
if (this.#reversed) { this.currentTime = raf.time - this.#offset;
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;
}
const target_frame = this.currentTime / this.#duration; 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) { #apply_keyframe(t) {
const keyframes = this.#keyframes; const n = Math.min(1, Math.max(0, t)) * (this.#keyframes.length - 1);
const keyframes_size = keyframes.length - 1;
const frame = keyframes[Math.min(keyframes_size, Math.floor(keyframes.length * target_frame))]; 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) { for (let prop in frame) {
// @ts-ignore // @ts-ignore
this.#target.style[prop] = frame[prop]; 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() { if (this.currentTime >= this.#duration) {
this.onfinish(); this.currentTime = this.#duration;
this.currentTime = this.#reversed ? 0 : this.#duration; for (let prop in frame) {
if (this.#reversed) { // @ts-ignore
raf.animations.delete(this); this.#target.style[prop] = null;
}
} }
this.playState = 'idle';
} }
cancel() { cancel() {
this.#paused = true;
if (this.currentTime > 0 && this.currentTime < this.#duration) { if (this.currentTime > 0 && this.currentTime < this.#duration) {
this._applyKeyFrame(this.#reversed ? this.#keyframes.length - 1 : 0); this.#apply_keyframe(0);
} }
}
pause() { this.#cancelled();
this.#paused = true; raf.animations.delete(this);
this.playState = 'paused';
} }
}
/**
* @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() { let result = '';
if (this.#paused && !raf.animations.has(this)) {
raf.animations.add(this); 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; result += a_match[i + 1] ?? '';
this.playState = 'running';
} }
return result;
} }
/** /**
* @param {Keyframe[]} keyframes * @param {Keyframe[]} keyframes
* @param {{duration?: number}} options * @param {{duration: number}} options
* @returns {globalThis.Animation} * @returns {globalThis.Animation}
*/ */
HTMLElement.prototype.animate = function (keyframes, options) { HTMLElement.prototype.animate = function (keyframes, options) {
const animation = new Animation(this, keyframes, options); const animation = new Animation(this, keyframes, options);
raf.animations.add(animation);
// @ts-ignore // @ts-ignore
return animation; return animation;
}; };

@ -1,11 +1,13 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
export let logs;
export let state; export let logs;
export let state;
onMount(() => { onMount(() => {
logs.push(`mount ${state}`); logs.push(`mount ${state}`);
return () => { return () => {
logs.push(`unmount ${state}`); logs.push(`unmount ${state}`);
}; };
}); });
</script> </script>

@ -20,7 +20,7 @@
export async function test() { export async function test() {
resolve_promise(); resolve_promise();
await Promise.resolve(); await tick();
new_promise(); new_promise();
resolve_promise(); resolve_promise();
return tick(); return tick();

@ -18,7 +18,7 @@ export default test({
raf.tick(150); raf.tick(150);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
'<p>foo</p><p class="red svelte-1yszte8 border" style="overflow: hidden; opacity: 0; border-top-width: 0.5000600024000317px; border-bottom-width: 0.5000600024000317px;">bar</p>' '<p>foo</p><p class="red svelte-1yszte8 border" style="overflow: hidden; opacity: 0; border-top-width: 0.5px; border-bottom-width: 0.5px;">bar</p>'
); );
component.open = true; component.open = true;
raf.tick(250); raf.tick(250);

@ -23,7 +23,6 @@ export default test({
`, `,
test({ assert, component, target, raf }) { test({ assert, component, target, raf }) {
raf.tick(0);
component.tag = 'p'; component.tag = 'p';
assert.equal(target.querySelectorAll('p').length, 5); assert.equal(target.querySelectorAll('p').length, 5);
@ -52,11 +51,11 @@ export default test({
]; ];
divs = target.querySelectorAll('div'); divs = target.querySelectorAll('div');
assert.ok(~divs[0].style.transform); assert.equal(divs[0].style.transform, 'translate(0px, 120px)');
assert.equal(divs[1].style.transform, 'translate(1px, 0px)'); assert.equal(divs[1].style.transform, '');
assert.equal(divs[2].style.transform, 'translate(1px, 0px)'); assert.equal(divs[2].style.transform, '');
assert.equal(divs[3].style.transform, 'translate(1px, 0px)'); assert.equal(divs[3].style.transform, '');
assert.ok(~divs[4].style.transform); assert.equal(divs[4].style.transform, 'translate(0px, -120px)');
raf.tick(100); raf.tick(100);
assert.deepEqual([divs[0].style.transform, divs[4].style.transform], ['', '']); assert.deepEqual([divs[0].style.transform, divs[4].style.transform], ['', '']);

@ -8,7 +8,7 @@
return { return {
duration: 100, duration: 100,
css: (t, u) => `transform: translate(${u + dx}px, ${u * dy}px)` css: (t, u) => `transform: translate(${u * dx}px, ${u * dy}px)`
}; };
} }
</script> </script>

@ -15,8 +15,9 @@ export default test({
assert.equal(h1.style.opacity, ''); assert.equal(h1.style.opacity, '');
assert.equal(h2.style.opacity, ''); assert.equal(h2.style.opacity, '');
raf.tick(50); raf.tick(200);
component.visible = false; component.visible = false;
assert.equal(h2.style.opacity, '0.49998000000000004'); raf.tick(250);
assert.equal(h2.style.opacity, '0.5');
} }
}); });

@ -1,4 +1,4 @@
import { ok, test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
get props() { get props() {
@ -8,14 +8,11 @@ export default test({
test({ assert, component, target, raf }) { test({ assert, component, target, raf }) {
component.visible = false; component.visible = false;
raf.tick(150);
const outer = /** @type {HTMLSpanElement} */ (target.querySelector('.outer')); const outer = /** @type {HTMLSpanElement} */ (target.querySelector('.outer'));
const inner = /** @type {HTMLSpanElement} */ (target.querySelector('.inner')); const inner = /** @type {HTMLSpanElement} */ (target.querySelector('.inner'));
raf.tick(150); assert.deepEqual([outer.style.cssText, inner.style.cssText], ['opacity: 0.25;', '']);
assert.deepEqual(
[outer.style.cssText, inner.style.cssText],
['opacity: 0.24999000000000002;', '']
);
} }
}); });

@ -6,14 +6,12 @@ export default test({
const div = target.querySelector('div'); const div = target.querySelector('div');
ok(div); ok(div);
raf.tick(25); raf.tick(50);
assert.equal(div.style.opacity, '0.5');
assert.equal(div.style.opacity, '0.16666');
component.visible = false; component.visible = false;
raf.tick(40); raf.tick(75);
assert.equal(div.style.opacity, '0.25');
assert.ok(div.style.opacity === '0');
} }
}); });

@ -5,6 +5,7 @@ export default test({
async test({ assert, component, target, raf }) { async test({ assert, component, target, raf }) {
const frame = /** @type {HTMLIFrameElement} */ (target.querySelector('iframe')); const frame = /** @type {HTMLIFrameElement} */ (target.querySelector('iframe'));
await tick(); await tick();
await tick(); // TODO investigate why this second tick is necessary. without it, `Foo.svelte` initializes with `visible = true`, incorrectly
component.visible = true; component.visible = true;
const div = frame.contentDocument?.querySelector('div'); const div = frame.contentDocument?.querySelector('div');
@ -14,8 +15,10 @@ export default test({
component.visible = false; component.visible = false;
raf.tick(26); raf.tick(25);
// The exact number doesn't matter here, this test is about ensuring that transitions work in iframes assert.equal(div.style.opacity, '0.25');
assert.equal(Number(div.style.opacity).toFixed(4), '0.8333');
raf.tick(35);
assert.equal(div.style.opacity, '0.18333333333333335');
} }
}); });

@ -6,18 +6,27 @@ export default test({
const div = target.querySelector('div'); const div = target.querySelector('div');
ok(div); ok(div);
assert.equal(div.style.opacity, '0'); assert.equal(div.style.scale, '0');
raf.tick(50); raf.tick(50);
component.visible = false; component.visible = false;
// both in and out styles // 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); 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; component.visible = true;
// reset original styles // reset original styles
assert.equal(div.style.scale, '0');
assert.equal(div.style.opacity, '1'); assert.equal(div.style.opacity, '1');
assert.equal(div.style.rotate, '360deg');
} }
}); });

@ -5,7 +5,7 @@
return { return {
duration: 100, duration: 100,
css: t => { css: t => {
return `opacity: ${t}`; return `scale: ${t}`;
} }
}; };
} }
@ -14,7 +14,7 @@
return { return {
duration: 100, duration: 100,
css: t => { css: t => {
return `opacity: ${t}`; return `rotate: ${t * 360}deg; opacity: ${t}`;
} }
}; };
} }

@ -6,29 +6,26 @@ export default test({
const b = target.querySelector('button.b'); const b = target.querySelector('button.b');
ok(a); ok(a);
ok(b); 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 // check and abort halfway through the outro transition
component.visible = false; component.visible = false;
raf.tick(50); raf.tick(50);
assert.strictEqual(target.querySelector('button.a')?.inert, true); assert.ok(target.querySelector('button.a')?.inert);
assert.strictEqual(target.querySelector('button.b')?.inert, true); assert.ok(target.querySelector('button.b')?.inert);
component.visible = true; component.visible = true;
assert.strictEqual(target.querySelector('button.a')?.inert, false); assert.ok(!target.querySelector('button.a')?.inert);
assert.strictEqual(target.querySelector('button.b')?.inert, false); assert.ok(!target.querySelector('button.b')?.inert);
// let it transition out completely and then back in // let it transition out completely and then back in
component.visible = false; component.visible = false;
raf.tick(101); raf.tick(101);
component.visible = true; component.visible = true;
raf.tick(150); raf.tick(150);
assert.strictEqual(target.querySelector('button.a')?.inert, false); assert.ok(!target.querySelector('button.a')?.inert);
assert.strictEqual(target.querySelector('button.b')?.inert, false); assert.ok(!target.querySelector('button.b')?.inert);
raf.tick(151); raf.tick(151);
assert.strictEqual(target.querySelector('button.a')?.inert, false); assert.ok(!target.querySelector('button.a')?.inert);
assert.strictEqual(target.querySelector('button.b')?.inert, false); assert.ok(!target.querySelector('button.b')?.inert);
} }
}); });

@ -23,8 +23,6 @@ export default test({
assert.equal(spans[1].foo, 0.25); assert.equal(spans[1].foo, 0.25);
assert.equal(spans[2].foo, 0.75); assert.equal(spans[2].foo, 0.75);
raf.tick(7);
component.things = things; component.things = things;
raf.tick(225); raf.tick(225);
@ -42,8 +40,8 @@ export default test({
target.querySelectorAll('span') target.querySelectorAll('span')
); );
assert.equal(spans[0].foo, undefined); assert.equal(spans[0].foo, 1);
assert.equal(spans[1].foo, undefined); assert.equal(spans[1].foo, 1);
assert.equal(spans[2].foo, undefined); assert.equal(spans[2].foo, 1);
} }
}); });

@ -14,8 +14,6 @@ export default test({
intro: true, intro: true,
async test({ assert, target, component, raf }) { async test({ assert, target, component, raf }) {
raf.tick(0);
assert.htmlEqual(target.innerHTML, '<p class="pending" foo="0.0">loading...</p>'); assert.htmlEqual(target.innerHTML, '<p class="pending" foo="0.0">loading...</p>');
let time = 0; let time = 0;
@ -24,10 +22,6 @@ export default test({
assert.htmlEqual(target.innerHTML, '<p class="pending" foo="0.5">loading...</p>'); assert.htmlEqual(target.innerHTML, '<p class="pending" foo="0.5">loading...</p>');
await fulfil(42); await fulfil(42);
await Promise.resolve();
await Promise.resolve();
raf.tick(time);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
@ -61,9 +55,6 @@ export default test({
fulfil = f; fulfil = f;
}); });
await Promise.resolve(); await Promise.resolve();
await Promise.resolve();
raf.tick(time);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
@ -83,11 +74,6 @@ export default test({
); );
await fulfil(43); await fulfil(43);
await Promise.resolve();
await Promise.resolve();
raf.tick(time);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
@ -109,9 +95,6 @@ export default test({
fulfil = f; fulfil = f;
}); });
await Promise.resolve(); await Promise.resolve();
await Promise.resolve();
raf.tick(time);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
@ -132,11 +115,6 @@ export default test({
); );
await fulfil(44); await fulfil(44);
await Promise.resolve();
await Promise.resolve();
raf.tick(time);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
@ -159,10 +137,6 @@ export default test({
fulfil = f; fulfil = f;
}); });
await Promise.resolve(); await Promise.resolve();
await Promise.resolve();
raf.tick(time);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
@ -172,7 +146,6 @@ export default test({
); );
raf.tick((time += 40)); raf.tick((time += 40));
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
@ -182,10 +155,6 @@ export default test({
); );
await fulfil(45); await fulfil(45);
await Promise.resolve();
await Promise.resolve();
raf.tick(time);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
@ -197,7 +166,6 @@ export default test({
); );
raf.tick((time += 20)); raf.tick((time += 20));
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
@ -211,9 +179,6 @@ export default test({
fulfil = f; fulfil = f;
}); });
await Promise.resolve(); await Promise.resolve();
await Promise.resolve();
raf.tick(time);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
@ -238,10 +203,6 @@ export default test({
); );
await fulfil(46); await fulfil(46);
await Promise.resolve();
await Promise.resolve();
raf.tick(time);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
@ -255,7 +216,6 @@ export default test({
); );
raf.tick((time += 10)); raf.tick((time += 10));
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
@ -265,7 +225,6 @@ export default test({
); );
raf.tick((time += 20)); raf.tick((time += 20));
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
@ -274,7 +233,6 @@ export default test({
); );
raf.tick((time += 70)); raf.tick((time += 70));
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `

@ -1,4 +1,4 @@
import { ok, test } from '../../test'; import { test } from '../../test';
/** @type {(value: any) => void} */ /** @type {(value: any) => void} */
let fulfil; let fulfil;
@ -36,8 +36,8 @@ export default test({
); );
assert.equal(ps[0].className, 'pending'); assert.equal(ps[0].className, 'pending');
assert.equal(ps[1].className, 'then'); assert.equal(ps[1].className, 'then');
assert.equal(ps[0].foo, 0.8); assert.equal(ps[0].foo, 0.2);
assert.equal(ps[1].foo, undefined); assert.equal(ps[1].foo, 0.3);
raf.tick(100); raf.tick(100);
}); });
} }

@ -35,9 +35,9 @@ export default test({
component.visible = true; component.visible = true;
raf.tick(100); raf.tick(100);
assert.equal(divs[0].foo, 1); assert.equal(divs[0].foo, 0.3);
assert.equal(divs[1].foo, 1); assert.equal(divs[1].foo, 0.3);
assert.equal(divs[2].foo, 1); assert.equal(divs[2].foo, 0.3);
assert.equal(divs[0].bar, 1); assert.equal(divs[0].bar, 1);
assert.equal(divs[1].bar, 1); assert.equal(divs[1].bar, 1);

@ -15,7 +15,7 @@ export default test({
raf.tick(500); raf.tick(500);
component.x = true; component.x = true;
assert.equal(component.no, null); assert.equal(component.no, null);
assert.equal(component.yes.foo, undefined); assert.equal(component.yes.foo, 0);
raf.tick(700); raf.tick(700);
assert.equal(component.yes.foo, 0.5); assert.equal(component.yes.foo, 0.5);

@ -11,7 +11,7 @@ export default test({
btn?.click(); btn?.click();
}); });
assert.htmlEqual(target.innerHTML, `<h1>Outside</h1><button>Hide</button>`); assert.htmlEqual(target.innerHTML, `<h1>Outside</h1><button style="opacity: 0;">Hide</button>`);
raf.tick(100); raf.tick(100);

@ -11,7 +11,7 @@ export default test({
btn?.click(); btn?.click();
}); });
assert.htmlEqual(target.innerHTML, `<h1>Outside</h1><button>Hide</button>`); assert.htmlEqual(target.innerHTML, `<h1>Outside</h1><button style="opacity: 0;">Hide</button>`);
raf.tick(100); raf.tick(100);

Loading…
Cancel
Save