Merge remote-tracking branch 'origin/main' into svelte-custom-renderer

pull/18058/head
paoloricciuti 4 months ago
commit f35e12cf0e

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: discard batches made obsolete by commit

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: defer batch resolution until earlier intersecting batches have committed

@ -1,5 +1,31 @@
# svelte
## 5.54.0
### Minor Changes
- feat: allow `css`, `runes`, `customElement` compiler options to be functions ([#17951](https://github.com/sveltejs/svelte/pull/17951))
### Patch Changes
- fix: reinstate reactivity loss tracking ([#17801](https://github.com/sveltejs/svelte/pull/17801))
## 5.53.13
### Patch Changes
- fix: ensure `$inspect` after top level await doesn't break builds ([#17943](https://github.com/sveltejs/svelte/pull/17943))
- fix: resume inert effects when they come from offscreen ([#17942](https://github.com/sveltejs/svelte/pull/17942))
- fix: don't eagerly access not-yet-initialized functions in template ([#17938](https://github.com/sveltejs/svelte/pull/17938))
- fix: discard batches made obsolete by commit ([#17934](https://github.com/sveltejs/svelte/pull/17934))
- fix: ensure "is standalone child" is correctly reset ([#17944](https://github.com/sveltejs/svelte/pull/17944))
- fix: remove nodes in boundary when work is pending and HMR is active ([#17932](https://github.com/sveltejs/svelte/pull/17932))
## 5.53.12
### Patch Changes

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

@ -23,6 +23,7 @@ export { print } from './print/index.js';
export function compile(source, options) {
source = remove_bom(source);
state.reset({ warning: options.warningFilter, filename: options.filename });
const validated = validate_component_options(options, '');
let parsed = _parse(source);
@ -33,7 +34,9 @@ export function compile(source, options) {
const combined_options = {
...validated,
...parsed_options,
customElementOptions
customElementOptions,
css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : validated.css,
runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes
};
if (parsed.metadata.ts) {

@ -146,6 +146,8 @@ export function migrate(source, { filename, use_ts } = {}) {
...parsed_options,
customElementOptions,
filename: filename ?? UNKNOWN_FILENAME,
css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : () => 'external',
runes: 'runes' in parsed_options ? () => parsed_options.runes : () => undefined,
experimental: {
async: true
}

@ -345,6 +345,8 @@ export function analyze_component(root, source, options) {
let synthetic_stores_legacy_check = [];
const runes_option = options.runes?.({ filename: options.filename });
// create synthetic bindings for store subscriptions
for (const [name, references] of module.scope.references) {
if (name[0] !== '$' || RESERVED.includes(name)) continue;
@ -359,7 +361,7 @@ export function analyze_component(root, source, options) {
// If we're not in legacy mode through the compiler option, assume the user
// is referencing a rune and not a global store.
if (
options.runes === false ||
runes_option === false ||
!is_rune(name) ||
(declaration !== null &&
// const state = $state(0) is valid
@ -395,7 +397,7 @@ export function analyze_component(root, source, options) {
e.store_invalid_scoped_subscription(is_nested_store_subscription_node);
}
if (options.runes !== false) {
if (runes_option !== false) {
if (declaration === null && /[a-z]/.test(store_name[0])) {
e.global_reference_invalid(references[0].node, name);
} else if (declaration !== null && is_rune(name)) {
@ -447,7 +449,7 @@ export function analyze_component(root, source, options) {
const component_name = get_component_name(options.filename);
const runes =
options.runes ??
runes_option ??
(has_await || instance.has_await || Array.from(module.scope.references.keys()).some(is_rune));
if (!runes) {
@ -463,7 +465,10 @@ export function analyze_component(root, source, options) {
}
}
const is_custom_element = !!options.customElementOptions || options.customElement;
const custom_element_from_option = options.customElement({ filename: options.filename });
const css = options.css({ filename: options.filename });
const custom_element = options.customElementOptions ?? custom_element_from_option;
const is_custom_element = !!options.customElementOptions || custom_element_from_option;
const name = module.scope.generate(options.name ?? component_name);
@ -491,7 +496,7 @@ export function analyze_component(root, source, options) {
maybe_runes:
!runes &&
// if they explicitly disabled runes, use the legacy behavior
options.runes !== false &&
runes_option !== false &&
![...module.scope.references.keys()].some((name) =>
['$$props', '$$restProps'].includes(name)
) &&
@ -523,8 +528,8 @@ export function analyze_component(root, source, options) {
needs_props: false,
event_directive_node: null,
uses_event_attributes: false,
custom_element: is_custom_element,
inject_styles: options.css === 'injected' || is_custom_element,
custom_element,
inject_styles: css === 'injected' || is_custom_element,
accessors:
is_custom_element ||
(runes ? false : !!options.accessors) ||
@ -680,7 +685,7 @@ export function analyze_component(root, source, options) {
w.options_deprecated_accessors(attribute);
}
if (attribute.name === 'customElement' && !options.customElement) {
if (attribute.name === 'customElement' && !custom_element_from_option) {
w.options_missing_custom_element(attribute);
}

@ -619,7 +619,7 @@ export function client_component(analysis, options) {
);
}
const ce = options.customElementOptions ?? options.customElement;
const ce = analysis.custom_element;
if (ce) {
const ce_props = typeof ce === 'boolean' ? {} : ce.props || {};

@ -63,6 +63,7 @@ export function Fragment(node, context) {
/** @type {ComponentClientTransformState} */
const state = {
...context.state,
is_standalone,
init: [],
snippets: [],
consts: [],
@ -128,7 +129,7 @@ export function Fragment(node, context) {
// no need to create a template, we can just use the existing block's anchor
process_children(trimmed, () => b.id('$$anchor'), false, {
...context,
state: { ...state, is_standalone }
state
});
} else {
/** @type {(is_text: boolean) => Expression} */

@ -82,12 +82,16 @@ export class Memoizer {
async_values() {
if (this.#async.length === 0) return;
return b.array(this.#async.map((memo) => b.thunk(memo.expression, true)));
// use `b.arrow` rather than `b.thunk` so that deferred async/template effects
// always read live bindings rather than a possibly stale snapshot.
return b.array(this.#async.map((memo) => b.arrow([], memo.expression, true)));
}
sync_values() {
if (this.#sync.length === 0) return;
return b.array(this.#sync.map((memo) => b.thunk(memo.expression)));
// use `b.arrow` rather than `b.thunk` so that deferred async/template effects
// always read live bindings rather than a possibly stale snapshot.
return b.array(this.#sync.map((memo) => b.arrow([], memo.expression)));
}
}

@ -65,7 +65,7 @@ export function render_stylesheet(source, analysis, options) {
merge_with_preprocessor_map(css, options, css.map.sources[0]);
if (dev && options.css === 'injected' && css.code) {
if (dev && analysis.inject_styles && css.code) {
css.code += `\n/*# sourceMappingURL=${css.map.toUrl()} */`;
}

@ -300,7 +300,7 @@ export function server_component(analysis, options) {
const body = [...state.hoisted, ...module.body];
if (analysis.css.ast !== null && options.css === 'injected' && !options.customElement) {
if (analysis.css.ast !== null && analysis.inject_styles && !analysis.custom_element) {
const hash = b.literal(analysis.css.hash);
const code = b.literal(render_stylesheet(analysis.source, analysis, options).code);

@ -95,7 +95,8 @@ export function transform_body(instance_body, runner, transform) {
);
if (expression.type === 'EmptyStatement') {
return null;
// Keep indices stable for async sequencing while avoiding array holes in run([...]).
return b.thunk(b.void0, false);
}
return expression.type === 'AwaitExpression'

@ -73,9 +73,11 @@ export interface CompileOptions extends ModuleCompileOptions {
/**
* If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component.
*
* You can also pass a function that receives `{ filename }` and returns a boolean.
*
* @default false
*/
customElement?: boolean;
customElement?: boolean | ((options: { filename: string }) => boolean);
/**
* If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`.
*
@ -101,8 +103,10 @@ export interface CompileOptions extends ModuleCompileOptions {
* - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root.
* - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files.
* This is always `'injected'` when compiling with `customElement` mode.
*
* You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`.
*/
css?: 'injected' | 'external';
css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external');
/**
* A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS.
* It defaults to returning `svelte-${hash(filename ?? css)}`.
@ -146,7 +150,7 @@ export interface CompileOptions extends ModuleCompileOptions {
* which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead.
* @default undefined
*/
runes?: boolean | undefined;
runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined);
/**
* If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`.
*
@ -252,18 +256,22 @@ export type ValidatedCompileOptions = ValidatedModuleCompileOptions &
Required<CompileOptions>,
| keyof ModuleCompileOptions
| 'name'
| 'customElement'
| 'compatibility'
| 'outputFilename'
| 'cssOutputFilename'
| 'sourcemap'
| 'css'
| 'runes'
> & {
name: CompileOptions['name'];
customElement: (options: { filename: string }) => boolean;
outputFilename: CompileOptions['outputFilename'];
cssOutputFilename: CompileOptions['cssOutputFilename'];
sourcemap: CompileOptions['sourcemap'];
compatibility: Required<Required<CompileOptions>['compatibility']>;
runes: CompileOptions['runes'];
css: (options: { filename: string }) => 'injected' | 'external';
runes: (options: { filename: string }) => boolean | undefined;
customElementOptions: AST.SvelteOptions['customElement'];
hmr: CompileOptions['hmr'];
};

@ -36,6 +36,13 @@ export function assignment_pattern(left, right) {
* @returns {ESTree.ArrowFunctionExpression}
*/
export function arrow(params, body, async = false) {
// optimize `async () => await x()`, but not `async () => await x(await y)`
if (async && body.type === 'AwaitExpression') {
if (!has_await_expression(body.argument)) {
return arrow(params, body.argument);
}
}
return {
type: 'ArrowFunctionExpression',
params,
@ -462,13 +469,6 @@ export function thunk(expression, async = false) {
* @returns {ESTree.Expression}
*/
export function unthunk(expression) {
// optimize `async () => await x()`, but not `async () => await x(await y)`
if (expression.async && expression.body.type === 'AwaitExpression') {
if (!has_await_expression(expression.body.argument)) {
return unthunk(arrow(expression.params, expression.body.argument));
}
}
if (
expression.async === false &&
expression.body.type === 'CallExpression' &&

@ -51,24 +51,28 @@ const common_options = {
const component_options = {
accessors: deprecate(w.options_deprecated_accessors, boolean(false)),
css: validator('external', (input) => {
if (input === true || input === false) {
throw_error(
'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true'
);
}
if (input === 'none') {
throw_error(
'css: "none" is no longer a valid option. If this was crucial for you, please open an issue on GitHub with your use case.'
);
}
/** @type {Validator<'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external'), (options: { filename: string }) => 'injected' | 'external'>} */
css: parametric(
/** @type {(options: { filename: string }) => 'injected' | 'external'} */ (() => 'external'),
(input) => {
if (input === true || input === false) {
throw_error(
'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true'
);
}
if (input === 'none') {
throw_error(
'css: "none" is no longer a valid option. If this was crucial for you, please open an issue on GitHub with your use case.'
);
}
if (input !== 'external' && input !== 'injected') {
throw_error(`css should be either "external" (default, recommended) or "injected"`);
}
if (input !== 'external' && input !== 'injected') {
throw_error(`css should be either "external" (default, recommended) or "injected"`);
}
return input;
}),
return /** @type {'external' | 'injected'} */ (input);
}
),
cssHash: fun(({ css, filename, hash }) => {
return `svelte-${hash(filename === '(unknown)' ? css : filename ?? css)}`;
@ -77,7 +81,17 @@ const component_options = {
// TODO this is a sourcemap option, would be good to put under a sourcemap namespace
cssOutputFilename: string(undefined),
customElement: boolean(false),
/** @type {Validator<boolean | ((options: { filename: string }) => boolean), (options: { filename: string }) => boolean>} */
customElement: parametric(
/** @type {(options: { filename: string }) => boolean} */ (() => false),
(input, keypath) => {
if (typeof input !== 'boolean') {
throw_error(`${keypath} should be true or false`);
}
return /** @type {boolean} */ (input);
}
),
discloseVersion: boolean(true),
@ -109,7 +123,8 @@ const component_options = {
preserveWhitespace: boolean(false),
runes: boolean(undefined),
/** @type {Validator<boolean | undefined | (() => boolean | undefined), () => boolean | undefined>} */
runes: parametric(() => /** @type {boolean | undefined} */ (undefined)),
hmr: boolean(false),
@ -320,6 +335,28 @@ function fun(fallback) {
});
}
/**
* @template {(...args: any[]) => any} F
* @param {F} fallback
* @param {(value: unknown, keypath: string) => ReturnType<F>} [normalize]
* @returns {Validator}
*/
function parametric(fallback, normalize = (value) => /** @type {ReturnType<F>} */ (value)) {
return validator(fallback, (input, keypath) => {
if (typeof input === 'function') {
/** @type {(...args: Parameters<F>) => ReturnType<F>} */
const normalized = (...args) => normalize(input(...args), keypath);
return /** @type {F} */ (/** @type {unknown} */ (normalized));
}
/** @type {(...args: Parameters<F>) => ReturnType<F>} */
const normalized = (..._args) => normalize(input, keypath);
return /** @type {F} */ (/** @type {unknown} */ (normalized));
});
}
/** @param {string} msg */
function throw_error(msg) {
e.options_invalid_value(null, msg);

@ -6,6 +6,8 @@ import { block, branch, destroy_effect } from '../reactivity/effects.js';
import { set, source } from '../reactivity/sources.js';
import { set_should_intro } from '../render.js';
import { get } from '../runtime.js';
import { assign_nodes } from '../dom/template.js';
import { create_comment } from '../dom/operations.js';
/**
* @template {(anchor: Comment, props: any) => any} Component
@ -27,6 +29,13 @@ export function hmr(fn) {
let ran = false;
// Surround the wrapped effects with comments and assign the nodes
// on the wrapping effects so the parent can properly do DOM operations.
let start = create_comment();
let end = create_comment();
anchor.before(start);
block(() => {
if (component === (component = get(current))) {
return;
@ -61,6 +70,10 @@ export function hmr(fn) {
anchor = hydrate_node;
}
anchor.before(end);
assign_nodes(start, end);
return instance;
}

@ -480,6 +480,14 @@ function reconcile(state, array, anchor, flags, get_key) {
}
}
if ((effect.f & INERT) !== 0) {
resume_effect(effect);
if (is_animated) {
effect.nodes?.a?.unfix();
(to_animate ??= new Set()).delete(effect);
}
}
if ((effect.f & EFFECT_OFFSCREEN) !== 0) {
effect.f ^= EFFECT_OFFSCREEN;
@ -508,14 +516,6 @@ function reconcile(state, array, anchor, flags, get_key) {
}
}
if ((effect.f & INERT) !== 0) {
resume_effect(effect);
if (is_animated) {
effect.nodes?.a?.unfix();
(to_animate ??= new Set()).delete(effect);
}
}
if (effect !== current) {
if (seen !== undefined && seen.has(effect)) {
if (matched.length < stashed.length) {

@ -19,10 +19,10 @@ import {
import { Batch, current_batch } from './batch.js';
import {
async_derived,
current_async_effect,
reactivity_loss_tracker,
derived,
derived_safe_equal,
set_from_async_derived
set_reactivity_loss_tracker
} from './deriveds.js';
import { aborted } from './effects.js';
@ -131,7 +131,7 @@ export function capture() {
}
if (DEV) {
set_from_async_derived(null);
set_reactivity_loss_tracker(null);
set_dev_stack(previous_dev_stack);
}
};
@ -163,11 +163,11 @@ export async function save(promise) {
* @returns {Promise<() => T>}
*/
export async function track_reactivity_loss(promise) {
var previous_async_effect = current_async_effect;
var previous_async_effect = reactivity_loss_tracker;
var value = await promise;
return () => {
set_from_async_derived(previous_async_effect);
set_reactivity_loss_tracker(previous_async_effect);
return value;
};
}
@ -224,7 +224,7 @@ export function unset_context(deactivate_batch = true) {
if (deactivate_batch) current_batch?.deactivate();
if (DEV) {
set_from_async_derived(null);
set_reactivity_loss_tracker(null);
set_dev_stack(null);
}
}
@ -307,15 +307,16 @@ export function wait(blockers) {
* @returns {(skip?: boolean) => void}
*/
export function increment_pending() {
var boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b);
var effect = /** @type {Effect} */ (active_effect);
var boundary = /** @type {Boundary} */ (effect.b);
var batch = /** @type {Batch} */ (current_batch);
var blocking = boundary.is_rendered();
boundary.update_pending_count(1, batch);
batch.increment(blocking);
batch.increment(blocking, effect);
return (skip = false) => {
boundary.update_pending_count(-1, batch);
batch.decrement(blocking, skip);
batch.decrement(blocking, effect, skip);
};
}

@ -90,20 +90,20 @@ var source_stacks = DEV ? new Set() : null;
let uid = 1;
export class Batch {
// for debugging. TODO remove once async is stable
id = uid++;
/**
* The current values of any sources that are updated in this batch
* The current values of any signals that are updated in this batch.
* Tuple format: [value, is_derived] (note: is_derived is false for deriveds, too, if they were overridden via assignment)
* They keys of this map are identical to `this.#previous`
* @type {Map<Source, any>}
* @type {Map<Value, [any, boolean]>}
*/
current = new Map();
/**
* The values of any sources that are updated in this batch _before_ those updates took place.
* The values of any signals (sources and deriveds) that are updated in this batch _before_ those updates took place.
* They keys of this map are identical to `this.#current`
* @type {Map<Source, any>}
* @type {Map<Value, any>}
*/
previous = new Map();
@ -121,14 +121,16 @@ export class Batch {
#discard_callbacks = new Set();
/**
* The number of async effects that are currently in flight
* Async effects that are currently in flight
* @type {Map<Effect, number>}
*/
#pending = 0;
#pending = new Map();
/**
* The number of async effects that are currently in flight, _not_ inside a pending boundary
* Async effects that are currently in flight, _not_ inside a pending boundary
* @type {Map<Effect, number>}
*/
#blocking_pending = 0;
#blocking_pending = new Map();
/**
* A deferred that resolves when the batch is committed, used with `settled()`
@ -168,8 +170,35 @@ export class Batch {
#decrement_queued = false;
/** @type {Set<Batch>} */
#blockers = new Set();
#is_deferred() {
return this.is_fork || this.#blocking_pending > 0;
return this.is_fork || this.#blocking_pending.size > 0;
}
#is_blocked() {
for (const batch of this.#blockers) {
for (const effect of batch.#blocking_pending.keys()) {
var skipped = false;
var e = effect;
while (e.parent !== null) {
if (this.#skipped_branches.has(e)) {
skipped = true;
break;
}
e = e.parent;
}
if (!skipped) {
return true;
}
}
}
return false;
}
/**
@ -264,7 +293,7 @@ export class Batch {
collected_effects = null;
legacy_updates = null;
if (this.#is_deferred()) {
if (this.#is_deferred() || this.#is_blocked()) {
this.#defer_effects(render_effects);
this.#defer_effects(effects);
@ -272,7 +301,7 @@ export class Batch {
reset_branch(e, t);
}
} else {
if (this.#pending === 0) {
if (this.#pending.size === 0) {
batches.delete(this);
}
@ -383,17 +412,18 @@ export class Batch {
/**
* Associate a change to a given source with the current
* batch, noting its previous and current values
* @param {Source} source
* @param {Value} source
* @param {any} old_value
* @param {boolean} [is_derived]
*/
capture(source, old_value) {
capture(source, old_value, is_derived = false) {
if (old_value !== UNINITIALIZED && !this.previous.has(source)) {
this.previous.set(source, old_value);
}
// Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get`
if ((source.f & ERROR_VALUE) === 0) {
this.current.set(source, source.v);
this.current.set(source, [source.v, is_derived]);
batch_values?.set(source, source.v);
}
}
@ -453,11 +483,13 @@ export class Batch {
/** @type {Source[]} */
var sources = [];
for (const [source, value] of this.current) {
for (const [source, [value, is_derived]] of this.current) {
if (batch.current.has(source)) {
if (is_earlier && value !== batch.current.get(source)) {
var batch_value = /** @type {[any, boolean]} */ (batch.current.get(source))[0]; // faster than destructuring
if (is_earlier && value !== batch_value) {
// bring the value up to date
batch.current.set(source, value);
batch.current.set(source, [value, is_derived]);
} else {
// same value or later batch has more recent value,
// no need to re-run these effects
@ -507,24 +539,56 @@ export class Batch {
batch.deactivate();
}
}
for (const batch of batches) {
if (batch.#blockers.has(this)) {
batch.#blockers.delete(this);
if (batch.#blockers.size === 0 && !batch.#is_deferred()) {
batch.activate();
batch.#process();
}
}
}
}
/**
*
* @param {boolean} blocking
* @param {Effect} effect
*/
increment(blocking) {
this.#pending += 1;
if (blocking) this.#blocking_pending += 1;
increment(blocking, effect) {
let pending_count = this.#pending.get(effect) ?? 0;
this.#pending.set(effect, pending_count + 1);
if (blocking) {
let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0;
this.#blocking_pending.set(effect, blocking_pending_count + 1);
}
}
/**
* @param {boolean} blocking
* @param {Effect} effect
* @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction)
*/
decrement(blocking, skip) {
this.#pending -= 1;
if (blocking) this.#blocking_pending -= 1;
decrement(blocking, effect, skip) {
let pending_count = this.#pending.get(effect) ?? 0;
if (pending_count === 1) {
this.#pending.delete(effect);
} else {
this.#pending.set(effect, pending_count - 1);
}
if (blocking) {
let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0;
if (blocking_pending_count === 1) {
this.#blocking_pending.delete(effect);
} else {
this.#blocking_pending.set(effect, blocking_pending_count - 1);
}
}
if (this.#decrement_queued || skip) return;
this.#decrement_queued = true;
@ -597,15 +661,37 @@ export class Batch {
// if there are multiple batches, we are 'time travelling' —
// we need to override values with the ones in this batch...
batch_values = new Map(this.current);
batch_values = new Map();
for (const [source, [value]] of this.current) {
batch_values.set(source, value);
}
// ...and undo changes belonging to other batches
// ...and undo changes belonging to other batches unless they block this one
for (const batch of batches) {
if (batch === this || batch.is_fork) continue;
for (const [source, previous] of batch.previous) {
if (!batch_values.has(source)) {
batch_values.set(source, previous);
// A batch is blocked on an earlier batch if it overlaps with the earlier batch's changes but is not a superset
var intersects = false;
var differs = false;
if (batch.id < this.id) {
for (const [source, [, is_derived]] of batch.current) {
// Derived values don't partake in the blocking mechanism, because a derived could
// be triggered in one batch already but not the other one yet, causing a false-positive
if (is_derived) continue;
intersects ||= this.current.has(source);
differs ||= !this.current.has(source);
}
}
if (intersects && differs) {
this.#blockers.add(batch);
} else {
for (const [source, previous] of batch.previous) {
if (!batch_values.has(source)) {
batch_values.set(source, previous);
}
}
}
}
@ -1065,7 +1151,7 @@ export function fork(fn) {
batch.is_fork = false;
// apply changes and update write versions so deriveds see the change
for (var [source, value] of batch.current) {
for (var [source, [value]] of batch.current) {
source.v = value;
source.wv = increment_write_version();
}

@ -45,12 +45,16 @@ import { increment_pending, unset_context } from './async.js';
import { deferred, includes, noop } from '../../shared/utils.js';
import { set_signal_status, update_derived_status } from './status.js';
/** @type {Effect | null} */
export let current_async_effect = null;
/**
* This allows us to track 'reactivity loss' that occurs when signals
* are read after a non-context-restoring `await`. Dev-only
* @type {{ effect: Effect, warned: boolean } | null}
*/
export let reactivity_loss_tracker = null;
/** @param {Effect | null} v */
export function set_from_async_derived(v) {
current_async_effect = v;
/** @param {{ effect: Effect, warned: boolean } | null} v */
export function set_reactivity_loss_tracker(v) {
reactivity_loss_tracker = v;
}
export const recent_async_deriveds = new Set();
@ -124,7 +128,12 @@ export function async_derived(fn, label, location) {
var deferreds = new Map();
async_effect(() => {
if (DEV) current_async_effect = active_effect;
if (DEV) {
reactivity_loss_tracker = {
effect: /** @type {Effect} */ (active_effect),
warned: false
};
}
var effect = /** @type {Effect} */ (active_effect);
@ -142,7 +151,9 @@ export function async_derived(fn, label, location) {
unset_context();
}
if (DEV) current_async_effect = null;
if (DEV) {
reactivity_loss_tracker = null;
}
var batch = /** @type {Batch} */ (current_batch);
@ -174,7 +185,9 @@ export function async_derived(fn, label, location) {
* @param {unknown} error
*/
const handler = (value, error = undefined) => {
if (DEV) current_async_effect = null;
if (DEV) {
reactivity_loss_tracker = null;
}
if (decrement_pending) {
// don't trigger an update if we're only here because
@ -383,7 +396,7 @@ export function update_derived(derived) {
// change, `derived.equals` may incorrectly return `true`
if (!current_batch?.is_fork || derived.deps === null) {
derived.v = value;
current_batch?.capture(derived, old_value);
current_batch?.capture(derived, old_value, true);
// deriveds without dependencies should never be recomputed
if (derived.deps === null) {

@ -27,7 +27,7 @@ import {
} from './constants.js';
import { old_values } from './reactivity/sources.js';
import {
destroy_derived_effects,
reactivity_loss_tracker,
execute_derived,
freeze_derived_effects,
recent_async_deriveds,
@ -58,6 +58,7 @@ import { UNINITIALIZED } from '../../constants.js';
import { captured_signals } from './legacy.js';
import { without_reactive_context } from './dom/elements/bindings/shared.js';
import { set_signal_status, update_derived_status } from './reactivity/status.js';
import * as w from './warnings.js';
let is_updating_effect = false;
@ -568,19 +569,20 @@ export function get(signal) {
}
if (DEV) {
// TODO reinstate this, but make it actually work
// if (current_async_effect) {
// var tracking = (current_async_effect.f & REACTION_IS_UPDATING) !== 0;
// var was_read = current_async_effect.deps?.includes(signal);
if (
!untracking &&
reactivity_loss_tracker &&
!reactivity_loss_tracker.warned &&
(reactivity_loss_tracker.effect.f & REACTION_IS_UPDATING) === 0
) {
reactivity_loss_tracker.warned = true;
// if (!tracking && !untracking && !was_read) {
// w.await_reactivity_loss(/** @type {string} */ (signal.label));
w.await_reactivity_loss(/** @type {string} */ (signal.label));
// var trace = get_error('traced at');
// // eslint-disable-next-line no-console
// if (trace) console.warn(trace);
// }
// }
var trace = get_error('traced at');
// eslint-disable-next-line no-console
if (trace) console.warn(trace);
}
recent_async_deriveds.delete(signal);
@ -595,7 +597,7 @@ export function get(signal) {
if (signal.trace) {
signal.trace();
} else {
var trace = get_error('traced at');
trace = get_error('traced at');
if (trace) {
var entry = tracing_expressions.entries.get(signal);

@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
export const VERSION = '5.53.12';
export const VERSION = '5.54.0';
export const PUBLIC_VERSION = '5';

@ -0,0 +1,29 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [a, b, resolve] = target.querySelectorAll('button');
a.click();
await tick();
b.click();
await tick();
resolve.click();
await tick();
resolve.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>a</button>
<button>b</button>
<button>resolve</button>
hi
1
`
);
}
});

@ -0,0 +1,21 @@
<script>
let a = $state(0);
let b = $state(0);
let a_b = $derived(a * b);
const queued = [];
function push(value) {
if (!value) return value;
return new Promise(resolve => {
queued.push(() => resolve(value));
});
}
</script>
<button onclick={() => (a++)}>a</button>
<button onclick={() => (b++)}>b</button>
<button onclick={() => (queued.shift()?.())}>resolve</button>
<!-- a_b called in a block effect before being called in an async effect -->
{#if a_b}hi{/if}
{await push(a_b)}

@ -0,0 +1,32 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const spam = /** @type {HTMLButtonElement} */ (target.querySelector('button.spam'));
const resolve = /** @type {HTMLButtonElement} */ (target.querySelector('button.resolve'));
resolve.click();
await tick();
for (let i = 0; i < 5; i += 1) {
spam.click();
await tick();
}
for (let i = 0; i < 5; i += 1) {
resolve.click();
await tick();
}
assert.equal(target.querySelectorAll('div').length, 1);
assert.htmlEqual(
target.innerHTML,
`
<button class="spam">Spam</button>
<button class="resolve">Resolve</button>
<div>5</div>
`
);
}
});

@ -0,0 +1,28 @@
<script>
let value = $state({ id: '0' });
const resolvers = [];
function wait() {
const promise = Promise.withResolvers();
resolvers.push(promise.resolve);
return promise.promise;
}
function spam() {
value.id = `${Number(value.id) + 1}`;
}
</script>
<button class="spam" onclick={spam}>Spam</button>
<button class="resolve" onclick={() => resolvers.shift()?.()}>Resolve</button>
<svelte:boundary>
{#each [value.id] as s (s)}
{await wait()}
<div>{s}</div>
{/each}
{#snippet pending()}
<p>pending</p>
{/snippet}
</svelte:boundary>

@ -1,102 +0,0 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
skip: true,
async test({ assert, target }) {
const [add, shift] = target.querySelectorAll('button');
add.click();
await tick();
add.click();
await tick();
add.click();
await tick();
// TODO pending count / number of pushes is off
assert.htmlEqual(
target.innerHTML,
`
<button>add</button>
<button>shift</button>
<p>pending=6 values.length=1 values=[1]</p>
<div>not keyed:
<div>1</div>
</div>
<div>keyed:
<div>1</div>
</div>
`
);
shift.click();
await tick();
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>add</button>
<button>shift</button>
<p>pending=4 values.length=2 values=[1,2]</p>
<div>not keyed:
<div>1</div>
<div>2</div>
</div>
<div>keyed:
<div>1</div>
<div>2</div>
</div>
`
);
shift.click();
await tick();
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>add</button>
<button>shift</button>
<p>pending=2 values.length=3 values=[1,2,3]</p>
<div>not keyed:
<div>1</div>
<div>2</div>
<div>3</div>
</div>
<div>keyed:
<div>1</div>
<div>2</div>
<div>3</div>
</div>
`
);
shift.click();
await tick();
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>add</button>
<button>shift</button>
<p>pending=0 values.length=4 values=[1,2,3,4]</p>
<div>not keyed:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
</div>
<div>keyed:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
</div>
`
);
}
});

@ -0,0 +1,29 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [a, b] = target.querySelectorAll('button');
assert.htmlEqual(target.innerHTML, `<button>a 0</button><button>b 0</button><p>hello</p>`);
a.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>a 0</button><button>b 0</button><p>hello</p>`);
a.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>a 2</button><button>b 0</button><p>hello</p>`);
a.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>a 2</button><button>b 0</button><p>hello</p>`);
// if we don't skip over the never-resolving promise in the `else` block, we will never update
b.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>a 3</button><button>b 1</button><p>hello</p>`);
}
});

@ -0,0 +1,14 @@
<script>
let a = $state(0);
let b = $state(0);
let show = $state(true);
</script>
<button onclick={() => (a++, show = !show)}>a {a}</button>
<button onclick={() => (b++, show = !show)}>b {b}</button>
{#if show}
<p>hello</p>
{:else}
{await new Promise(() => {})}
{/if}

@ -0,0 +1,10 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
ssrHtml: 'works',
async test({ assert, target }) {
await tick();
assert.htmlEqual(target.innerHTML, 'works');
}
});

@ -0,0 +1,7 @@
<script lang="ts">
const test = async () => "test";
await test();
$inspect("inspect after await shouldnt break builds");
</script>
works

@ -0,0 +1,9 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
assert.htmlEqual(target.innerHTML, 'aaa 1');
}
});

@ -0,0 +1,10 @@
<script>
let name = $derived(await new Promise((a) => a('aaa')));
function use() {
return () => 1;
}
const aa = use();
</script>
{name}
{aa()}

@ -0,0 +1,30 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [a_b, b, resolve] = target.querySelectorAll('button');
a_b.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>a_b 0_0</button> <button>b 0</button> <button>resolve</button> 0'
);
b.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>a_b 0_0</button> <button>b 0</button> <button>resolve</button> 0'
);
resolve.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>a_b 1_2</button> <button>b 2</button> <button>resolve</button> 1'
);
}
});

@ -0,0 +1,17 @@
<script>
let a = $state(0);
let b = $state(0);
let deferreds = [];
function push(value) {
if (!value) return value;
return new Promise(resolve => {
deferreds.push(() => resolve(value));
});
}
</script>
<button onclick={() => {a++;b++}}>a_b {a}_{b}</button>
<button onclick={() => (b++)}>b {b}</button>
<button onclick={() => (deferreds.shift()?.())}>resolve</button>
{await push(a)}

@ -0,0 +1,205 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [add, shift, pop] = target.querySelectorAll('button');
add.click();
await tick();
add.click();
await tick();
add.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>add</button>
<button>shift</button>
<button>pop</button>
<p>pending=6 values.length=1 values=[1]</p>
<div>not keyed:
<div>1</div>
</div>
<div>keyed:
<div>1</div>
</div>
`
);
shift.click();
await tick();
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>add</button>
<button>shift</button>
<button>pop</button>
<p>pending=4 values.length=2 values=[1,2]</p>
<div>not keyed:
<div>1</div>
<div>2</div>
</div>
<div>keyed:
<div>1</div>
<div>2</div>
</div>
`
);
shift.click();
await tick();
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>add</button>
<button>shift</button>
<button>pop</button>
<p>pending=2 values.length=3 values=[1,2,3]</p>
<div>not keyed:
<div>1</div>
<div>2</div>
<div>3</div>
</div>
<div>keyed:
<div>1</div>
<div>2</div>
<div>3</div>
</div>
`
);
shift.click();
await tick();
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>add</button>
<button>shift</button>
<button>pop</button>
<p>pending=0 values.length=4 values=[1,2,3,4]</p>
<div>not keyed:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
</div>
<div>keyed:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
</div>
`
);
add.click();
await tick();
add.click();
await tick();
add.click();
await tick();
add.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>add</button>
<button>shift</button>
<button>pop</button>
<p>pending=8 values.length=4 values=[1,2,3,4]</p>
<div>not keyed:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
</div>
<div>keyed:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
</div>
`
);
// pop should have no effect until earlier promises have also resolved
pop.click();
await tick();
pop.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>add</button>
<button>shift</button>
<button>pop</button>
<p>pending=6 values.length=4 values=[1,2,3,4]</p>
<div>not keyed:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
</div>
<div>keyed:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
</div>
`
);
pop.click();
await tick();
pop.click();
await tick();
pop.click();
await tick();
pop.click();
await tick();
pop.click();
await tick();
pop.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>add</button>
<button>shift</button>
<button>pop</button>
<p>pending=0 values.length=8 values=[1,2,3,4,5,6,7,8]</p>
<div>not keyed:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
<div>5</div>
<div>6</div>
<div>7</div>
<div>8</div>
</div>
<div>keyed:
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
<div>5</div>
<div>6</div>
<div>7</div>
<div>8</div>
</div>
`
);
}
});

@ -11,18 +11,14 @@
return p.promise;
}
function shift() {
const fn = queue.shift();
if (fn) fn();
}
function addValue() {
values.push(values.length+1);
}
</script>
<button onclick={addValue}>add</button>
<button onclick={shift}>shift</button>
<button onclick={() => queue.shift()?.()}>shift</button>
<button onclick={() => queue.pop()?.()}>pop</button>
<p>
pending={$effect.pending()}

@ -1,10 +1,8 @@
import { tick } from 'svelte';
import { test } from '../../test';
import { normalise_trace_logs } from '../../../helpers.js';
export default test({
// TODO reinstate
skip: true,
compileOptions: {
dev: true
},
@ -15,13 +13,10 @@ export default test({
await tick();
assert.htmlEqual(target.innerHTML, '<button>a</button><button>b</button><h1>3</h1>');
assert.equal(
warnings[0],
'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`'
);
assert.equal(warnings[1].name, 'traced at');
assert.equal(warnings.length, 2);
assert.deepEqual(normalise_trace_logs(warnings), [
{
log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`'
}
]);
}
});

@ -2,9 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
// TODO reinstate this
skip: true,
compileOptions: {
dev: true
},

@ -0,0 +1,5 @@
<script>
let { src } = $props();
</script>
<img {src} />

@ -0,0 +1,7 @@
<script>
let { children } = $props();
</script>
<a href="/">
{@render children()}
</a>

@ -0,0 +1,14 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['hydrate'],
async test({ assert, target }) {
await tick();
assert.htmlEqual(
target.innerHTML,
`<a href="/"><div>card</div> <img src="https://svelte.dev" /></a>`
);
}
});

@ -0,0 +1,11 @@
<script lang="ts">
import Image from "./Image.svelte";
import Link from "./Link.svelte";
let url = $derived(await 'https://svelte.dev');
</script>
<Link>
<div>card</div>
<Image src={url} />
</Link>

@ -6,7 +6,7 @@ var root = $.from_html(`<p> </p>`);
export default function Async_top_level_inspect_server($$anchor) {
var data;
var $$promises = $.run([async () => data = await Promise.resolve(42),,]);
var $$promises = $.run([async () => data = await Promise.resolve(42), () => void 0]);
var p = root();
var text = $.child(p, true);

@ -3,7 +3,7 @@ import * as $ from 'svelte/internal/server';
export default function Async_top_level_inspect_server($$renderer) {
var data;
var $$promises = $$renderer.run([async () => data = await Promise.resolve(42),,]);
var $$promises = $$renderer.run([async () => data = await Promise.resolve(42), () => void 0]);
$$renderer.push(`<p>`);
$$renderer.async([$$promises[1]], ($$renderer) => $$renderer.push(() => $.escape(data)));

@ -32,7 +32,7 @@ export default function Main($$anchor) {
$.set_attribute(div_1, 'foobar', $0);
$.set_attribute(svg_1, 'viewBox', $1);
},
[y, y]
[() => y(), () => y()]
);
$.append($$anchor, fragment);

@ -19,6 +19,6 @@ export default function Text_nodes_deriveds($$anchor) {
var text = $.child(p);
$.reset(p);
$.template_effect(($0, $1) => $.set_text(text, `${$0 ?? ''}${$1 ?? ''}`), [text1, text2]);
$.template_effect(($0, $1) => $.set_text(text, `${$0 ?? ''}${$1 ?? ''}`), [() => text1(), () => text2()]);
$.append($$anchor, p);
}

@ -1030,9 +1030,11 @@ declare module 'svelte/compiler' {
/**
* If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component.
*
* You can also pass a function that receives `{ filename }` and returns a boolean.
*
* @default false
*/
customElement?: boolean;
customElement?: boolean | ((options: { filename: string }) => boolean);
/**
* If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`.
*
@ -1058,8 +1060,10 @@ declare module 'svelte/compiler' {
* - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root.
* - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files.
* This is always `'injected'` when compiling with `customElement` mode.
*
* You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`.
*/
css?: 'injected' | 'external';
css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external');
/**
* A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS.
* It defaults to returning `svelte-${hash(filename ?? css)}`.
@ -1103,7 +1107,7 @@ declare module 'svelte/compiler' {
* which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead.
* @default undefined
*/
runes?: boolean | undefined;
runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined);
/**
* If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`.
*
@ -3017,9 +3021,11 @@ declare module 'svelte/types/compiler/interfaces' {
/**
* If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component.
*
* You can also pass a function that receives `{ filename }` and returns a boolean.
*
* @default false
*/
customElement?: boolean;
customElement?: boolean | ((options: { filename: string }) => boolean);
/**
* If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`.
*
@ -3045,8 +3051,10 @@ declare module 'svelte/types/compiler/interfaces' {
* - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root.
* - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files.
* This is always `'injected'` when compiling with `customElement` mode.
*
* You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`.
*/
css?: 'injected' | 'external';
css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external');
/**
* A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS.
* It defaults to returning `svelte-${hash(filename ?? css)}`.
@ -3090,7 +3098,7 @@ declare module 'svelte/types/compiler/interfaces' {
* which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead.
* @default undefined
*/
runes?: boolean | undefined;
runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined);
/**
* If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`.
*

Loading…
Cancel
Save