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

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

@ -44,10 +44,6 @@ export function BindDirective(node, context) {
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') {
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') {
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(
// node,
// 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}`
const thing = 'constant';
const thing =
binding.declaration_kind === 'import'
? 'import'
: binding.kind === 'derived'
? 'derived state'
: 'constant';
if (is_binding) {
e.constant_binding(node, thing);

@ -390,7 +390,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
if (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);
}
} else {
@ -401,7 +401,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
start: node.start,
end: node.end
};
const binding = scope.declare(id, 'derived', 'const');
const binding = scope.declare(id, 'template', 'const');
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)) {
const binding = state.scope.declare(
id,
is_parent_const_tag ? 'derived' : 'normal',
is_parent_const_tag ? 'template' : 'normal',
node.kind,
declarator.init
);
@ -548,7 +548,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
binding.metadata = { inside_rest: is_rest_id };
}
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(node.context, { scope });
@ -557,7 +557,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
const is_keyed =
node.key &&
(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 });
@ -604,7 +604,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
scopes.set(node.value, value_scope);
context.visit(node.value, { scope: value_scope });
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');
}
}
@ -618,7 +618,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
scopes.set(node.error, error_scope);
context.visit(node.error, { scope: error_scope });
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');
}
}

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

@ -92,7 +92,7 @@ export function createRawSnippet(fn) {
var fragment = create_fragment_from_html(html);
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();
}

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

