Merge branch 'main' into gh-12624

gh-12624
Rich Harris 4 months ago
commit a4e2d76242

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: use WAAPI to control timing of JS-based animations

@ -201,6 +201,7 @@
"fifty-steaks-float", "fifty-steaks-float",
"fifty-toys-invite", "fifty-toys-invite",
"five-maps-reflect", "five-maps-reflect",
"five-shirts-run",
"five-tigers-search", "five-tigers-search",
"flat-feet-visit", "flat-feet-visit",
"flat-ghosts-fly", "flat-ghosts-fly",
@ -342,6 +343,7 @@
"itchy-terms-guess", "itchy-terms-guess",
"khaki-cheetahs-refuse", "khaki-cheetahs-refuse",
"khaki-cooks-develop", "khaki-cooks-develop",
"khaki-donkeys-jump",
"khaki-ligers-sing", "khaki-ligers-sing",
"khaki-mails-draw", "khaki-mails-draw",
"khaki-mails-scream", "khaki-mails-scream",
@ -633,6 +635,7 @@
"silver-points-approve", "silver-points-approve",
"silver-sheep-knock", "silver-sheep-knock",
"six-apes-peel", "six-apes-peel",
"six-beans-laugh",
"six-bears-trade", "six-bears-trade",
"six-boats-shave", "six-boats-shave",
"six-chicken-kneel", "six-chicken-kneel",
@ -648,6 +651,7 @@
"slimy-clouds-talk", "slimy-clouds-talk",
"slimy-hairs-impress", "slimy-hairs-impress",
"slimy-laws-explode", "slimy-laws-explode",
"slimy-news-help",
"slimy-onions-approve", "slimy-onions-approve",
"slimy-walls-draw", "slimy-walls-draw",
"slow-beds-shave", "slow-beds-shave",
@ -810,6 +814,7 @@
"twenty-gifts-develop", "twenty-gifts-develop",
"two-brooms-fail", "two-brooms-fail",
"two-candles-move", "two-candles-move",
"two-cats-approve",
"two-dogs-accept", "two-dogs-accept",
"two-dragons-yell", "two-dragons-yell",
"two-falcons-buy", "two-falcons-buy",

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: prevent binding to imports

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: never abort bidirectional transitions