@ -290,10 +290,10 @@ let mounted_components = new WeakMap();
*/
export function unmount(component) {
const fn = mounted_components.get(component);
if (DEV && !fn) {
if (fn) {
fn();
} else if (DEV) {
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 */
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;
t: () => number;
}
export type TransitionFn<P> = (

@ -50,6 +50,10 @@ function print_error(payload, parent, child) {
payload.head.out += `<script>console.error(${JSON.stringify(message)})</script>`;
}
export function reset_elements() {
parent = null;
}
/**
* @param {Payload} payload
* @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 { validate_store } from '../shared/validate.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://infra.spec.whatwg.org/#noncharacter
@ -100,6 +101,11 @@ export function render(component, options = {}) {
on_destroy = [];
payload.out += BLOCK_OPEN;
if (DEV) {
// prevent parent/child element state being corrupted by a bad render
reset_elements();
}
if (options.context) {
push();
/** @type {Component} */ (current_component).c = options.context;

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

@ -1,4 +1,6 @@
import { flushSync } from 'svelte';
import { raf as svelte_raf } from 'svelte/internal/client';
import { queue_micro_task } from '../src/internal/client/dom/task.js';
export const raf = {
animations: new Set(),
@ -23,6 +25,7 @@ export const raf = {
*/
function tick(time) {
raf.time = time;
flushSync();
for (const animation of raf.animations) {
animation._update();
}
@ -38,12 +41,16 @@ class Animation {
#offset = raf.time;
#finished = () => {};
#cancelled = () => {};
/** @type {Function} */
#onfinish = () => {};
/** @type {Function} */
#oncancel = () => {};
target;
currentTime = 0;
startTime = 0;
playState = 'running';
/**
* @param {HTMLElement} target
@ -53,24 +60,9 @@ class Animation {
constructor(target, keyframes, { duration, delay }) {
this.target = target;
this.#keyframes = keyframes;
this.#duration = duration;
this.#duration = Math.round(duration);
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();
}
@ -82,7 +74,7 @@ class Animation {
this.#apply_keyframe(target_frame);
if (this.currentTime >= this.#duration) {
this.#finished();
this.#onfinish();
raf.animations.delete(this);
}
}
@ -131,9 +123,31 @@ class Animation {
this.currentTime = null;
// @ts-ignore
this.startTime = null;
this.#cancelled();
this.playState = 'idle';
this.#oncancel();
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;
</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[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);
assert.equal(spans[0].foo, 0);
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[1].foo, 1);
assert.equal(spans[2].foo, 1);

@ -14,4 +14,4 @@
{#each things as thing, i}
<span out:foo="{{delay: i * 50}}">{thing}</span>
{/each}
{/each}

@ -17,4 +17,4 @@
<p class='then' transition:foo>{value}</p>
{:catch error}
<p class='catch' transition:foo>{error.message}</p>
{/await}
{/await}

@ -4,7 +4,7 @@
let foo_text;
let bar_text;
function foo(node, params) {
function foo(node, { duration = 100 }) {
foo_text = node.textContent;
return () => {
@ -13,7 +13,7 @@
}
return {
duration: 100,
duration,
tick: t => {
node.foo = t;
}
@ -21,7 +21,7 @@
};
}
function bar(node, params) {
function bar(node, { duration = 100 }) {
bar_text = node.textContent;
return () => {
@ -30,7 +30,7 @@
}
return {
duration: 100,
duration,
tick: t => {
node.foo = t;
}
@ -43,4 +43,4 @@
<div class="foo" in:foo>a</div>
{:else}
<div out:bar>b</div>
{/if}
{/if}

@ -15,5 +15,7 @@ export default test({
component.$destroy();
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[];
warnings: any[];
errors: any[];
hydrate: Function;
}) => 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;
runtime_error?: string;
warnings?: string[];
errors?: string[];
expect_unhandled_rejections?: boolean;
withoutNormalizeHtml?: boolean | 'only-strip-comments';
recover?: boolean;
@ -96,6 +98,15 @@ afterAll(() => {
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) {
return suite_with_variants<RuntimeTest, 'hydrate' | 'ssr' | 'dom', CompileOptions>(
['dom', 'hydrate', 'ssr'],
@ -171,11 +182,9 @@ async function run_test_variant(
) {
let unintended_error = false;
// eslint-disable-next-line no-console
const { log, warn } = console;
let logs: string[] = [];
let warnings: string[] = [];
let errors: string[] = [];
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 {
@ -311,15 +327,6 @@ async function run_test_variant(
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 props: any;
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) {
unintended_error = true;
assert.fail('Expected a runtime error');
@ -395,6 +399,7 @@ async function run_test_variant(
compileOptions,
logs,
warnings,
errors,
hydrate: hydrate_fn
});
}
@ -412,12 +417,20 @@ async function run_test_variant(
if (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;
warn.apply(console, warnings);
console_warn.apply(console, 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(
target.innerHTML,
'',
@ -440,8 +453,9 @@ async function run_test_variant(
throw err;
}
} finally {
console.log = log;
console.warn = warn;
console.log = console_log;
console.warn = console_warn;
console.error = console_error;
config.after_test?.();

@ -3,11 +3,18 @@ import { test } from '../../test';
export default test({
html: '<p><p>invalid</p></p>',
mode: ['hydrate'],
recover: true,
test({ assert, target, logs }) {
target.click();
flushSync();
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';
let console_error = console.error;
/**
* @type {any[]}
*/
const log = [];
export default test({
compileOptions: {
dev: true
@ -16,27 +9,14 @@ export default test({
recover: true,
before_test() {
console.error = (x) => {
log.push(x);
};
},
mode: ['hydrate'],
after_test() {
console.error = console_error;
log.length = 0;
},
errors: [
'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.',
'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 }) {
if (variant === 'hydrate') {
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)'
);
}
}
warnings: [
'Hydration failed because the initial UI does not match what was rendered on the server'
]
});

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

@ -3,5 +3,9 @@ import { test } from '../../test';
export default test({
compileOptions: {
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>;
props?: Record<string, any>;
withoutNormalizeHtml?: boolean;
errors?: string[];
}
// eslint-disable-next-line no-console
let console_error = console.error;
const { test, run } = suite<SSRTest>(async (config, test_dir) => {
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 expected_html = try_read_file(`${test_dir}/_expected.html`);
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 };

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

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