@ -1,5 +1,23 @@
# svelte # svelte
## 5.0.0-next.240
### Patch Changes
- fix: use WAAPI to control timing of JS-based animations ([#13018](https://github.com/sveltejs/svelte/pull/13018))
- fix: prevent binding to imports ([#13035](https://github.com/sveltejs/svelte/pull/13035))
- fix: never abort bidirectional transitions ([#13018](https://github.com/sveltejs/svelte/pull/13018))
## 5.0.0-next.239
### Patch Changes
- fix: properly handle proxied array length mutations ([#13026](https://github.com/sveltejs/svelte/pull/13026))
- fix: repair `href` attribute mismatches ([#13032](https://github.com/sveltejs/svelte/pull/13032))
## 5.0.0-next.238 ## 5.0.0-next.238
### Patch Changes ### Patch Changes

@ -2,7 +2,7 @@
"name": "svelte", "name": "svelte",
"description": "Cybernetically enhanced web apps", "description": "Cybernetically enhanced web apps",
"license": "MIT", "license": "MIT",
"version": "5.0.0-next.238", "version": "5.0.0-next.240",
"type": "module", "type": "module",
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
"engines": { "engines": {

@ -44,10 +44,6 @@ export function BindDirective(node, context) {
e.bind_invalid_value(node.expression); e.bind_invalid_value(node.expression);
} }
if (binding?.kind === 'derived') {
e.constant_binding(node.expression, 'derived state');
}
if (context.state.analysis.runes && binding?.kind === 'each') { if (context.state.analysis.runes && binding?.kind === 'each') {
e.each_item_invalid_assignment(node); e.each_item_invalid_assignment(node);
} }

@ -71,7 +71,11 @@ export function validate_no_const_assignment(node, argument, scope, is_binding)
} }
} else if (argument.type === 'Identifier') { } else if (argument.type === 'Identifier') {
const binding = scope.get(argument.name); const binding = scope.get(argument.name);
if (binding?.declaration_kind === 'const' && binding.kind !== 'each') { if (
binding?.kind === 'derived' ||
binding?.declaration_kind === 'import' ||
(binding?.declaration_kind === 'const' && binding.kind !== 'each')
) {
// e.invalid_const_assignment( // e.invalid_const_assignment(
// node, // node,
// is_binding, // is_binding,
@ -83,7 +87,12 @@ export function validate_no_const_assignment(node, argument, scope, is_binding)
// ); // );
// TODO have a more specific error message for assignments to things like `{:then foo}` // TODO have a more specific error message for assignments to things like `{:then foo}`
const thing = 'constant'; const thing =
binding.declaration_kind === 'import'
? 'import'
: binding.kind === 'derived'
? 'derived state'
: 'constant';
if (is_binding) { if (is_binding) {
e.constant_binding(node, thing); e.constant_binding(node, thing);

@ -390,7 +390,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
if (node.expression) { if (node.expression) {
for (const id of extract_identifiers_from_destructuring(node.expression)) { for (const id of extract_identifiers_from_destructuring(node.expression)) {
const binding = scope.declare(id, 'derived', 'const'); const binding = scope.declare(id, 'template', 'const');
bindings.push(binding); bindings.push(binding);
} }
} else { } else {
@ -401,7 +401,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
start: node.start, start: node.start,
end: node.end end: node.end
}; };
const binding = scope.declare(id, 'derived', 'const'); const binding = scope.declare(id, 'template', 'const');
bindings.push(binding); bindings.push(binding);
} }
}, },
@ -492,7 +492,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
for (const id of extract_identifiers(declarator.id)) { for (const id of extract_identifiers(declarator.id)) {
const binding = state.scope.declare( const binding = state.scope.declare(
id, id,
is_parent_const_tag ? 'derived' : 'normal', is_parent_const_tag ? 'template' : 'normal',
node.kind, node.kind,
declarator.init declarator.init
); );
@ -548,7 +548,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
binding.metadata = { inside_rest: is_rest_id }; binding.metadata = { inside_rest: is_rest_id };
} }
if (node.context.type !== 'Identifier') { if (node.context.type !== 'Identifier') {
scope.declare(b.id('$$item'), 'derived', 'synthetic'); scope.declare(b.id('$$item'), 'template', 'synthetic');
} }
// Visit to pick up references from default initializers // Visit to pick up references from default initializers
visit(node.context, { scope }); visit(node.context, { scope });
@ -557,7 +557,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
const is_keyed = const is_keyed =
node.key && node.key &&
(node.key.type !== 'Identifier' || !node.index || node.key.name !== node.index); (node.key.type !== 'Identifier' || !node.index || node.key.name !== node.index);
scope.declare(b.id(node.index), is_keyed ? 'derived' : 'normal', 'const', node); scope.declare(b.id(node.index), is_keyed ? 'template' : 'normal', 'const', node);
} }
if (node.key) visit(node.key, { scope }); if (node.key) visit(node.key, { scope });
@ -604,7 +604,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
scopes.set(node.value, value_scope); scopes.set(node.value, value_scope);
context.visit(node.value, { scope: value_scope }); context.visit(node.value, { scope: value_scope });
for (const id of extract_identifiers(node.value)) { for (const id of extract_identifiers(node.value)) {
then_scope.declare(id, 'derived', 'const'); then_scope.declare(id, 'template', 'const');
value_scope.declare(id, 'normal', 'const'); value_scope.declare(id, 'normal', 'const');
} }
} }
@ -618,7 +618,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
scopes.set(node.error, error_scope); scopes.set(node.error, error_scope);
context.visit(node.error, { scope: error_scope }); context.visit(node.error, { scope: error_scope });
for (const id of extract_identifiers(node.error)) { for (const id of extract_identifiers(node.error)) {
catch_scope.declare(id, 'derived', 'const'); catch_scope.declare(id, 'template', 'const');
error_scope.declare(id, 'normal', 'const'); error_scope.declare(id, 'normal', 'const');
} }
} }

@ -267,6 +267,7 @@ export interface Binding {
* - `snippet`: A snippet parameter * - `snippet`: A snippet parameter
* - `store_sub`: A $store value * - `store_sub`: A $store value
* - `legacy_reactive`: A `$:` declaration * - `legacy_reactive`: A `$:` declaration
* - `template`: A binding declared in the template, e.g. in an `await` block or `const` tag
*/ */
kind: kind:
| 'normal' | 'normal'
@ -279,7 +280,8 @@ export interface Binding {
| 'each' | 'each'
| 'snippet' | 'snippet'
| 'store_sub' | 'store_sub'
| 'legacy_reactive'; | 'legacy_reactive'
| 'template';
declaration_kind: DeclarationKind; declaration_kind: DeclarationKind;
/** /**
* What the value was initialized with. * What the value was initialized with.

@ -92,7 +92,7 @@ export function createRawSnippet(fn) {
var fragment = create_fragment_from_html(html); var fragment = create_fragment_from_html(html);
element = /** @type {Element} */ (get_first_child(fragment)); element = /** @type {Element} */ (get_first_child(fragment));
if (DEV && (get_next_sibling(element) !== null || element.nodeType !== 3)) { if (DEV && (get_next_sibling(element) !== null || element.nodeType !== 1)) {
w.invalid_raw_snippet_render(); w.invalid_raw_snippet_render();
} }

@ -1,8 +1,7 @@
/** @import { AnimateFn, Animation, AnimationConfig, EachItem, Effect, Task, TransitionFn, TransitionManager } from '#client' */ /** @import { AnimateFn, Animation, AnimationConfig, EachItem, Effect, TransitionFn, TransitionManager } from '#client' */
import { noop, is_function } from '../../../shared/utils.js'; import { noop, is_function } from '../../../shared/utils.js';
import { effect } from '../../reactivity/effects.js'; import { effect } from '../../reactivity/effects.js';
import { current_effect, untrack } from '../../runtime.js'; import { current_effect, untrack } from '../../runtime.js';
import { raf } from '../../timing.js';
import { loop } from '../../loop.js'; import { loop } from '../../loop.js';
import { should_intro } from '../../render.js'; import { should_intro } from '../../render.js';
import { current_each_item } from '../blocks/each.js'; import { current_each_item } from '../blocks/each.js';
@ -97,17 +96,10 @@ export function animation(element, get_fn, get_params) {
) { ) {
const options = get_fn()(this.element, { from, to }, get_params?.()); const options = get_fn()(this.element, { from, to }, get_params?.());
animation = animate( animation = animate(this.element, options, undefined, 1, () => {
this.element,
options,
undefined,
1,
() => {
animation?.abort(); animation?.abort();
animation = undefined; animation = undefined;
}, });
undefined
);
} }
}, },
fix() { fix() {
@ -192,14 +184,13 @@ export function transition(flags, element, get_fn, get_params) {
/** @type {Animation | undefined} */ /** @type {Animation | undefined} */
var outro; var outro;
/** @type {(() => void) | undefined} */
var reset;
function get_options() { function get_options() {
// If a transition is still ongoing, we use the existing options rather than generating // If a transition is still ongoing, we use the existing options rather than generating
// new ones. This ensures that reversible transitions reverse smoothly, rather than // new ones. This ensures that reversible transitions reverse smoothly, rather than
// jumping to a new spot because (for example) a different `duration` was used // jumping to a new spot because (for example) a different `duration` was used
return (current_options ??= get_fn()(element, get_params?.(), { direction })); return (current_options ??= get_fn()(element, get_params?.() ?? /** @type {P} */ ({}), {
direction
}));
} }
/** @type {TransitionManager} */ /** @type {TransitionManager} */
@ -208,65 +199,43 @@ export function transition(flags, element, get_fn, get_params) {
in() { in() {
element.inert = inert; element.inert = inert;
// abort the outro to prevent overlap with the intro if (!is_intro) {
outro?.abort(); outro?.abort();
// abort previous intro (can happen if an element is intro'd, then outro'd, then intro'd again) outro?.reset?.();
return;
}
if (!is_outro) {
// if we intro then outro then intro again, we want to abort the first intro,
// if it's not a bidirectional transition
intro?.abort(); intro?.abort();
}
if (is_intro) {
dispatch_event(element, 'introstart'); dispatch_event(element, 'introstart');
intro = animate(
element, intro = animate(element, get_options(), outro, 1, () => {
get_options(),
outro,
1,
() => {
dispatch_event(element, 'introend'); dispatch_event(element, 'introend');
// Ensure we cancel the animation to prevent leaking // Ensure we cancel the animation to prevent leaking
intro?.abort(); intro?.abort();
intro = current_options = undefined; intro = current_options = undefined;
}, });
is_both
? undefined
: () => {
intro = current_options = undefined;
}
);
} else {
reset?.();
}
}, },
out(fn) { out(fn) {
// abort previous outro (can happen if an element is outro'd, then intro'd, then outro'd again) if (!is_outro) {
outro?.abort(); fn?.();
current_options = undefined;
return;
}
if (is_outro) {
element.inert = true; element.inert = true;
dispatch_event(element, 'outrostart'); dispatch_event(element, 'outrostart');
outro = animate(
element,
get_options(),
intro,
0,
() => {
dispatch_event(element, 'outroend');
outro = current_options = undefined;
fn?.();
},
is_both
? undefined
: () => {
outro = current_options = undefined;
}
);
// TODO arguably the outro should never null itself out until _all_ outros for this effect have completed... outro = animate(element, get_options(), intro, 0, () => {
// in that case we wouldn't need to store `reset` separately dispatch_event(element, 'outroend');
reset = outro.reset;
} else {
fn?.(); fn?.();
} });
}, },
stop: () => { stop: () => {
intro?.abort(); intro?.abort();
@ -282,7 +251,7 @@ export function transition(flags, element, get_fn, get_params) {
// parent (block) effect is where the state change happened. we can determine that by // parent (block) effect is where the state change happened. we can determine that by
// looking at whether the block effect is currently initializing // looking at whether the block effect is currently initializing
if (is_intro && should_intro) { if (is_intro && should_intro) {
let run = is_global; var run = is_global;
if (!run) { if (!run) {
var block = /** @type {Effect | null} */ (e.parent); var block = /** @type {Effect | null} */ (e.parent);
@ -311,17 +280,16 @@ export function transition(flags, element, get_fn, get_params) {
* @param {AnimationConfig | ((opts: { direction: 'in' | 'out' }) => AnimationConfig)} options * @param {AnimationConfig | ((opts: { direction: 'in' | 'out' }) => AnimationConfig)} options
* @param {Animation | undefined} counterpart The corresponding intro/outro to this outro/intro * @param {Animation | undefined} counterpart The corresponding intro/outro to this outro/intro
* @param {number} t2 The target `t` value `1` for intro, `0` for outro * @param {number} t2 The target `t` value `1` for intro, `0` for outro
* @param {(() => void) | undefined} on_finish Called after successfully completing the animation * @param {(() => void)} on_finish Called after successfully completing the animation
* @param {(() => void) | undefined} on_abort Called if the animation is aborted
* @returns {Animation} * @returns {Animation}
*/ */
function animate(element, options, counterpart, t2, on_finish, on_abort) { function animate(element, options, counterpart, t2, on_finish) {
var is_intro = t2 === 1; var is_intro = t2 === 1;
if (is_function(options)) { if (is_function(options)) {
// In the case of a deferred transition (such as `crossfade`), `option` will be // In the case of a deferred transition (such as `crossfade`), `option` will be
// a function rather than an `AnimationConfig`. We need to call this function // a function rather than an `AnimationConfig`. We need to call this function
// once DOM has been updated... // once the DOM has been updated...
/** @type {Animation} */ /** @type {Animation} */
var a; var a;
var aborted = false; var aborted = false;
@ -329,7 +297,7 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
queue_micro_task(() => { queue_micro_task(() => {
if (aborted) return; if (aborted) return;
var o = options({ direction: is_intro ? 'in' : 'out' }); var o = options({ direction: is_intro ? 'in' : 'out' });
a = animate(element, o, counterpart, t2, on_finish, on_abort); a = animate(element, o, counterpart, t2, on_finish);
}); });
// ...but we want to do so without using `async`/`await` everywhere, so // ...but we want to do so without using `async`/`await` everywhere, so
@ -341,14 +309,15 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
}, },
deactivate: () => a.deactivate(), deactivate: () => a.deactivate(),
reset: () => a.reset(), reset: () => a.reset(),
t: (now) => a.t(now) t: () => a.t()
}; };
} }
counterpart?.deactivate(); counterpart?.deactivate();
if (!options?.duration) { if (!options?.duration) {
on_finish?.(); on_finish();
return { return {
abort: noop, abort: noop,
deactivate: noop, deactivate: noop,
@ -359,90 +328,73 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
const { delay = 0, css, tick, easing = linear } = options; const { delay = 0, css, tick, easing = linear } = options;
var start = raf.now() + delay; var keyframes = [];
var t1 = counterpart?.t(start) ?? 1 - t2;
var delta = t2 - t1;
var duration = options.duration * Math.abs(delta); if (is_intro && counterpart === undefined) {
var end = start + duration; if (tick) {
tick(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes?
}
/** @type {globalThis.Animation} */ if (css) {
var animation; var styles = css_to_keyframe(css(0, 1));
keyframes.push(styles, styles);
}
}
/** @type {Task} */ var get_t = () => 1 - t2;
var task;
if (css) { // create a dummy animation that lasts as long as the delay (but with whatever devtools
// run after a micro task so that all transitions that are lining up and are about to run can correctly measure the DOM // multiplier is in effect). in the common case that it is `0`, we keep it anyway so that
queue_micro_task(() => { // the CSS keyframes aren't created until the DOM is updated
// WAAPI var animation = element.animate(keyframes, { duration: delay });
animation.onfinish = () => {
// for bidirectional transitions, we start from the current position,
// rather than doing a full intro/outro
var t1 = counterpart?.t() ?? 1 - t2;
counterpart?.abort();
var delta = t2 - t1;
var duration = /** @type {number} */ (options.duration) * Math.abs(delta);
var keyframes = []; var keyframes = [];
var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value
// In case of a delayed intro, apply the initial style for the duration of the delay; if (css) {
// else in case of a fade-in for example the element would be visible until the animation starts var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value
if (is_intro && delay > 0) {
let m = Math.ceil(delay / (1000 / 60));
let keyframe = css_to_keyframe(css(0, 1));
for (let i = 0; i < m; i += 1) {
keyframes.push(keyframe);
}
}
for (var i = 0; i <= n; i += 1) { for (var i = 0; i <= n; i += 1) {
var t = t1 + delta * easing(i / n); var t = t1 + delta * easing(i / n);
var styles = css(t, 1 - t); var styles = css(t, 1 - t);
keyframes.push(css_to_keyframe(styles)); keyframes.push(css_to_keyframe(styles));
} }
}
animation = element.animate(keyframes, { animation = element.animate(keyframes, { duration, fill: 'forwards' });
delay: is_intro ? 0 : delay,
duration: duration + (is_intro ? delay : 0),
easing: 'linear',
fill: 'forwards'
});
animation.finished animation.onfinish = () => {
.then(() => { get_t = () => t2;
on_finish?.(); tick?.(t2, 1 - t2);
on_finish();
};
if (t2 === 1) { get_t = () => {
animation.cancel(); var time = /** @type {number} */ (
} /** @type {globalThis.Animation} */ (animation).currentTime
}) );
.catch((e) => {
// Error for DOMException: The user aborted a request. This results in two things:
// - startTime is `null`
// - currentTime is `null`
// We can't use the existence of an AbortError as this error and error code is shared
// with other Web APIs such as fetch().
if (animation.startTime !== null && animation.currentTime !== null) { return t1 + delta * easing(time / duration);
throw e; };
}
});
});
} else {
// Timer
if (t1 === 0) {
tick?.(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes?
}
task = loop((now) => { if (tick) {
if (now >= end) { loop(() => {
tick?.(t2, 1 - t2); if (animation.playState !== 'running') return false;
on_finish?.();
return false;
}
if (now >= start) { var t = get_t();
var p = t1 + delta * easing((now - start) / duration); tick(t, 1 - t);
tick?.(p, 1 - p);
}
return true; return true;
}); });
} }
};
return { return {
abort: () => { abort: () => {
@ -451,23 +403,15 @@ function animate(element, options, counterpart, t2, on_finish, on_abort) {
// This prevents memory leaks in Chromium // This prevents memory leaks in Chromium
animation.effect = null; animation.effect = null;
} }
task?.abort();
on_abort?.();
on_finish = undefined;
on_abort = undefined;
}, },
deactivate: () => { deactivate: () => {
on_finish = undefined; on_finish = noop;
on_abort = undefined;
}, },
reset: () => { reset: () => {
if (t2 === 0) { if (t2 === 0) {
tick?.(1, 0); tick?.(1, 0);
} }
}, },
t: (now) => { t: () => get_t()
var t = t1 + delta * easing((now - start) / duration);
return Math.min(1, Math.max(0, t));
}
}; };
} }

@ -290,10 +290,10 @@ let mounted_components = new WeakMap();
*/ */
export function unmount(component) { export function unmount(component) {
const fn = mounted_components.get(component); const fn = mounted_components.get(component);
if (DEV && !fn) {
if (fn) {
fn();
} else if (DEV) {
w.lifecycle_double_unmount(); w.lifecycle_double_unmount();
// eslint-disable-next-line no-console
console.trace('stack trace');
} }
fn?.();
} }

@ -121,7 +121,7 @@ export interface Animation {
/** 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 */ /** 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; reset: () => void;
/** Get the `t` value (between `0` and `1`) of the animation, so that its counterpart can start from the right place */ /** 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; t: () => number;
} }
export type TransitionFn<P> = ( export type TransitionFn<P> = (

@ -50,6 +50,10 @@ function print_error(payload, parent, child) {
payload.head.out += `<script>console.error(${JSON.stringify(message)})</script>`; payload.head.out += `<script>console.error(${JSON.stringify(message)})</script>`;
} }
export function reset_elements() {
parent = null;
}
/** /**
* @param {Payload} payload * @param {Payload} payload
* @param {string} tag * @param {string} tag

@ -16,6 +16,7 @@ import { current_component, pop, push } from './context.js';
import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js'; import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
import { validate_store } from '../shared/validate.js'; import { validate_store } from '../shared/validate.js';
import { is_boolean_attribute, is_void } from '../../utils.js'; import { is_boolean_attribute, is_void } from '../../utils.js';
import { reset_elements } from './dev.js';
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
// https://infra.spec.whatwg.org/#noncharacter // https://infra.spec.whatwg.org/#noncharacter
@ -100,6 +101,11 @@ export function render(component, options = {}) {
on_destroy = []; on_destroy = [];
payload.out += BLOCK_OPEN; payload.out += BLOCK_OPEN;
if (DEV) {
// prevent parent/child element state being corrupted by a bad render
reset_elements();
}
if (options.context) { if (options.context) {
push(); push();
/** @type {Component} */ (current_component).c = options.context; /** @type {Component} */ (current_component).c = options.context;

@ -6,5 +6,5 @@
* https://svelte.dev/docs/svelte-compiler#svelte-version * https://svelte.dev/docs/svelte-compiler#svelte-version
* @type {string} * @type {string}
*/ */
export const VERSION = '5.0.0-next.238'; export const VERSION = '5.0.0-next.240';
export const PUBLIC_VERSION = '5'; export const PUBLIC_VERSION = '5';

@ -1,4 +1,6 @@
import { flushSync } from 'svelte';
import { raf as svelte_raf } from 'svelte/internal/client'; import { raf as svelte_raf } from 'svelte/internal/client';
import { queue_micro_task } from '../src/internal/client/dom/task.js';
export const raf = { export const raf = {
animations: new Set(), animations: new Set(),
@ -23,6 +25,7 @@ export const raf = {
*/ */
function tick(time) { function tick(time) {
raf.time = time; raf.time = time;
flushSync();
for (const animation of raf.animations) { for (const animation of raf.animations) {
animation._update(); animation._update();
} }
@ -38,12 +41,16 @@ class Animation {
#offset = raf.time; #offset = raf.time;
#finished = () => {}; /** @type {Function} */
#cancelled = () => {}; #onfinish = () => {};
/** @type {Function} */
#oncancel = () => {};
target; target;
currentTime = 0; currentTime = 0;
startTime = 0; startTime = 0;
playState = 'running';
/** /**
* @param {HTMLElement} target * @param {HTMLElement} target
@ -53,24 +60,9 @@ class Animation {
constructor(target, keyframes, { duration, delay }) { constructor(target, keyframes, { duration, delay }) {
this.target = target; this.target = target;
this.#keyframes = keyframes; this.#keyframes = keyframes;
this.#duration = duration; this.#duration = Math.round(duration);
this.#delay = delay ?? 0; this.#delay = delay ?? 0;
// Promise-like semantics, but call callbacks immediately on raf.tick
this.finished = {
/** @param {() => void} callback */
then: (callback) => {
this.#finished = callback;
return {
/** @param {() => void} callback */
catch: (callback) => {
this.#cancelled = callback;
}
};
}
};
this._update(); this._update();
} }
@ -82,7 +74,7 @@ class Animation {
this.#apply_keyframe(target_frame); this.#apply_keyframe(target_frame);
if (this.currentTime >= this.#duration) { if (this.currentTime >= this.#duration) {
this.#finished(); this.#onfinish();
raf.animations.delete(this); raf.animations.delete(this);
} }
} }
@ -131,9 +123,31 @@ class Animation {
this.currentTime = null; this.currentTime = null;
// @ts-ignore // @ts-ignore
this.startTime = null; this.startTime = null;
this.#cancelled();
this.playState = 'idle';
this.#oncancel();
raf.animations.delete(this); raf.animations.delete(this);
} }
/** @param {() => {}} fn */
set onfinish(fn) {
if (this.#duration === 0) {
queue_micro_task(fn);
} else {
this.#onfinish = () => {
fn();
this.#onfinish = () => {};
};
}
}
/** @param {() => {}} fn */
set oncancel(fn) {
this.#oncancel = () => {
fn();
this.#oncancel = () => {};
};
}
} }
/** /**

@ -2,4 +2,4 @@
export let count; export let count;
</script> </script>
<button on:click="{() => count.update(n => n + 1)}">count {$count}</button> <button on:click={() => count.update((n) => n + 1)}>count {$count}</button>

@ -0,0 +1,30 @@
import { flushSync } from 'svelte';
import { ok, test } from '../../test';
export default test({
test({ assert, component, target, raf, logs }) {
const button = target.querySelector('button');
flushSync(() => button?.click());
const div = target.querySelector('div');
ok(div);
let ended = 0;
div.addEventListener('introend', () => {
ended += 1;
});
assert.equal(div.style.scale, '0');
assert.deepEqual(logs, ['tick: 0']);
raf.tick(50);
assert.equal(div.style.scale, '0.5');
assert.deepEqual(logs, ['tick: 0', 'tick: 0.5']);
raf.tick(100);
assert.equal(div.style.scale, '');
assert.deepEqual(logs, ['tick: 0', 'tick: 0.5', 'tick: 1']);
assert.equal(ended, 1);
}
});

@ -0,0 +1,21 @@
<script>
let visible = false;
function foo() {
return {
duration: 100,
css: (t) => {
return `scale: ${t}`;
},
tick: (t) => {
console.log(`tick: ${t}`);
}
};
}
</script>
<button on:click={() => (visible = !visible)}>toggle</button>
{#if visible}
<div transition:foo></div>
{/if}

@ -18,6 +18,11 @@ export default test({
assert.equal(spans[1].foo, undefined); assert.equal(spans[1].foo, undefined);
assert.equal(spans[2].foo, undefined); assert.equal(spans[2].foo, undefined);
// intermediate ticks necessary for testing purposes, so that time
// elapses after the initial delay animation's onfinish callback runs
raf.tick(50);
raf.tick(100);
raf.tick(125); raf.tick(125);
assert.equal(spans[0].foo, 0); assert.equal(spans[0].foo, 0);
assert.equal(spans[1].foo, 0.25); assert.equal(spans[1].foo, 0.25);
@ -36,10 +41,6 @@ export default test({
` `
); );
spans = /** @type {NodeListOf<HTMLSpanElement & { foo: number }>} */ (
target.querySelectorAll('span')
);
assert.equal(spans[0].foo, 1); assert.equal(spans[0].foo, 1);
assert.equal(spans[1].foo, 1); assert.equal(spans[1].foo, 1);
assert.equal(spans[2].foo, 1); assert.equal(spans[2].foo, 1);

@ -4,7 +4,7 @@
let foo_text; let foo_text;
let bar_text; let bar_text;
function foo(node, params) { function foo(node, { duration = 100 }) {
foo_text = node.textContent; foo_text = node.textContent;
return () => { return () => {
@ -13,7 +13,7 @@
} }
return { return {
duration: 100, duration,
tick: t => { tick: t => {
node.foo = t; node.foo = t;
} }
@ -21,7 +21,7 @@
}; };
} }
function bar(node, params) { function bar(node, { duration = 100 }) {
bar_text = node.textContent; bar_text = node.textContent;
return () => { return () => {
@ -30,7 +30,7 @@
} }
return { return {
duration: 100, duration,
tick: t => { tick: t => {
node.foo = t; node.foo = t;
} }

@ -15,5 +15,7 @@ export default test({
component.$destroy(); component.$destroy();
raf.tick(100); raf.tick(100);
} },
warnings: ['Tried to unmount a component that was not mounted']
}); });

@ -60,6 +60,7 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
}; };
logs: any[]; logs: any[];
warnings: any[]; warnings: any[];
errors: any[];
hydrate: Function; hydrate: Function;
}) => void | Promise<void>; }) => void | Promise<void>;
test_ssr?: (args: { logs: any[]; assert: Assert }) => void | Promise<void>; test_ssr?: (args: { logs: any[]; assert: Assert }) => void | Promise<void>;
@ -70,6 +71,7 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
error?: string; error?: string;
runtime_error?: string; runtime_error?: string;
warnings?: string[]; warnings?: string[];
errors?: string[];
expect_unhandled_rejections?: boolean; expect_unhandled_rejections?: boolean;
withoutNormalizeHtml?: boolean | 'only-strip-comments'; withoutNormalizeHtml?: boolean | 'only-strip-comments';
recover?: boolean; recover?: boolean;
@ -96,6 +98,15 @@ afterAll(() => {
process.removeListener('unhandledRejection', unhandled_rejection_handler); process.removeListener('unhandledRejection', unhandled_rejection_handler);
}); });
// eslint-disable-next-line no-console
let console_log = console.log;
// eslint-disable-next-line no-console
let console_warn = console.warn;
// eslint-disable-next-line no-console
let console_error = console.error;
export function runtime_suite(runes: boolean) { export function runtime_suite(runes: boolean) {
return suite_with_variants<RuntimeTest, 'hydrate' | 'ssr' | 'dom', CompileOptions>( return suite_with_variants<RuntimeTest, 'hydrate' | 'ssr' | 'dom', CompileOptions>(
['dom', 'hydrate', 'ssr'], ['dom', 'hydrate', 'ssr'],
@ -171,11 +182,9 @@ async function run_test_variant(
) { ) {
let unintended_error = false; let unintended_error = false;
// eslint-disable-next-line no-console
const { log, warn } = console;
let logs: string[] = []; let logs: string[] = [];
let warnings: string[] = []; let warnings: string[] = [];
let errors: string[] = [];
let manual_hydrate = false; let manual_hydrate = false;
{ {
@ -214,6 +223,13 @@ async function run_test_variant(
} }
}; };
} }
if (str.slice(0, i).includes('errors') || config.errors) {
// eslint-disable-next-line no-console
console.error = (...args) => {
errors.push(...args);
};
}
} }
try { try {
@ -311,15 +327,6 @@ async function run_test_variant(
config.before_test?.(); config.before_test?.();
// eslint-disable-next-line no-console
const error = console.error;
// eslint-disable-next-line no-console
console.error = (error) => {
if (typeof error === 'string' && error.startsWith('Hydration failed')) {
throw new Error(error);
}
};
let instance: any; let instance: any;
let props: any; let props: any;
let hydrate_fn: Function = () => { let hydrate_fn: Function = () => {
@ -357,9 +364,6 @@ async function run_test_variant(
}); });
} }
// eslint-disable-next-line no-console
console.error = error;
if (config.error) { if (config.error) {
unintended_error = true; unintended_error = true;
assert.fail('Expected a runtime error'); assert.fail('Expected a runtime error');
@ -395,6 +399,7 @@ async function run_test_variant(
compileOptions, compileOptions,
logs, logs,
warnings, warnings,
errors,
hydrate: hydrate_fn hydrate: hydrate_fn
}); });
} }
@ -412,12 +417,20 @@ async function run_test_variant(
if (config.warnings) { if (config.warnings) {
assert.deepEqual(warnings, config.warnings); assert.deepEqual(warnings, config.warnings);
} else if (warnings.length && console.warn === warn) { } else if (warnings.length && console.warn === console_warn) {
unintended_error = true; unintended_error = true;
warn.apply(console, warnings); console_warn.apply(console, warnings);
assert.fail('Received unexpected warnings'); assert.fail('Received unexpected warnings');
} }
if (config.errors) {
assert.deepEqual(errors, config.errors);
} else if (errors.length && console.error === console_error) {
unintended_error = true;
console_error.apply(console, errors);
assert.fail('Received unexpected errors');
}
assert_html_equal( assert_html_equal(
target.innerHTML, target.innerHTML,
'', '',
@ -440,8 +453,9 @@ async function run_test_variant(
throw err; throw err;
} }
} finally { } finally {
console.log = log; console.log = console_log;
console.warn = warn; console.warn = console_warn;
console.error = console_error;
config.after_test?.(); config.after_test?.();

@ -3,11 +3,18 @@ import { test } from '../../test';
export default test({ export default test({
html: '<p><p>invalid</p></p>', html: '<p><p>invalid</p></p>',
mode: ['hydrate'], mode: ['hydrate'],
recover: true, recover: true,
test({ assert, target, logs }) { test({ assert, target, logs }) {
target.click(); target.click();
flushSync(); flushSync();
assert.deepEqual(logs, ['body', 'document', 'window']); assert.deepEqual(logs, ['body', 'document', 'window']);
} },
warnings: [
'Hydration failed because the initial UI does not match what was rendered on the server'
]
}); });

@ -1,12 +1,5 @@
import { test } from '../../test'; import { test } from '../../test';
let console_error = console.error;
/**
* @type {any[]}
*/
const log = [];
export default test({ export default test({
compileOptions: { compileOptions: {
dev: true dev: true
@ -16,27 +9,14 @@ export default test({
recover: true, recover: true,
before_test() { mode: ['hydrate'],
console.error = (x) => {
log.push(x);
};
},
after_test() { errors: [
console.error = console_error; 'node_invalid_placement_ssr: `<p>` (main.svelte:6:0) cannot contain `<h1>` (h1.svelte:1:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.',
log.length = 0; 'node_invalid_placement_ssr: `<form>` (main.svelte:9:0) cannot contain `<form>` (form.svelte:1:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.'
}, ],
async test({ assert, variant }) { warnings: [
if (variant === 'hydrate') { 'Hydration failed because the initial UI does not match what was rendered on the server'
assert.equal( ]
log[0].split('\n')[0],
'node_invalid_placement_ssr: `<p>` (main.svelte:6:0) cannot contain `<h1>` (h1.svelte:1:0)'
);
assert.equal(
log[1].split('\n')[0],
'node_invalid_placement_ssr: `<form>` (main.svelte:9:0) cannot contain `<form>` (form.svelte:1:0)'
);
}
}
}); });

@ -18,7 +18,7 @@ export default test({
'<button>toggle</button><p style="opacity: 0;">delayed fade</p>' '<button>toggle</button><p style="opacity: 0;">delayed fade</p>'
); );
raf.tick(99); raf.tick(100);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
'<button>toggle</button><p style="opacity: 0;">delayed fade</p>' '<button>toggle</button><p style="opacity: 0;">delayed fade</p>'
@ -39,6 +39,8 @@ export default test({
raf.tick(275); raf.tick(275);
assert.htmlEqual(target.innerHTML, '<button>toggle</button><p style="">delayed fade</p>'); assert.htmlEqual(target.innerHTML, '<button>toggle</button><p style="">delayed fade</p>');
raf.tick(300);
raf.tick(350); raf.tick(350);
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,

@ -3,5 +3,9 @@ import { test } from '../../test';
export default test({ export default test({
compileOptions: { compileOptions: {
dev: true dev: true
} },
errors: [
'node_invalid_placement_ssr: `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:1:0) cannot contain `<p>` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:2:1)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.'
]
}); });

@ -16,11 +16,21 @@ interface SSRTest extends BaseTest {
compileOptions?: Partial<CompileOptions>; compileOptions?: Partial<CompileOptions>;
props?: Record<string, any>; props?: Record<string, any>;
withoutNormalizeHtml?: boolean; withoutNormalizeHtml?: boolean;
errors?: string[];
} }
// eslint-disable-next-line no-console
let console_error = console.error;
const { test, run } = suite<SSRTest>(async (config, test_dir) => { const { test, run } = suite<SSRTest>(async (config, test_dir) => {
await compile_directory(test_dir, 'server', config.compileOptions); await compile_directory(test_dir, 'server', config.compileOptions);
const errors: string[] = [];
console.error = (...args) => {
errors.push(...args);
};
const Component = (await import(`${test_dir}/_output/server/main.svelte.js`)).default; const Component = (await import(`${test_dir}/_output/server/main.svelte.js`)).default;
const expected_html = try_read_file(`${test_dir}/_expected.html`); const expected_html = try_read_file(`${test_dir}/_expected.html`);
const rendered = render(Component, { props: config.props || {} }); const rendered = render(Component, { props: config.props || {} });
@ -64,6 +74,12 @@ const { test, run } = suite<SSRTest>(async (config, test_dir) => {
} }
} }
} }
if (errors.length > 0) {
assert.deepEqual(config.errors, errors);
}
console.error = console_error;
}); });
export { test }; export { test };

@ -0,0 +1,14 @@
[
{
"code": "constant_binding",
"message": "Cannot bind to import",
"start": {
"line": 6,
"column": 7
},
"end": {
"line": 6,
"column": 25
}
}
]

@ -0,0 +1,6 @@
<script>
import Input from './Input.svelte';
import { dummy } from './dummy.js';
</script>
<Input bind:value={dummy} />

@ -0,0 +1,14 @@
[
{
"code": "constant_binding",
"message": "Cannot bind to import",
"start": {
"line": 5,
"column": 7
},
"end": {
"line": 5,
"column": 25
}
}
]

@ -0,0 +1,5 @@
<script>
import { dummy } from './dummy.js';
</script>
<input bind:value={dummy}>

@ -922,6 +922,7 @@ declare module 'svelte/compiler' {
* - `snippet`: A snippet parameter * - `snippet`: A snippet parameter
* - `store_sub`: A $store value * - `store_sub`: A $store value
* - `legacy_reactive`: A `$:` declaration * - `legacy_reactive`: A `$:` declaration
* - `template`: A binding declared in the template, e.g. in an `await` block or `const` tag
*/ */
kind: kind:
| 'normal' | 'normal'
@ -934,7 +935,8 @@ declare module 'svelte/compiler' {
| 'each' | 'each'
| 'snippet' | 'snippet'
| 'store_sub' | 'store_sub'
| 'legacy_reactive'; | 'legacy_reactive'
| 'template';
declaration_kind: DeclarationKind; declaration_kind: DeclarationKind;
/** /**
* What the value was initialized with. * What the value was initialized with.

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save