each-block-pending
Rich Harris 2 days ago
commit 68fa3462dd

@ -1,5 +1,6 @@
---
title: Best practices
skill: true
name: svelte-core-bestpractices
description: Guidance on writing fast, robust, modern Svelte code. Load this skill whenever in a Svelte project and asked to write/edit or analyze a Svelte component or module. Covers reactivity, event handling, styling, integration with libraries and more.
---

@ -324,7 +324,7 @@ When spreading props, local event handlers must go _after_ the spread, or they r
>
> It was always possible to use component callback props, but because you had to listen to DOM events using `on:`, it made sense to use `createEventDispatcher` for component events due to syntactical consistency. Now that we have event attributes (`onclick`), it's the other way around: Callback props are now the more sensible thing to do.
>
> The removal of event modifiers is arguably one of the changes that seems like a step back for those who've liked the shorthand syntax of event modifiers. Given that they are not used that frequently, we traded a smaller surface area for more explicitness. Modifiers also were inconsistent, because most of them were only useable on DOM elements.
> The removal of event modifiers is arguably one of the changes that seems like a step back for those who've liked the shorthand syntax of event modifiers. Given that they are not used that frequently, we traded a smaller surface area for more explicitness. Modifiers also were inconsistent, because most of them were only usable on DOM elements.
>
> Multiple listeners for the same event are also no longer possible, but it was something of an anti-pattern anyway, since it impedes readability: if there are many attributes, it becomes harder to spot that there are two handlers unless they are right next to each other. It also implies that the two handlers are independent, when in fact something like `event.stopImmediatePropagation()` inside `one` would prevent `two` from being called.
>

@ -1,5 +1,43 @@
# svelte
## 5.53.9
### Patch Changes
- fix: better `bind:this` cleanup timing ([#17885](https://github.com/sveltejs/svelte/pull/17885))
## 5.53.8
### Patch Changes
- fix: `{@html}` no longer duplicates content inside `contenteditable` elements ([#17853](https://github.com/sveltejs/svelte/pull/17853))
- fix: don't access inert block effects ([#17882](https://github.com/sveltejs/svelte/pull/17882))
- fix: handle asnyc updates within pending boundary ([#17873](https://github.com/sveltejs/svelte/pull/17873))
- perf: avoid re-traversing the effect tree after `$:` assignments ([#17848](https://github.com/sveltejs/svelte/pull/17848))
- chore: simplify scheduling logic ([#17805](https://github.com/sveltejs/svelte/pull/17805))
## 5.53.7
### Patch Changes
- fix: correctly add \_\_svelte_meta after else-if chains ([#17830](https://github.com/sveltejs/svelte/pull/17830))
- perf: cache element interactivity and source line splitting in compiler ([#17839](https://github.com/sveltejs/svelte/pull/17839))
- chore: avoid rescheduling effects during branch commit ([#17837](https://github.com/sveltejs/svelte/pull/17837))
- perf: optimize CSS selector pruning ([#17846](https://github.com/sveltejs/svelte/pull/17846))
- fix: preserve original boundary errors when keyed each rows are removed during async updates ([#17843](https://github.com/sveltejs/svelte/pull/17843))
- perf: avoid O(n²) name scanning in scope `generate` and `unique` ([#17844](https://github.com/sveltejs/svelte/pull/17844))
- fix: preserve each items that are needed by pending batches ([#17819](https://github.com/sveltejs/svelte/pull/17819))
## 5.53.6
### Patch Changes

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

@ -236,16 +236,36 @@ function truncate(node) {
* @param {Compiler.AST.CSS.Rule} rule
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
* @param {Direction} direction
* @param {number} [from]
* @param {number} [to]
* @returns {boolean}
*/
function apply_selector(relative_selectors, rule, element, direction) {
const rest_selectors = relative_selectors.slice();
const relative_selector = direction === FORWARD ? rest_selectors.shift() : rest_selectors.pop();
function apply_selector(
relative_selectors,
rule,
element,
direction,
from = 0,
to = relative_selectors.length
) {
if (from >= to) return false;
const selector_index = direction === FORWARD ? from : to - 1;
const relative_selector = relative_selectors[selector_index];
const rest_from = direction === FORWARD ? from + 1 : from;
const rest_to = direction === FORWARD ? to : to - 1;
const matched =
!!relative_selector &&
relative_selector_might_apply_to_node(relative_selector, rule, element, direction) &&
apply_combinator(relative_selector, rest_selectors, rule, element, direction);
apply_combinator(
relative_selector,
relative_selectors,
rest_from,
rest_to,
rule,
element,
direction
);
if (matched) {
if (!is_outer_global(relative_selector)) {
@ -260,15 +280,21 @@ function apply_selector(relative_selectors, rule, element, direction) {
/**
* @param {Compiler.AST.CSS.RelativeSelector} relative_selector
* @param {Compiler.AST.CSS.RelativeSelector[]} rest_selectors
* @param {Compiler.AST.CSS.RelativeSelector[]} relative_selectors
* @param {number} from
* @param {number} to
* @param {Compiler.AST.CSS.Rule} rule
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement | Compiler.AST.RenderTag | Compiler.AST.Component | Compiler.AST.SvelteComponent | Compiler.AST.SvelteSelf} node
* @param {Direction} direction
* @returns {boolean}
*/
function apply_combinator(relative_selector, rest_selectors, rule, node, direction) {
function apply_combinator(relative_selector, relative_selectors, from, to, rule, node, direction) {
const combinator =
direction == FORWARD ? rest_selectors[0]?.combinator : relative_selector.combinator;
direction == FORWARD
? from < to
? relative_selectors[from].combinator
: undefined
: relative_selector.combinator;
if (!combinator) return true;
switch (combinator.name) {
@ -282,7 +308,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
let parent_matched = false;
for (const parent of parents) {
if (apply_selector(rest_selectors, rule, parent, direction)) {
if (apply_selector(relative_selectors, rule, parent, direction, from, to)) {
parent_matched = true;
}
}
@ -291,7 +317,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
parent_matched ||
(direction === BACKWARD &&
(!is_adjacent || parents.length === 0) &&
rest_selectors.every((selector) => is_global(selector, rule)))
every_is_global(relative_selectors, from, to, rule))
);
}
@ -308,10 +334,12 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
possible_sibling.type === 'Component'
) {
// `{@render foo()}<p>foo</p>` with `:global(.x) + p` is a match
if (rest_selectors.length === 1 && rest_selectors[0].metadata.is_global) {
if (to - from === 1 && relative_selectors[from].metadata.is_global) {
sibling_matched = true;
}
} else if (apply_selector(rest_selectors, rule, possible_sibling, direction)) {
} else if (
apply_selector(relative_selectors, rule, possible_sibling, direction, from, to)
) {
sibling_matched = true;
}
}
@ -320,7 +348,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
sibling_matched ||
(direction === BACKWARD &&
get_element_parent(node) === null &&
rest_selectors.every((selector) => is_global(selector, rule)))
every_is_global(relative_selectors, from, to, rule))
);
}
@ -330,6 +358,20 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
}
}
/**
* @param {Compiler.AST.CSS.RelativeSelector[]} relative_selectors
* @param {number} from
* @param {number} to
* @param {Compiler.AST.CSS.Rule} rule
* @returns {boolean}
*/
function every_is_global(relative_selectors, from, to, rule) {
for (let i = from; i < to; i++) {
if (!is_global(relative_selectors[i], rule)) return false;
}
return true;
}
/**
* Returns `true` if the relative selector is global, meaning
* it's a `:global(...)` or unscopeable selector, or
@ -392,42 +434,37 @@ const regex_backslash_and_following_character = /\\(.)/g;
* @returns {boolean}
*/
function relative_selector_might_apply_to_node(relative_selector, rule, element, direction) {
// Sort :has(...) selectors in one bucket and everything else into another
const has_selectors = [];
const other_selectors = [];
/** @type {boolean | undefined} */
let include_self;
for (const selector of relative_selector.selectors) {
// Handle :has(...) selectors inline to avoid allocating temporary arrays
if (selector.type === 'PseudoClassSelector' && selector.name === 'has' && selector.args) {
has_selectors.push(selector);
} else {
other_selectors.push(selector);
}
}
// If we're called recursively from a :has(...) selector, we're on the way of checking if the other selectors match.
// In that case ignore this check (because we just came from this) to avoid an infinite loop.
if (has_selectors.length > 0) {
// If this is a :has inside a global selector, we gotta include the element itself, too,
// because the global selector might be for an element that's outside the component,
// e.g. :root:has(.scoped), :global(.foo):has(.scoped), or :root { &:has(.scoped) {} }
const rules = get_parent_rules(rule);
const include_self =
rules.some((r) => r.prelude.children.some((c) => c.children.some((s) => is_global(s, r)))) ||
rules[rules.length - 1].prelude.children.some((c) =>
c.children.some((r) =>
r.selectors.some(
(s) =>
s.type === 'PseudoClassSelector' &&
(s.name === 'root' || (s.name === 'global' && s.args))
)
)
);
// Lazy-compute include_self on first :has encounter
if (include_self === undefined) {
// If this is a :has inside a global selector, we gotta include the element itself, too,
// because the global selector might be for an element that's outside the component,
// e.g. :root:has(.scoped), :global(.foo):has(.scoped), or :root { &:has(.scoped) {} }
const rules = get_parent_rules(rule);
include_self =
rules.some((r) =>
r.prelude.children.some((c) => c.children.some((s) => is_global(s, r)))
) ||
rules[rules.length - 1].prelude.children.some((c) =>
c.children.some((r) =>
r.selectors.some(
(s) =>
s.type === 'PseudoClassSelector' &&
(s.name === 'root' || (s.name === 'global' && s.args))
)
)
);
}
// :has(...) is special in that it means "look downwards in the CSS tree". Since our matching algorithm goes
// upwards and back-to-front, we need to first check the selectors inside :has(...), then check the rest of the
// selector in a way that is similar to ancestor matching. In a sense, we're treating `.x:has(.y)` as `.x .y`.
for (const has_selector of has_selectors) {
const complex_selectors = /** @type {Compiler.AST.CSS.SelectorList} */ (has_selector.args)
// :has(...) is special in that it means "look downwards in the CSS tree". Since our matching algorithm goes
// upwards and back-to-front, we need to first check the selectors inside :has(...), then check the rest of the
// selector in a way that is similar to ancestor matching. In a sense, we're treating `.x:has(.y)` as `.x .y`.
const complex_selectors = /** @type {Compiler.AST.CSS.SelectorList} */ (selector.args)
.children;
let matched = false;
@ -465,13 +502,15 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
if (!matched) {
return false;
}
continue;
}
}
for (const selector of other_selectors) {
if (selector.type === 'Percentage' || selector.type === 'Nth') continue;
const name = selector.name.replace(regex_backslash_and_following_character, '$1');
const name = selector.name.includes('\\')
? selector.name.replace(regex_backslash_and_following_character, '$1')
: selector.name;
switch (selector.type) {
case 'PseudoClassSelector': {
@ -672,11 +711,11 @@ function test_attribute(operator, expected_value, case_insensitive, value) {
* @param {boolean} case_insensitive
*/
function attribute_matches(node, name, expected_value, operator, case_insensitive) {
const name_lower = name.toLowerCase();
for (const attribute of node.attributes) {
if (attribute.type === 'SpreadAttribute') return true;
if (attribute.type === 'BindDirective' && attribute.name === name) return true;
const name_lower = name.toLowerCase();
// match attributes against the corresponding directive but bail out on exact matching
if (attribute.type === 'StyleDirective' && name_lower === 'style') return true;
if (attribute.type === 'ClassDirective' && name_lower === 'class') {

@ -100,6 +100,11 @@ export function check_element(node, context) {
}
}
const interactivity = element_interactivity(node.name, attribute_map);
const is_interactive = interactivity === ElementInteractivity.Interactive;
const is_non_interactive = interactivity === ElementInteractivity.NonInteractive;
const is_static = interactivity === ElementInteractivity.Static;
for (const attribute of node.attributes) {
if (attribute.type !== 'Attribute') continue;
@ -133,7 +138,7 @@ export function check_element(node, context) {
if (
name === 'aria-activedescendant' &&
!is_dynamic_element &&
!is_interactive_element(node.name, attribute_map) &&
!is_interactive &&
!attribute_map.has('tabindex') &&
!has_spread
) {
@ -215,7 +220,7 @@ export function check_element(node, context) {
!is_hidden_from_screen_reader(node.name, attribute_map) &&
!is_presentation_role(current_role) &&
is_interactive_roles(current_role) &&
is_static_element(node.name, attribute_map) &&
is_static &&
!attribute_map.get('tabindex')
) {
const has_interactive_handlers = [...handlers].some((handler) =>
@ -229,7 +234,7 @@ export function check_element(node, context) {
// no-interactive-element-to-noninteractive-role
if (
!has_spread &&
is_interactive_element(node.name, attribute_map) &&
is_interactive &&
(is_non_interactive_roles(current_role) || is_presentation_role(current_role))
) {
w.a11y_no_interactive_element_to_noninteractive_role(node, node.name, current_role);
@ -238,7 +243,7 @@ export function check_element(node, context) {
// no-noninteractive-element-to-interactive-role
if (
!has_spread &&
is_non_interactive_element(node.name, attribute_map) &&
is_non_interactive &&
is_interactive_roles(current_role) &&
!a11y_non_interactive_element_to_interactive_role_exceptions[node.name]?.includes(
current_role
@ -291,7 +296,7 @@ export function check_element(node, context) {
!is_dynamic_element &&
!is_hidden_from_screen_reader(node.name, attribute_map) &&
(!role || is_non_presentation_role) &&
!is_interactive_element(node.name, attribute_map) &&
!is_interactive &&
!has_spread
) {
const has_key_event =
@ -307,11 +312,7 @@ export function check_element(node, context) {
);
// no-noninteractive-tabindex
if (
!is_dynamic_element &&
!is_interactive_element(node.name, attribute_map) &&
!is_interactive_roles(role_static_value)
) {
if (!is_dynamic_element && !is_interactive && !is_interactive_roles(role_static_value)) {
const tab_index = attribute_map.get('tabindex');
const tab_index_value = get_static_text_value(tab_index);
if (tab_index && (tab_index_value === null || Number(tab_index_value) >= 0)) {
@ -341,9 +342,8 @@ export function check_element(node, context) {
!has_contenteditable_attr &&
!is_hidden_from_screen_reader(node.name, attribute_map) &&
!is_presentation_role(role_static_value) &&
((!is_interactive_element(node.name, attribute_map) &&
is_non_interactive_roles(role_static_value)) ||
(is_non_interactive_element(node.name, attribute_map) && !role))
((!is_interactive && is_non_interactive_roles(role_static_value)) ||
(is_non_interactive && !role))
) {
const has_interactive_handlers = [...handlers].some((handler) =>
a11y_recommended_interactive_handlers.includes(handler)
@ -359,9 +359,9 @@ export function check_element(node, context) {
(!role || role_static_value !== null) &&
!is_hidden_from_screen_reader(node.name, attribute_map) &&
!is_presentation_role(role_static_value) &&
!is_interactive_element(node.name, attribute_map) &&
!is_interactive &&
!is_interactive_roles(role_static_value) &&
!is_non_interactive_element(node.name, attribute_map) &&
!is_non_interactive &&
!is_non_interactive_roles(role_static_value) &&
!is_abstract_role(role_static_value)
) {
@ -643,33 +643,6 @@ function element_interactivity(tag_name, attribute_map) {
return ElementInteractivity.Static;
}
/**
* @param {string} tag_name
* @param {Map<string, AST.Attribute>} attribute_map
* @returns {boolean}
*/
function is_interactive_element(tag_name, attribute_map) {
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Interactive;
}
/**
* @param {string} tag_name
* @param {Map<string, AST.Attribute>} attribute_map
* @returns {boolean}
*/
function is_non_interactive_element(tag_name, attribute_map) {
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.NonInteractive;
}
/**
* @param {string} tag_name
* @param {Map<string, AST.Attribute>} attribute_map
* @returns {boolean}
*/
function is_static_element(tag_name, attribute_map) {
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Static;
}
/**
* @param {ARIARoleDefinitionKey} role
* @param {string} tag_name

@ -9,7 +9,11 @@ import { build_expression } from './shared/utils.js';
* @param {ComponentContext} context
*/
export function HtmlTag(node, context) {
context.state.template.push_comment();
const is_controlled = node.metadata.is_controlled;
if (!is_controlled) {
context.state.template.push_comment();
}
const has_await = node.metadata.expression.has_await;
const has_blockers = node.metadata.expression.has_blockers();
@ -17,14 +21,17 @@ export function HtmlTag(node, context) {
const expression = build_expression(context, node.expression, node.metadata.expression);
const html = has_await ? b.call('$.get', b.id('$$html')) : expression;
const is_svg = context.state.metadata.namespace === 'svg';
const is_mathml = context.state.metadata.namespace === 'mathml';
// When is_controlled, the parent node already provides the correct namespace,
// so is_svg/is_mathml are only needed for the non-controlled path's wrapper element
const is_svg = !is_controlled && context.state.metadata.namespace === 'svg';
const is_mathml = !is_controlled && context.state.metadata.namespace === 'mathml';
const statement = b.stmt(
b.call(
'$.html',
context.state.node,
b.thunk(html),
is_controlled && b.true,
is_svg && b.true,
is_mathml && b.true,
is_ignored(node, 'hydration_html_changed') && b.true

@ -51,7 +51,7 @@ export function IfBlock(node, context) {
}
}
const render_call = b.stmt(b.call('$$render', consequent_id, index > 0 && b.literal(index)));
const render_call = b.stmt(b.call('$$render', consequent_id, index !== 0 && b.literal(index)));
const new_if = b.if(test, render_call);
if (last_if) {
@ -71,7 +71,7 @@ export function IfBlock(node, context) {
const alternate_id = b.id(context.state.scope.generate('alternate'));
statements.push(b.var(alternate_id, b.arrow([b.id('$$anchor')], alternate)));
last_if.alternate = b.stmt(b.call('$$render', alternate_id, b.literal(false)));
last_if.alternate = b.stmt(b.call('$$render', alternate_id, b.literal(-1)));
}
// Build $.if() arguments

@ -109,6 +109,8 @@ export function process_children(nodes, initial, is_element, context) {
!node.metadata.expression.is_async()
) {
node.metadata.is_controlled = true;
} else if (node.type === 'HtmlTag' && nodes.length === 1 && is_element) {
node.metadata.is_controlled = true;
} else {
const id = flush_node(
false,

@ -1,8 +1,8 @@
/** @import { BlockStatement, Expression, IfStatement, Statement } from 'estree' */
/** @import { BlockStatement, Expression, IfStatement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders';
import { block_close, block_open, block_open_else, create_child_block } from './shared/utils.js';
import { block_close, create_child_block } from './shared/utils.js';
/**
* @param {AST.IfBlock} node
@ -10,7 +10,7 @@ import { block_close, block_open, block_open_else, create_child_block } from './
*/
export function IfBlock(node, context) {
const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent));
consequent.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), block_open)));
consequent.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), b.literal(`<!--[0-->`))));
/** @type {IfStatement} */
let if_statement = b.if(/** @type {Expression} */ (context.visit(node.test)), consequent);
@ -34,7 +34,7 @@ export function IfBlock(node, context) {
// Handle final else (or remaining async chain)
const final_alternate = alt ? /** @type {BlockStatement} */ (context.visit(alt)) : b.block([]);
final_alternate.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), block_open_else)));
final_alternate.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), b.literal(`<!--[-1-->`))));
current_if.alternate = final_alternate;
context.state.template.push(

@ -713,8 +713,18 @@ export class Scope {
}
preferred_name = preferred_name.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^[0-9]/, '_');
let name = preferred_name;
let n = 1;
// Use cached counter to skip names already known to be taken (avoids O(n²) scanning)
let n = this.root.next_counter(preferred_name);
let name;
if (n === 0) {
name = preferred_name;
n = 1;
} else {
name = `${preferred_name}_${n}`;
n++;
}
while (
this.references.has(name) ||
@ -725,6 +735,7 @@ export class Scope {
name = `${preferred_name}_${n++}`;
}
this.root.set_counter(preferred_name, n);
this.references.set(name, []);
this.root.conflicts.add(name);
return name;
@ -852,18 +863,49 @@ export class ScopeRoot {
/** @type {Set<string>} */
conflicts = new Set();
/**
* Tracks the next suffix counter per name to avoid O(n) rescanning in generate/unique.
* @type {Map<string, number>}
*/
#name_counters = new Map();
/**
* @param {string} name
* @returns {number}
*/
next_counter(name) {
return this.#name_counters.get(name) ?? 0;
}
/**
* @param {string} name
* @param {number} value
*/
set_counter(name, value) {
this.#name_counters.set(name, value);
}
/**
* @param {string} preferred_name
*/
unique(preferred_name) {
preferred_name = preferred_name.replace(/[^a-zA-Z0-9_$]/g, '_');
let final_name = preferred_name;
let n = 1;
let n = this.#name_counters.get(preferred_name) ?? 0;
let final_name;
if (n === 0) {
final_name = preferred_name;
n = 1;
} else {
final_name = `${preferred_name}_${n}`;
n++;
}
while (this.conflicts.has(final_name)) {
final_name = `${preferred_name}_${n++}`;
}
this.#name_counters.set(preferred_name, n);
this.conflicts.add(final_name);
const id = b.id(final_name);
return id;

@ -32,6 +32,12 @@ export let component_name = '<unknown>';
*/
export let source;
/**
* The source code split into lines (set by `set_source`)
* @type {string[]}
*/
export let source_lines = [];
/**
* True if compiling with `dev: true`
* @type {boolean}
@ -46,6 +52,7 @@ export let locator;
/** @param {string} value */
export function set_source(value) {
source = value;
source_lines = source.split('\n');
const l = getLocator(source, { offsetLine: 1 });
@ -134,6 +141,7 @@ export function reset(state) {
runes = false;
component_name = UNKNOWN_FILENAME;
source = '';
source_lines = [];
filename = (state.filename ?? UNKNOWN_FILENAME).replace(/\\/g, '/');
warning_filter = state.warning ?? (() => true);
warnings = [];

@ -133,6 +133,8 @@ export namespace AST {
/** @internal */
metadata: {
expression: ExpressionMetadata;
/** If `true`, the `{@html}` block is the only child of its parent element and can use `parent.innerHTML` directly */
is_controlled?: boolean;
};
}

@ -11,12 +11,11 @@ function tabs_to_spaces(str) {
}
/**
* @param {string} source
* @param {number} line
* @param {number} column
*/
function get_code_frame(source, line, column) {
const lines = source.split('\n');
function get_code_frame(line, column) {
const lines = state.source_lines;
const frame_start = Math.max(0, line - 2);
const frame_end = Math.min(line + 3, lines.length);
const digits = String(frame_end + 1).length;
@ -70,7 +69,7 @@ export class CompileDiagnostic {
this.start = state.locator(position[0]);
this.end = state.locator(position[1]);
if (this.start && this.end) {
this.frame = get_code_frame(state.source, this.start.line - 1, this.end.column);
this.frame = get_code_frame(this.start.line - 1, this.end.column);
}
}
}

@ -29,6 +29,8 @@ export const INERT = 1 << 13;
export const DESTROYED = 1 << 14;
/** Set once a reaction has run for the first time */
export const REACTION_RAN = 1 << 15;
/** Effect is in the process of getting destroyed. Can be observed in child teardown functions */
export const DESTROYING = 1 << 25;
// Flags exclusive to effects
/**

@ -5,7 +5,7 @@ import { active_effect, active_reaction } from './runtime.js';
import { create_user_effect } from './reactivity/effects.js';
import { async_mode_flag, legacy_mode_flag } from '../flags/index.js';
import { FILENAME } from '../../constants.js';
import { BRANCH_EFFECT, REACTION_RAN } from './constants.js';
import { BRANCH_EFFECT } from './constants.js';
/** @type {ComponentContext | null} */
export let component_context = null;
@ -182,6 +182,7 @@ export function push(props, runes = false, fn) {
e: null,
s: props,
x: null,
r: /** @type {Effect} */ (active_effect),
l: legacy_mode_flag && !runes ? { s: null, u: null, $: [] } : null
};

@ -68,19 +68,35 @@ function effect_label(effect, append_effect = false) {
return label;
}
/**
*
* @param {Effect} effect
* @param {Effect[]} highlighted
*/
export function log_effect_tree(effect, depth = 0) {
export function log_effect_tree(effect, highlighted = [], depth = 0, is_reachable = true) {
const flags = effect.f;
const label = effect_label(effect);
let label = effect_label(effect);
let status =
(flags & CLEAN) !== 0 ? 'clean' : (flags & MAYBE_DIRTY) !== 0 ? 'maybe dirty' : 'dirty';
let styles = [`font-weight: ${status === 'clean' ? 'normal' : 'bold'}`];
if (status !== 'clean' && !is_reachable) {
label = `⚠️ ${label}`;
styles.push(`color: red`);
}
if ((flags & INERT) !== 0) {
styles.push('font-style: italic');
}
if (highlighted.includes(effect)) {
styles.push('background-color: yellow');
}
// eslint-disable-next-line no-console
console.group(`%c${label} (${status})`, `font-weight: ${status === 'clean' ? 'normal' : 'bold'}`);
console.group(`%c${label} (${status})`, styles.join('; '));
if (depth === 0) {
const callsite = new Error().stack
@ -120,9 +136,11 @@ export function log_effect_tree(effect, depth = 0) {
}
}
var child_is_reachable = is_reachable && ((flags & BRANCH_EFFECT) === 0 || (flags & CLEAN) === 0);
let child = effect.first;
while (child !== null) {
log_effect_tree(child, depth + 1);
log_effect_tree(child, highlighted, depth + 1, child_is_reachable);
child = child.next;
}

@ -1,6 +1,6 @@
/** @import { SourceLocation } from '#client' */
import { COMMENT_NODE, DOCUMENT_FRAGMENT_NODE, ELEMENT_NODE } from '#client/constants';
import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../constants.js';
import { HYDRATION_END, HYDRATION_START } from '../../../constants.js';
import { hydrating } from '../dom/hydration.js';
import { dev_stack } from '../context.js';
@ -50,7 +50,7 @@ function assign_locations(node, filename, locations) {
while (node && i < locations.length) {
if (hydrating && node.nodeType === COMMENT_NODE) {
var comment = /** @type {Comment} */ (node);
if (comment.data === HYDRATION_START || comment.data === HYDRATION_START_ELSE) depth += 1;
if (comment.data[0] === HYDRATION_START) depth += 1;
else if (comment.data[0] === HYDRATION_END) depth -= 1;
}

@ -45,7 +45,14 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
var branches = new BranchManager(node);
block(() => {
var batch = /** @type {Batch} */ (current_batch);
// we null out `current_batch` because otherwise `save(...)` will incorrectly restore it —
// the batch will already have been committed by the time it resolves
batch.deactivate();
var input = get_input();
batch.activate();
var destroyed = false;
/** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */

@ -35,7 +35,7 @@ import { queue_micro_task } from '../task.js';
import * as e from '../../errors.js';
import * as w from '../../warnings.js';
import { DEV } from 'esm-env';
import { Batch, schedule_effect } from '../../reactivity/batch.js';
import { Batch, current_batch, schedule_effect } from '../../reactivity/batch.js';
import { internal_set, source } from '../../reactivity/sources.js';
import { tag } from '../../dev/tracing.js';
import { createSubscriber } from '../../../../reactivity/create-subscriber.js';
@ -218,6 +218,8 @@ export class Boundary {
this.is_pending = true;
this.#pending_effect = branch(() => pending(this.#anchor));
var batch = /** @type {Batch} */ (current_batch);
queue_micro_task(() => {
var fragment = (this.#offscreen_fragment = document.createDocumentFragment());
var anchor = create_text();
@ -225,7 +227,6 @@ export class Boundary {
fragment.append(anchor);
this.#main_effect = this.#run(() => {
Batch.ensure();
return branch(() => this.#children(anchor));
});
@ -237,12 +238,14 @@ export class Boundary {
this.#pending_effect = null;
});
this.#resolve();
this.#resolve(batch);
}
});
}
#render() {
var batch = /** @type {Batch} */ (current_batch);
try {
this.is_pending = this.has_pending_snippet();
this.#pending_count = 0;
@ -259,14 +262,17 @@ export class Boundary {
const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending);
this.#pending_effect = branch(() => pending(this.#anchor));
} else {
this.#resolve();
this.#resolve(batch);
}
} catch (error) {
this.error(error);
}
}
#resolve() {
/**
* @param {Batch} batch
*/
#resolve(batch) {
this.is_pending = false;
// any effects that were previously deferred should be rescheduled —
@ -274,12 +280,12 @@ export class Boundary {
// same update that brought us here) the effects will be flushed
for (const e of this.#dirty_effects) {
set_signal_status(e, DIRTY);
schedule_effect(e);
batch.schedule(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
schedule_effect(e);
batch.schedule(e);
}
this.#dirty_effects.clear();
@ -320,6 +326,7 @@ export class Boundary {
set_component_context(this.#effect.ctx);
try {
Batch.ensure();
return fn();
} catch (e) {
handle_error(e);
@ -335,11 +342,12 @@ export class Boundary {
* Updates the pending count associated with the currently visible pending snippet,
* if any, such that we can replace the snippet with content once work is done
* @param {1 | -1} d
* @param {Batch} batch
*/
#update_pending_count(d) {
#update_pending_count(d, batch) {
if (!this.has_pending_snippet()) {
if (this.parent) {
this.parent.#update_pending_count(d);
this.parent.#update_pending_count(d, batch);
}
// if there's no parent, we're in a scope with no pending snippet
@ -349,7 +357,7 @@ export class Boundary {
this.#pending_count += d;
if (this.#pending_count === 0) {
this.#resolve();
this.#resolve(batch);
if (this.#pending_effect) {
pause_effect(this.#pending_effect, () => {
@ -369,9 +377,10 @@ export class Boundary {
* and controls when the current `pending` snippet (if any) is removed.
* Do not call from inside the class
* @param {1 | -1} d
* @param {Batch} batch
*/
update_pending_count(d) {
this.#update_pending_count(d);
update_pending_count(d, batch) {
this.#update_pending_count(d, batch);
this.#local_pending_count += d;
@ -445,9 +454,6 @@ export class Boundary {
}
this.#run(() => {
// If the failure happened while flushing effects, current_batch can be null
Batch.ensure();
this.#render();
});
};
@ -464,8 +470,6 @@ export class Boundary {
if (failed) {
this.#failed_effect = this.#run(() => {
Batch.ensure();
try {
return branch(() => {
// errors in `failed` snippets cause the boundary to error again

@ -35,7 +35,7 @@ import {
} from '../../reactivity/effects.js';
import { source, mutable_source, internal_set } from '../../reactivity/sources.js';
import { array_from, is_array } from '../../../shared/utils.js';
import { BRANCH_EFFECT, COMMENT_NODE, EFFECT_OFFSCREEN, INERT } from '#client/constants';
import { BRANCH_EFFECT, COMMENT_NODE, DESTROYED, EFFECT_OFFSCREEN, INERT } from '#client/constants';
import { queue_micro_task } from '../task.js';
import { get } from '../../runtime.js';
import { DEV } from 'esm-env';
@ -217,6 +217,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
* @param {Batch} batch
*/
function commit(batch) {
if ((state.effect.f & DESTROYED) !== 0) {
return;
}
state.pending.delete(batch);
state.fallback = fallback;

@ -42,17 +42,33 @@ function check_hash(element, server_hash, value) {
/**
* @param {Element | Text | Comment} node
* @param {() => string | TrustedHTML} get_value
* @param {boolean} [is_controlled]
* @param {boolean} [svg]
* @param {boolean} [mathml]
* @param {boolean} [skip_warning]
* @returns {void}
*/
export function html(node, get_value, svg = false, mathml = false, skip_warning = false) {
export function html(
node,
get_value,
is_controlled = false,
svg = false,
mathml = false,
skip_warning = false
) {
var anchor = node;
/** @type {string | TrustedHTML} */
var value = '';
if (is_controlled) {
var parent_node = /** @type {Element} */ (node);
if (hydrating) {
anchor = set_hydrate_node(get_first_child(parent_node));
}
}
template_effect(() => {
var effect = /** @type {Effect} */ (active_effect);
@ -61,6 +77,22 @@ export function html(node, get_value, svg = false, mathml = false, skip_warning
return;
}
if (is_controlled && !hydrating) {
// When @html is the only child, use innerHTML directly.
// This also handles contenteditable, where the user may delete the anchor comment.
effect.nodes = null;
parent_node.innerHTML = /** @type {string} */ (value);
if (value !== '') {
assign_nodes(
/** @type {TemplateNode} */ (get_first_child(parent_node)),
/** @type {TemplateNode} */ (parent_node.lastChild)
);
}
return;
}
if (effect.nodes !== null) {
remove_effect_dom(effect.nodes.start, /** @type {TemplateNode} */ (effect.nodes.end));
effect.nodes = null;

@ -11,7 +11,6 @@ import {
} from '../hydration.js';
import { block } from '../../reactivity/effects.js';
import { BranchManager } from './branches.js';
import { HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js';
/**
* @param {TemplateNode} node
@ -37,21 +36,9 @@ export function if_block(node, fn, elseif = false) {
function update_branch(key, fn) {
if (hydrating) {
var data = read_hydration_instruction(/** @type {TemplateNode} */ (marker));
/**
* @type {number | false}
* "[" = branch 0, "[1" = branch 1, "[2" = branch 2, ..., "[!" = else (false)
*/
var hydrated_key;
if (data === HYDRATION_START) {
hydrated_key = 0;
} else if (data === HYDRATION_START_ELSE) {
hydrated_key = false;
} else {
hydrated_key = parseInt(data.substring(1)); // "[1", "[2", etc.
}
if (key !== hydrated_key) {
// "[n" = branch n, "[-1" = else
if (key !== parseInt(data.substring(1))) {
// Hydration mismatch: remove everything inside the anchor and start fresh.
// This could happen with `{#if browser}...{/if}`, for example
var anchor = skip_nodes();
@ -79,7 +66,7 @@ export function if_block(node, fn, elseif = false) {
});
if (!has_branch) {
update_branch(false, null);
update_branch(-1, null);
}
}, flags);
}

@ -9,6 +9,7 @@ import { hydrating } from '../../hydration.js';
import { tick, untrack } from '../../../runtime.js';
import { is_runes } from '../../../context.js';
import { current_batch, previous_batch } from '../../../reactivity/batch.js';
import { async_mode_flag } from '../../../../flags/index.js';
/**
* @param {HTMLInputElement} input
@ -87,8 +88,9 @@ export function bind_value(input, get, set = get) {
var value = get();
if (input === document.activeElement) {
// we need both, because in non-async mode, render effects run before previous_batch is set
var batch = /** @type {Batch} */ (previous_batch ?? current_batch);
// In sync mode render effects are executed during tree traversal -> needs current_batch
// In async mode render effects are flushed once batch resolved, at which point current_batch is null -> needs previous_batch
var batch = /** @type {Batch} */ (async_mode_flag ? previous_batch : current_batch);
// Never rewrite the contents of a focused input. We can get here if, for example,
// an update is deferred because of async work depending on the input:

@ -4,6 +4,7 @@ import { is } from '../../../proxy.js';
import { is_array } from '../../../../shared/utils.js';
import * as w from '../../../warnings.js';
import { Batch, current_batch, previous_batch } from '../../../reactivity/batch.js';
import { async_mode_flag } from '../../../../flags/index.js';
/**
* Selects the correct option(s) (depending on whether this is a multiple select)
@ -115,8 +116,9 @@ export function bind_select_value(select, get, set = get) {
var value = get();
if (select === document.activeElement) {
// we need both, because in non-async mode, render effects run before previous_batch is set
var batch = /** @type {Batch} */ (previous_batch ?? current_batch);
// In sync mode render effects are executed during tree traversal -> needs current_batch
// In async mode render effects are flushed once batch resolved, at which point current_batch is null -> needs previous_batch
var batch = /** @type {Batch} */ (async_mode_flag ? previous_batch : current_batch);
// Don't update the <select> if it is focused. We can get here if, for example,
// an update is deferred because of async work depending on the select:

@ -1,7 +1,8 @@
import { STATE_SYMBOL } from '#client/constants';
/** @import { ComponentContext, Effect } from '#client' */
import { DESTROYING, STATE_SYMBOL } from '#client/constants';
import { component_context } from '../../../context.js';
import { effect, render_effect } from '../../../reactivity/effects.js';
import { untrack } from '../../../runtime.js';
import { queue_micro_task } from '../../task.js';
import { active_effect, untrack } from '../../../runtime.js';
/**
* @param {any} bound_value
@ -23,6 +24,9 @@ function is_bound_this(bound_value, element_or_component) {
* @returns {void}
*/
export function bind_this(element_or_component = {}, update, get_value, get_parts) {
var component_effect = /** @type {ComponentContext} */ (component_context).r;
var parent = /** @type {Effect} */ (active_effect);
effect(() => {
/** @type {unknown[]} */
var old_parts;
@ -48,12 +52,25 @@ export function bind_this(element_or_component = {}, update, get_value, get_part
});
return () => {
// We cannot use effects in the teardown phase, we we use a microtask instead.
queue_micro_task(() => {
// When the bind:this effect is destroyed, we go up the effect parent chain until we find the last parent effect that is destroyed,
// or the effect containing the component bind:this is in (whichever comes first). That way we can time the nulling of the binding
// as close to user/developer expectation as possible.
// TODO Svelte 6: Decide if we want to keep this logic or just always null the binding in the component effect's teardown
// (which would be simpler, but less intuitive in some cases, and breaks the `ondestroy-before-cleanup` test)
let p = parent;
while (p !== component_effect && p.parent !== null && p.parent.f & DESTROYING) {
p = p.parent;
}
const teardown = () => {
if (parts && is_bound_this(get_value(...parts), element_or_component)) {
update(null, ...parts);
}
});
};
const original_teardown = p.teardown;
p.teardown = () => {
teardown();
original_teardown?.();
};
};
});

@ -43,7 +43,6 @@ export function flatten(blockers, sync, async, fn) {
return;
}
var batch = current_batch;
var parent = /** @type {Effect} */ (active_effect);
var restore = capture();
@ -75,16 +74,22 @@ export function flatten(blockers, sync, async, fn) {
return;
}
var decrement_pending = increment_pending();
// Full path: has async expressions
function run() {
restore();
Promise.all(async.map((expression) => async_derived(expression)))
.then((result) => finish([...sync.map(d), ...result]))
.catch((error) => invoke_error_boundary(error, parent));
.catch((error) => invoke_error_boundary(error, parent))
.finally(() => decrement_pending());
}
if (blocker_promise) {
blocker_promise.then(run);
blocker_promise.then(() => {
restore();
run();
unset_context();
});
} else {
run();
}
@ -104,10 +109,10 @@ export function run_after_blockers(blockers, fn) {
* causes `b` to be registered as a dependency).
*/
export function capture() {
var previous_effect = active_effect;
var previous_effect = /** @type {Effect} */ (active_effect);
var previous_reaction = active_reaction;
var previous_component_context = component_context;
var previous_batch = current_batch;
var previous_batch = /** @type {Batch} */ (current_batch);
if (DEV) {
var previous_dev_stack = dev_stack;
@ -117,7 +122,13 @@ export function capture() {
set_active_effect(previous_effect);
set_active_reaction(previous_reaction);
set_component_context(previous_component_context);
if (activate_batch) previous_batch?.activate();
if (activate_batch && (previous_effect.f & DESTROYED) === 0) {
// TODO we only need optional chaining here because `{#await ...}` blocks
// are anomalous. Once we retire them we can get rid of it
previous_batch?.activate();
previous_batch?.apply();
}
if (DEV) {
set_from_async_derived(null);
@ -280,7 +291,7 @@ export function run(thunks) {
// wait one more tick, so that template effects are
// guaranteed to run before `$effect(...)`
.then(() => Promise.resolve())
.finally(decrement_pending);
.finally(() => decrement_pending());
return blockers;
}
@ -292,16 +303,19 @@ export function wait(blockers) {
return Promise.all(blockers.map((b) => b.promise));
}
/**
* @returns {(skip?: boolean) => void}
*/
export function increment_pending() {
var boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b);
var batch = /** @type {Batch} */ (current_batch);
var blocking = boundary.is_rendered();
boundary.update_pending_count(1);
boundary.update_pending_count(1, batch);
batch.increment(blocking);
return () => {
boundary.update_pending_count(-1);
batch.decrement(blocking);
return (skip = false) => {
boundary.update_pending_count(-1, batch);
batch.decrement(blocking, skip);
};
}

@ -14,7 +14,6 @@ import {
MAYBE_DIRTY,
DERIVED,
EAGER_EFFECT,
HEAD_EFFECT,
ERROR_VALUE,
MANAGED_EFFECT,
REACTION_RAN
@ -23,6 +22,7 @@ import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property, includes } from '../../shared/utils.js';
import {
active_effect,
active_reaction,
get,
increment_write_version,
is_dirty,
@ -37,6 +37,7 @@ import { eager_effect, unlink_effect } from './effects.js';
import { defer_effect } from './utils.js';
import { UNINITIALIZED } from '../../../constants.js';
import { set_signal_status } from './status.js';
import { legacy_is_updating_store } from './store.js';
/** @type {Set<Batch>} */
const batches = new Set();
@ -45,8 +46,7 @@ const batches = new Set();
export let current_batch = null;
/**
* This is needed to avoid overwriting inputs in non-async mode
* TODO 6.0 remove this, as non-async mode will go away
* This is needed to avoid overwriting inputs
* @type {Batch | null}
*/
export let previous_batch = null;
@ -59,14 +59,11 @@ export let previous_batch = null;
*/
export let batch_values = null;
// TODO this should really be a property of `batch`
/** @type {Effect[]} */
let queued_root_effects = [];
/** @type {Effect | null} */
let last_scheduled_effect = null;
export let is_flushing_sync = false;
let is_processing = false;
/**
* During traversal, this is an array. Newly created effects are (if not immediately
@ -76,7 +73,24 @@ export let is_flushing_sync = false;
*/
export let collected_effects = null;
/**
* An array of effects that are marked during traversal as a result of a `set`
* (not `internal_set`) call. These will be added to the next batch and
* trigger another `batch.process()`
* @type {Effect[] | null}
* @deprecated when we get rid of legacy mode and stores, we can get rid of this
*/
export let legacy_updates = null;
var flush_count = 0;
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
* They keys of this map are identical to `this.#previous`
@ -121,6 +135,12 @@ export class Batch {
*/
#deferred = null;
/**
* The root effects that need to be flushed
* @type {Effect[]}
*/
#roots = [];
/**
* Deferred effects (which run after async work has completed) that are DIRTY
* @type {Set<Effect>}
@ -172,22 +192,23 @@ export class Batch {
for (var e of tracked.d) {
set_signal_status(e, DIRTY);
schedule_effect(e);
this.schedule(e);
}
for (e of tracked.m) {
set_signal_status(e, MAYBE_DIRTY);
schedule_effect(e);
this.schedule(e);
}
}
}
/**
*
* @param {Effect[]} root_effects
*/
process(root_effects) {
queued_root_effects = [];
#process() {
if (flush_count++ > 1000) {
infinite_loop_guard();
}
const roots = this.#roots;
this.#roots = [];
this.apply();
@ -197,16 +218,28 @@ export class Batch {
/** @type {Effect[]} */
var render_effects = [];
for (const root of root_effects) {
this.#traverse_effect_tree(root, effects, render_effects);
// Note: #traverse_effect_tree runs block effects eagerly, which can schedule effects,
// which means queued_root_effects now may be filled again.
/**
* @type {Effect[]}
* @deprecated when we get rid of legacy mode and stores, we can get rid of this
*/
var updates = (legacy_updates = []);
for (const root of roots) {
this.#traverse(root, effects, render_effects);
}
// any writes should take effect in a subsequent batch
current_batch = null;
// Helpful for debugging reactivity loss that has to do with branches being skipped:
// log_inconsistent_branches(root);
if (updates.length > 0) {
var batch = Batch.ensure();
for (const e of updates) {
batch.schedule(e);
}
}
collected_effects = null;
legacy_updates = null;
if (this.#is_deferred()) {
this.#defer_effects(render_effects);
@ -216,32 +249,39 @@ export class Batch {
reset_branch(e, t);
}
} else {
// If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with
// newly updated sources, which could lead to infinite loops when effects run over and over again.
previous_batch = this;
current_batch = null;
// clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches.
this.#dirty_effects.clear();
this.#maybe_dirty_effects.clear();
// append/remove branches
for (const fn of this.#commit_callbacks) fn(this);
this.#commit_callbacks.clear();
previous_batch = this;
flush_queued_effects(render_effects);
flush_queued_effects(effects);
previous_batch = null;
if (this.#pending === 0) {
this.#commit();
}
flush_queued_effects(render_effects);
flush_queued_effects(effects);
this.#deferred?.resolve();
}
// Clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches.
this.#dirty_effects.clear();
this.#maybe_dirty_effects.clear();
var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch));
previous_batch = null;
if (next_batch !== null) {
batches.add(next_batch);
this.#deferred?.resolve();
}
if (DEV) {
for (const source of this.current.keys()) {
/** @type {Set<Source>} */ (source_stacks).add(source);
}
}
batch_values = null;
next_batch.#process();
}
}
/**
@ -251,7 +291,7 @@ export class Batch {
* @param {Effect[]} effects
* @param {Effect[]} render_effects
*/
#traverse_effect_tree(root, effects, render_effects) {
#traverse(root, effects, render_effects) {
root.f ^= CLEAN;
var effect = root.first;
@ -325,32 +365,54 @@ export class Batch {
activate() {
current_batch = this;
this.apply();
}
deactivate() {
// If we're not the current batch, don't deactivate,
// else we could create zombie batches that are never flushed
if (current_batch !== this) return;
current_batch = null;
batch_values = null;
}
flush() {
if (queued_root_effects.length > 0) {
var source_stacks = DEV ? new Set() : null;
try {
is_processing = true;
current_batch = this;
flush_effects();
} else if (this.#pending === 0 && !this.is_fork) {
// append/remove branches
for (const fn of this.#commit_callbacks) fn(this);
this.#commit_callbacks.clear();
this.#commit();
this.#deferred?.resolve();
}
// we only reschedule previously-deferred effects if we expect
// to be able to run them after processing the batch
if (!this.#is_deferred()) {
for (const e of this.#dirty_effects) {
this.#maybe_dirty_effects.delete(e);
set_signal_status(e, DIRTY);
this.schedule(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
this.schedule(e);
}
}
this.#process();
} finally {
flush_count = 0;
last_scheduled_effect = null;
collected_effects = null;
legacy_updates = null;
is_processing = false;
current_batch = null;
batch_values = null;
old_values.clear();
this.deactivate();
if (DEV) {
for (const source of /** @type {Set<Source>} */ (source_stacks)) {
source.updated = null;
}
}
}
}
discard() {
@ -401,9 +463,7 @@ export class Batch {
// Re-run async/block effects that depend on distinct values changed in both batches
const others = [...batch.current.keys()].filter((s) => !this.current.has(s));
if (others.length > 0) {
// Avoid running queued root effects on the wrong branch
var prev_queued_root_effects = queued_root_effects;
queued_root_effects = [];
batch.activate();
/** @type {Set<Value>} */
const marked = new Set();
@ -413,20 +473,17 @@ export class Batch {
mark_effects(source, others, marked, checked);
}
if (queued_root_effects.length > 0) {
current_batch = batch;
if (batch.#roots.length > 0) {
batch.apply();
for (const root of queued_root_effects) {
batch.#traverse_effect_tree(root, [], []);
for (const root of batch.#roots) {
batch.#traverse(root, [], []);
}
// TODO do we need to do anything with the dummy effect arrays?
batch.deactivate();
}
queued_root_effects = prev_queued_root_effects;
batch.deactivate();
}
}
@ -448,46 +505,22 @@ export class Batch {
}
/**
*
* @param {boolean} blocking
* @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction)
*/
decrement(blocking) {
decrement(blocking, skip) {
this.#pending -= 1;
if (blocking) this.#blocking_pending -= 1;
if (this.#decrement_queued) return;
if (this.#decrement_queued || skip) return;
this.#decrement_queued = true;
queue_micro_task(() => {
this.#decrement_queued = false;
if (!this.#is_deferred()) {
// we only reschedule previously-deferred effects if we expect
// to be able to run them after processing the batch
this.revive();
} else if (queued_root_effects.length > 0) {
// if other effects are scheduled, process the batch _without_
// rescheduling the previously-deferred effects
this.flush();
}
this.flush();
});
}
revive() {
for (const e of this.#dirty_effects) {
this.#maybe_dirty_effects.delete(e);
set_signal_status(e, DIRTY);
schedule_effect(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
schedule_effect(e);
}
this.flush();
}
/** @param {(batch: Batch) => void} fn */
oncommit(fn) {
this.#commit_callbacks.add(fn);
@ -505,17 +538,20 @@ export class Batch {
static ensure() {
if (current_batch === null) {
const batch = (current_batch = new Batch());
batches.add(current_batch);
if (!is_flushing_sync) {
queue_micro_task(() => {
if (current_batch !== batch) {
// a flushSync happened in the meantime
return;
}
if (!is_processing) {
batches.add(current_batch);
if (!is_flushing_sync) {
queue_micro_task(() => {
if (current_batch !== batch) {
// a flushSync happened in the meantime
return;
}
batch.flush();
});
batch.flush();
});
}
}
}
@ -559,6 +595,63 @@ export class Batch {
}
}
}
/**
*
* @param {Effect} effect
*/
schedule(effect) {
last_scheduled_effect = effect;
// defer render effects inside a pending boundary
// TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT — we can remove later
if (
effect.b?.is_pending &&
(effect.f & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 &&
(effect.f & REACTION_RAN) === 0
) {
effect.b.defer_effect(effect);
return;
}
var e = effect;
while (e.parent !== null) {
e = e.parent;
var flags = e.f;
// if the effect is being scheduled because a parent (each/await/etc) block
// updated an internal source, or because a branch is being unskipped,
// bail out or we'll cause a second flush
if (collected_effects !== null && e === active_effect) {
if (async_mode_flag) return;
// in sync mode, render effects run during traversal. in an extreme edge case
// — namely that we're setting a value inside a derived read during traversal —
// they can be made dirty after they have already been visited, in which
// case we shouldn't bail out. we also shouldn't bail out if we're
// updating a store inside a `$:`, since this might invalidate
// effects that were already visited
if (
(active_reaction === null || (active_reaction.f & DERIVED) === 0) &&
!legacy_is_updating_store
) {
return;
}
}
if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
if ((flags & CLEAN) === 0) {
// branch is already dirty, bail
return;
}
e.f ^= CLEAN;
}
}
this.#roots.push(e);
}
}
/**
@ -576,8 +669,8 @@ export function flushSync(fn) {
var result;
if (fn) {
if (current_batch !== null) {
flush_effects();
if (current_batch !== null && !current_batch.is_fork) {
current_batch.flush();
}
result = fn();
@ -586,87 +679,42 @@ export function flushSync(fn) {
while (true) {
flush_tasks();
if (queued_root_effects.length === 0) {
current_batch?.flush();
// we need to check again, in case we just updated an `$effect.pending()`
if (queued_root_effects.length === 0) {
// this would be reset in `flush_effects()` but since we are early returning here,
// we need to reset it here as well in case the first time there's 0 queued root effects
last_scheduled_effect = null;
return /** @type {T} */ (result);
}
if (current_batch === null) {
return /** @type {T} */ (result);
}
flush_effects();
current_batch.flush();
}
} finally {
is_flushing_sync = was_flushing_sync;
}
}
function flush_effects() {
var source_stacks = DEV ? new Set() : null;
try {
var flush_count = 0;
while (queued_root_effects.length > 0) {
var batch = Batch.ensure();
if (flush_count++ > 1000) {
if (DEV) {
var updates = new Map();
for (const source of batch.current.keys()) {
for (const [stack, update] of source.updated ?? []) {
var entry = updates.get(stack);
if (!entry) {
entry = { error: update.error, count: 0 };
updates.set(stack, entry);
}
function infinite_loop_guard() {
if (DEV) {
var updates = new Map();
entry.count += update.count;
}
}
for (const source of /** @type {Batch} */ (current_batch).current.keys()) {
for (const [stack, update] of source.updated ?? []) {
var entry = updates.get(stack);
for (const update of updates.values()) {
if (update.error) {
// eslint-disable-next-line no-console
console.error(update.error);
}
}
if (!entry) {
entry = { error: update.error, count: 0 };
updates.set(stack, entry);
}
infinite_loop_guard();
}
batch.process(queued_root_effects);
old_values.clear();
if (DEV) {
for (const source of batch.current.keys()) {
/** @type {Set<Source>} */ (source_stacks).add(source);
}
entry.count += update.count;
}
}
} finally {
queued_root_effects = [];
last_scheduled_effect = null;
collected_effects = null;
if (DEV) {
for (const source of /** @type {Set<Source>} */ (source_stacks)) {
source.updated = null;
for (const update of updates.values()) {
if (update.error) {
// eslint-disable-next-line no-console
console.error(update.error);
}
}
}
}
function infinite_loop_guard() {
try {
e.effect_update_depth_exceeded();
} catch (error) {
@ -836,52 +884,11 @@ function depends_on(reaction, sources, checked) {
}
/**
* @param {Effect} signal
* @param {Effect} effect
* @returns {void}
*/
export function schedule_effect(signal) {
var effect = (last_scheduled_effect = signal);
var boundary = effect.b;
// defer render effects inside a pending boundary
// TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT — we can remove later
if (
boundary?.is_pending &&
(signal.f & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 &&
(signal.f & REACTION_RAN) === 0
) {
boundary.defer_effect(signal);
return;
}
while (effect.parent !== null) {
effect = effect.parent;
var flags = effect.f;
// if the effect is being scheduled because a parent (each/await/etc) block
// updated an internal source, or because a branch is being unskipped,
// bail out or we'll cause a second flush
if (collected_effects !== null && effect === active_effect) {
// in sync mode, render effects run during traversal. in an extreme edge case
// they can be made dirty after they have already been visited, in which
// case we shouldn't bail out
if (async_mode_flag || (signal.f & RENDER_EFFECT) === 0) {
return;
}
}
if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
if ((flags & CLEAN) === 0) {
// branch is already dirty, bail
return;
}
effect.f ^= CLEAN;
}
}
queued_root_effects.push(effect);
export function schedule_effect(effect) {
/** @type {Batch} */ (current_batch).schedule(effect);
}
/** @type {Source<number>[]} */
@ -1058,7 +1065,7 @@ export function fork(fn) {
flush_eager_effects();
});
batch.revive();
batch.flush();
await settled;
},
discard: () => {

@ -1,5 +1,6 @@
/** @import { Derived, Effect, Source } from '#client' */
/** @import { Batch } from './batch.js'; */
/** @import { Boundary } from '../dom/blocks/boundary.js'; */
import { DEV } from 'esm-env';
import {
ERROR_VALUE,
@ -10,7 +11,8 @@ import {
ASYNC,
WAS_MARKED,
DESTROYED,
CLEAN
CLEAN,
REACTION_RAN
} from '#client/constants';
import {
active_reaction,
@ -36,7 +38,6 @@ import {
import { eager_effects, internal_set, set_eager_effects, source } from './sources.js';
import { get_error } from '../../shared/dev.js';
import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { Boundary } from '../dom/blocks/boundary.js';
import { component_context } from '../context.js';
import { UNINITIALIZED } from '../../../constants.js';
import { batch_values, current_batch } from './batch.js';
@ -125,6 +126,8 @@ export function async_derived(fn, label, location) {
async_effect(() => {
if (DEV) current_async_effect = active_effect;
var effect = /** @type {Effect} */ (active_effect);
/** @type {ReturnType<typeof deferred<V>>} */
var d = deferred();
promise = d.promise;
@ -144,10 +147,25 @@ export function async_derived(fn, label, location) {
var batch = /** @type {Batch} */ (current_batch);
if (should_suspend) {
var decrement_pending = increment_pending();
// we only increment the batch's pending state for updates, not creation, otherwise
// we will decrement to zero before the work that depends on this promise (e.g. a
// template effect) has initialized, causing the batch to resolve prematurely
if ((effect.f & REACTION_RAN) !== 0) {
var decrement_pending = increment_pending();
}
if (/** @type {Boundary} */ (parent.b).is_rendered()) {
deferreds.get(batch)?.reject(STALE_REACTION);
deferreds.delete(batch); // delete to ensure correct order in Map iteration below
} else {
// While the boundary is still showing pending, a new run supersedes all older in-flight runs
// for this async expression. Cancel eagerly so resolution cannot commit stale values.
for (const d of deferreds.values()) {
d.reject(STALE_REACTION);
}
deferreds.clear();
}
deferreds.get(batch)?.reject(STALE_REACTION);
deferreds.delete(batch); // delete to ensure correct order in Map iteration below
deferreds.set(batch, d);
}
@ -156,17 +174,26 @@ export function async_derived(fn, label, location) {
* @param {unknown} error
*/
const handler = (value, error = undefined) => {
current_async_effect = null;
if (DEV) current_async_effect = null;
if (decrement_pending) {
// don't trigger an update if we're only here because
// the promise was superseded before it could resolve
var skip = error === STALE_REACTION;
decrement_pending(skip);
}
if (error === STALE_REACTION || (effect.f & DESTROYED) !== 0) {
return;
}
batch.activate();
if (error) {
if (error !== STALE_REACTION) {
signal.f |= ERROR_VALUE;
signal.f |= ERROR_VALUE;
// @ts-expect-error the error is the wrong type, but we don't care
internal_set(signal, error);
}
// @ts-expect-error the error is the wrong type, but we don't care
internal_set(signal, error);
} else {
if ((signal.f & ERROR_VALUE) !== 0) {
signal.f ^= ERROR_VALUE;
@ -193,9 +220,7 @@ export function async_derived(fn, label, location) {
}
}
if (decrement_pending) {
decrement_pending();
}
batch.deactivate();
};
d.promise.then(handler, (e) => handler(null, e || 'unknown'));

@ -10,7 +10,8 @@ import {
set_active_reaction,
set_is_destroying_effect,
untrack,
untracking
untracking,
set_active_effect
} from '../runtime.js';
import {
DIRTY,
@ -33,14 +34,15 @@ import {
USER_EFFECT,
ASYNC,
CONNECTED,
MANAGED_EFFECT
MANAGED_EFFECT,
DESTROYING
} from '#client/constants';
import * as e from '../errors.js';
import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js';
import { component_context, dev_current_component_function, dev_stack } from '../context.js';
import { Batch, collected_effects, schedule_effect } from './batch.js';
import { Batch, collected_effects } from './batch.js';
import { flatten, increment_pending } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js';
@ -127,7 +129,7 @@ function create_effect(type, fn) {
collected_effects.push(effect);
} else {
// schedule for later
schedule_effect(effect);
Batch.ensure().schedule(effect);
}
} else if (fn !== null) {
try {
@ -315,7 +317,19 @@ export function legacy_pre_effect(deps, fn) {
if (token.ran) return;
token.ran = true;
untrack(fn);
var effect = /** @type {Effect} */ (active_effect);
// here, we lie: by setting `active_effect` to be the parent branch, any writes
// that happen inside `fn` will _not_ cause an unnecessary reschedule, because
// the affected effects will be children of `active_effect`. this is safe
// because these effects are known to run in the correct order
try {
set_active_effect(effect.parent);
untrack(fn);
} finally {
set_active_effect(effect);
}
});
}
@ -507,9 +521,9 @@ export function destroy_effect(effect, remove_dom = true) {
removed = true;
}
set_signal_status(effect, DESTROYING);
destroy_effect_children(effect, remove_dom && !removed);
remove_reactions(effect, 0);
set_signal_status(effect, DESTROYED);
var transitions = effect.nodes && effect.nodes.t;
@ -521,6 +535,9 @@ export function destroy_effect(effect, remove_dom = true) {
execute_effect_teardown(effect);
effect.f ^= DESTROYING;
effect.f |= DESTROYED;
var parent = effect.parent;
// If the parent doesn't have any children, then skip this work altogether
@ -671,7 +688,7 @@ function resume_children(effect, local) {
// `{#if foo}{foo.bar()}{/if}` if `foo` is now `undefined
if ((effect.f & CLEAN) === 0) {
set_signal_status(effect, DIRTY);
schedule_effect(effect);
Batch.ensure().schedule(effect); // Assumption: This happens during the commit phase of the batch, causing another flush, but it's safe
}
var child = effect.first;

@ -22,6 +22,7 @@ import { DESTROYED, LEGACY_PROPS, STATE_SYMBOL } from '#client/constants';
import { proxy } from '../proxy.js';
import { capture_store_binding } from './store.js';
import { legacy_mode_flag } from '../../flags/index.js';
import { effect, render_effect } from './effects.js';
/**
* @param {((value?: number) => number)} fn
@ -296,7 +297,7 @@ export function prop(props, key, flags, fallback) {
};
/** @type {((v: V) => void) | undefined} */
var setter;
let setter;
if (bindable) {
// Can be the case when someone does `mount(Component, props)` with `let props = $state({...})`
@ -308,6 +309,7 @@ export function prop(props, key, flags, fallback) {
(is_entry_props && key in props ? (v) => (props[key] = v) : undefined);
}
/** @type {V} */
var initial_value;
var is_store_sub = false;
@ -417,9 +419,7 @@ export function prop(props, key, flags, fallback) {
// special case — avoid recalculating the derived if we're in a
// teardown function and the prop was overridden locally, or the
// component was already destroyed (this latter part is necessary
// because `bind:this` can read props after the component has
// been destroyed. TODO simplify `bind:this`
// component was already destroyed (people could access props in a timeout)
if ((is_destroying_effect && overridden) || (parent_effect.f & DESTROYED) !== 0) {
return d.v;
}

@ -35,7 +35,13 @@ import { includes } from '../../shared/utils.js';
import { tag_proxy } from '../dev/tracing.js';
import { get_error } from '../../shared/dev.js';
import { component_context, is_runes } from '../context.js';
import { Batch, batch_values, eager_block_effects, schedule_effect } from './batch.js';
import {
Batch,
batch_values,
eager_block_effects,
schedule_effect,
legacy_updates
} from './batch.js';
import { proxy } from '../proxy.js';
import { execute_derived } from './deriveds.js';
import { set_signal_status, update_derived_status } from './status.js';
@ -162,16 +168,17 @@ export function set(source, value, should_proxy = false) {
tag_proxy(new_value, /** @type {string} */ (source.label));
}
return internal_set(source, new_value);
return internal_set(source, new_value, legacy_updates);
}
/**
* @template V
* @param {Source<V>} source
* @param {V} value
* @param {Effect[] | null} [updated_during_traversal]
* @returns {V}
*/
export function internal_set(source, value) {
export function internal_set(source, value, updated_during_traversal = null) {
if (!source.equals(value)) {
var old_value = source.v;
@ -231,7 +238,7 @@ export function internal_set(source, value) {
// For debugging, in case you want to know which reactions are being scheduled:
// log_reactions(source);
mark_reactions(source, DIRTY);
mark_reactions(source, DIRTY, updated_during_traversal);
// It's possible that the current reaction might not have up-to-date dependencies
// whilst it's actively running. So in the case of ensuring it registers the reaction
@ -317,9 +324,10 @@ export function increment(source) {
/**
* @param {Value} signal
* @param {number} status should be DIRTY or MAYBE_DIRTY
* @param {Effect[] | null} updated_during_traversal
* @returns {void}
*/
function mark_reactions(signal, status) {
function mark_reactions(signal, status, updated_during_traversal) {
var reactions = signal.reactions;
if (reactions === null) return;
@ -357,14 +365,20 @@ function mark_reactions(signal, status) {
reaction.f |= WAS_MARKED;
}
mark_reactions(derived, MAYBE_DIRTY);
mark_reactions(derived, MAYBE_DIRTY, updated_during_traversal);
}
} else if (not_dirty) {
var effect = /** @type {Effect} */ (reaction);
if ((flags & BLOCK_EFFECT) !== 0 && eager_block_effects !== null) {
eager_block_effects.add(/** @type {Effect} */ (reaction));
eager_block_effects.add(effect);
}
schedule_effect(/** @type {Effect} */ (reaction));
if (updated_during_traversal !== null) {
updated_during_traversal.push(effect);
} else {
schedule_effect(effect);
}
}
}
}

@ -8,6 +8,12 @@ import { teardown } from './effects.js';
import { mutable_source, set } from './sources.js';
import { DEV } from 'esm-env';
/**
* We set this to `true` when updating a store so that we correctly
* schedule effects if the update takes place inside a `$:` effect
*/
export let legacy_is_updating_store = false;
/**
* Whether or not the prop currently being read is a store binding, as in
* `<Child bind:x={$y} />`. If it is, we treat the prop as mutable even in
@ -102,7 +108,7 @@ export function store_unsub(store, store_name, stores) {
* @returns {V}
*/
export function store_set(store, value) {
store.set(value);
update_with_flag(store, value);
return value;
}
@ -141,6 +147,21 @@ export function setup_stores() {
return [stores, cleanup];
}
/**
* @param {Store<V>} store
* @param {V} value
* @template V
*/
function update_with_flag(store, value) {
legacy_is_updating_store = true;
try {
store.set(value);
} finally {
legacy_is_updating_store = false;
}
}
/**
* Updates a store with a new value.
* @param {Store<V>} store the store to update
@ -149,7 +170,7 @@ export function setup_stores() {
* @template V
*/
export function store_mutate(store, expression, new_value) {
store.set(new_value);
update_with_flag(store, new_value);
return expression;
}
@ -160,7 +181,7 @@ export function store_mutate(store, expression, new_value) {
* @returns {number}
*/
export function update_store(store, store_value, d = 1) {
store.set(store_value + d);
update_with_flag(store, store_value + d);
return store_value;
}
@ -172,7 +193,7 @@ export function update_store(store, store_value, d = 1) {
*/
export function update_pre_store(store, store_value, d = 1) {
const value = store_value + d;
store.set(value);
update_with_flag(store, value);
return value;
}

@ -38,6 +38,12 @@ export type ComponentContext = {
* @deprecated remove in 6.0
*/
x: Record<string, any> | null;
/**
* The parent effect of this component
* TODO 6.0 this is used to control `bind:this` timing that might change,
* in which case we can remove this property
*/
r: Effect;
/**
* legacy stuff
* @deprecated remove in 6.0

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

@ -0,0 +1,48 @@
import { test } from '../../test';
export default test({
warnings: [
{
code: 'css_unused_selector',
message: 'Unused CSS selector ".foob"',
start: {
line: 64,
column: 1,
character: 1574
},
end: {
line: 64,
column: 6,
character: 1579
}
},
{
code: 'css_unused_selector',
message: 'Unused CSS selector "main > article > div > section > span"',
start: {
line: 84,
column: 1,
character: 2196
},
end: {
line: 84,
column: 38,
character: 2233
}
},
{
code: 'css_unused_selector',
message: 'Unused CSS selector "nav:has(button).primary"',
start: {
line: 95,
column: 1,
character: 2560
},
end: {
line: 95,
column: 24,
character: 2583
}
}
]
});

@ -0,0 +1,80 @@
/* === ~= word matching === */
/* Should match: "foo" is a whole word in class="foo bar" */
.foo.svelte-xyz { color: green; }
/* Should match: "bar" is a whole word in class="foo bar" */
.bar.svelte-xyz { color: green; }
/* Should match: "foobar" is the whole class value */
.foobar.svelte-xyz { color: green; }
/* Should match: "bar-foo" is a whole word (hyphen not whitespace) */
.bar-foo.svelte-xyz { color: green; }
/* Should match: "baz" is a whole word in class="bar-foo baz" */
.baz.svelte-xyz { color: green; }
/* Should NOT match: "foob" is not a word in any element's class */
/* (unused) .foob { color: red; }*/
/* Should NOT match: "afoo" is a word but "foo-x" is not "foo" */
[class~="foo-x"].svelte-xyz { color: green; }
/* Attribute selector with ~= operator directly */
[class~="afoo"].svelte-xyz { color: green; }
/* === Deep combinator chains (4+ levels) === */
/* Should match: exact chain main > article > section > div > span */
main.svelte-xyz > article:where(.svelte-xyz) > section:where(.svelte-xyz) > div:where(.svelte-xyz) > span:where(.svelte-xyz) { color: green; }
/* Should match: descendant chain */
main.svelte-xyz article:where(.svelte-xyz) section:where(.svelte-xyz) div:where(.svelte-xyz) span:where(.svelte-xyz) { color: green; }
/* Should match: mixed combinators */
main.svelte-xyz > article:where(.svelte-xyz) section:where(.svelte-xyz) > div:where(.svelte-xyz) span:where(.svelte-xyz) { color: green; }
/* Should NOT match: wrong nesting order */
/* (unused) main > article > div > section > span { color: red; }*/
/* === :has() combined with other selectors === */
/* Should match: nav.primary has <a> descendant */
nav:has(a:where(.svelte-xyz)).primary.svelte-xyz { color: green; }
/* Should match: nav.secondary has <button> descendant */
nav:has(button:where(.svelte-xyz)).secondary.svelte-xyz { color: green; }
/* Should NOT match: nav.primary doesn't have <button> */
/* (unused) nav:has(button).primary { color: red; }*/
/* Multiple :has() on same element */
main.svelte-xyz:has(article:where(.svelte-xyz)):has(span:where(.svelte-xyz)) { color: green; }
/* :has() with child combinator */
main.svelte-xyz:has(> article:where(.svelte-xyz)) { color: green; }
/* === Escaped selectors === */
.a\-b.svelte-xyz { color: green; }
/* === :is()/:where()/:not() with deep selectors === */
/* :is() with matching selector */
header.svelte-xyz :is(h1:where(.svelte-xyz)) { color: green; }
/* :where() with matching selector */
ul.svelte-xyz :where(li:where(.svelte-xyz)) { color: green; }
/* :not() — should match span since it's not a div */
span.svelte-xyz:not(div) { color: green; }
/* :is() with deep combinator */
ul.svelte-xyz :is(li:where(.svelte-xyz) > span:where(.svelte-xyz)) { color: green; }
/* :not() with class — p.a-b is :not(.unused) */
p.svelte-xyz:not(.unused) { color: green; }
/* Complex: :has() + :is() */
ul.svelte-xyz:has(li:where(.svelte-xyz)) :is(span:where(.svelte-xyz)) { color: green; }

@ -0,0 +1,125 @@
<!-- Edge cases for CSS pruning optimizations:
1. ~= word matching (indexOf vs split)
2. Deep combinator chains (index-based apply_selector)
3. :has() combined with other selectors (single-pass handling)
4. Escaped selectors (backslash skip optimization)
5. :is()/:where()/:not() with deep selectors
-->
<!-- ~= word matching edge cases -->
<div class="foo bar">word match</div>
<div class="foobar">substring only</div>
<div class="bar-foo baz">hyphen separated</div>
<div class="afoo foo-x">prefix substring</div>
<!-- Deep combinator chains -->
<main>
<article>
<section>
<div>
<span class="deep">deep</span>
</div>
</section>
</article>
</main>
<!-- :has() with class selectors -->
<nav class="primary">
<a href="/">link</a>
</nav>
<nav class="secondary">
<button>action</button>
</nav>
<!-- Escaped selectors -->
<p class="a-b">escaped</p>
<!-- :is()/:where()/:not() with combinators -->
<header>
<h1>title</h1>
</header>
<ul>
<li class="active"><span>item</span></li>
</ul>
<style>
/* === ~= word matching === */
/* Should match: "foo" is a whole word in class="foo bar" */
.foo { color: green; }
/* Should match: "bar" is a whole word in class="foo bar" */
.bar { color: green; }
/* Should match: "foobar" is the whole class value */
.foobar { color: green; }
/* Should match: "bar-foo" is a whole word (hyphen not whitespace) */
.bar-foo { color: green; }
/* Should match: "baz" is a whole word in class="bar-foo baz" */
.baz { color: green; }
/* Should NOT match: "foob" is not a word in any element's class */
.foob { color: red; }
/* Should NOT match: "afoo" is a word but "foo-x" is not "foo" */
[class~="foo-x"] { color: green; }
/* Attribute selector with ~= operator directly */
[class~="afoo"] { color: green; }
/* === Deep combinator chains (4+ levels) === */
/* Should match: exact chain main > article > section > div > span */
main > article > section > div > span { color: green; }
/* Should match: descendant chain */
main article section div span { color: green; }
/* Should match: mixed combinators */
main > article section > div span { color: green; }
/* Should NOT match: wrong nesting order */
main > article > div > section > span { color: red; }
/* === :has() combined with other selectors === */
/* Should match: nav.primary has <a> descendant */
nav:has(a).primary { color: green; }
/* Should match: nav.secondary has <button> descendant */
nav:has(button).secondary { color: green; }
/* Should NOT match: nav.primary doesn't have <button> */
nav:has(button).primary { color: red; }
/* Multiple :has() on same element */
main:has(article):has(span) { color: green; }
/* :has() with child combinator */
main:has(> article) { color: green; }
/* === Escaped selectors === */
.a\-b { color: green; }
/* === :is()/:where()/:not() with deep selectors === */
/* :is() with matching selector */
header :is(h1) { color: green; }
/* :where() with matching selector */
ul :where(li) { color: green; }
/* :not() — should match span since it's not a div */
span:not(div) { color: green; }
/* :is() with deep combinator */
ul :is(li > span) { color: green; }
/* :not() with class — p.a-b is :not(.unused) */
p:not(.unused) { color: green; }
/* Complex: :has() + :is() */
ul:has(li) :is(span) { color: green; }
</style>

@ -0,0 +1,9 @@
<script lang="ts">
import { count } from './stores';
let n = 0;
$: $count = n;
</script>
<button onclick={() => n += 1}>{$count}</button>

@ -0,0 +1,22 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>0</button><button>0</button>`,
test({ assert, target }) {
const [button1, button2] = target.querySelectorAll('button');
flushSync(() => button1.click());
assert.htmlEqual(target.innerHTML, `<button>1</button><button>1</button>`);
flushSync(() => button1.click());
assert.htmlEqual(target.innerHTML, `<button>2</button><button>2</button>`);
flushSync(() => button2.click());
assert.htmlEqual(target.innerHTML, `<button>1</button><button>1</button>`);
flushSync(() => button2.click());
assert.htmlEqual(target.innerHTML, `<button>2</button><button>2</button>`);
}
});

@ -0,0 +1,6 @@
<script>
import Child from './Child.svelte';
</script>
<Child />
<Child />

@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const count = writable(0);

@ -1,4 +1,4 @@
import { flushSync, tick } from 'svelte';
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
@ -7,12 +7,7 @@ export default test({
`,
async test({ assert, target }) {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
flushSync();
await tick();
assert.htmlEqual(target.innerHTML, '<p data-foo="bar">hello</p>');
}
});

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

@ -0,0 +1,19 @@
<script>
let queue = [];
function push(value) {
const deferred = Promise.withResolvers();
queue.push(() => deferred.resolve(value));
return deferred.promise;
}
let count = $state(0);
</script>
<button onclick={() => queue.shift()()}>shift</button>
<button onclick={() => count++}>increment</button>
<svelte:boundary>
{await push(count)}
{#snippet pending()}loading{/snippet}
</svelte:boundary>

@ -0,0 +1,28 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
// this test doesn't fail without the associated fix — the error gets
// swallowed somewhere. but keeping it around for illustration
skip: true,
mode: ['client'],
async test({ assert, target, errors, logs }) {
const button = target.querySelector('button');
button?.click();
await tick();
await tick();
assert.deepEqual(logs, ['Simulated TypeError']);
assert.deepEqual(errors, []);
assert.htmlEqual(
target.innerHTML,
`
<button>Trigger</button>
<p>Error Caught: Simulated TypeError</p>
`
);
}
});

@ -0,0 +1,29 @@
<script>
let index = $state(0);
async function fn(id) {
if (id === 2) throw new Error('Simulated TypeError');
return id;
}
function onerror(error) {
console.log(error.message);
}
</script>
<button onclick={() => (index = 1)}>Trigger</button>
<svelte:boundary {onerror}>
{#snippet pending()}
<p>Loading...</p>
{/snippet}
{#snippet failed(error)}
<p>Error Caught: {error.message}</p>
{/snippet}
{#each [[1], [2]][index] as id (id)}
{@const result = await fn(id)}
<p>{result}</p>
{/each}
</svelte:boundary>

@ -0,0 +1,9 @@
<script>
let { data } = $props();
const processed = $derived(data.toUpperCase());
export function getProcessed() {
return processed;
}
</script>

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

@ -0,0 +1,19 @@
<script>
import Inner from './Inner.svelte';
let value = $state('hello');
let innerComp = $state();
// Reads Inner's derived value from outside the {#if} block, keeping it
// connected in the reactive graph even after the branch is destroyed.
const externalView = $derived(innerComp?.getProcessed() ?? '');
</script>
{#if value}
{@const result = value}
<Inner data={result} bind:this={innerComp} />
{/if}
<button onclick={() => (value = undefined)}>clear</button>
<p>{externalView}</p>

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

@ -0,0 +1,10 @@
<script>
let value = $state('hello');
let elements = {};
</script>
{#if value}
<span bind:this={elements[value.toUpperCase()]}>{value}</span>
{/if}
<button onclick={() => (value = undefined)}>clear</button>

@ -0,0 +1,25 @@
import { flushSync } from '../../../../src/index-client';
import { test } from '../../test';
export default test({
html: `<div id="editable" contenteditable="true"></div><p id="output"></p>`,
test({ assert, target }) {
const div = /** @type {HTMLDivElement} */ (target.querySelector('#editable'));
const output = /** @type {HTMLParagraphElement} */ (target.querySelector('#output'));
// Simulate user typing by directly modifying the DOM
div.textContent = 'hello';
// Simulate blur which triggers `content = e.currentTarget.innerText`
const event = new Event('blur');
div.dispatchEvent(event);
flushSync();
// The output should show "hello" (innerText was set correctly)
assert.equal(output.textContent, 'hello');
// The contenteditable div should contain "hello" once, not duplicated
assert.htmlEqual(div.innerHTML, 'hello');
}
});

@ -0,0 +1,9 @@
<script>
let content = $state("");
</script>
<div id="editable" onblur={(e) => { content = e.currentTarget.textContent; }} contenteditable="true">
{@html content}
</div>
<p id="output">{content}</p>

@ -0,0 +1,35 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
html: `<p>before</p><p>during</p><p>after</p>`,
async test({ target, assert }) {
const ps = target.querySelectorAll('p');
// @ts-expect-error
assert.deepEqual(ps[0].__svelte_meta.loc, {
file: 'main.svelte',
line: 1,
column: 0
});
// @ts-expect-error
assert.deepEqual(ps[1].__svelte_meta.loc, {
file: 'main.svelte',
line: 6,
column: 1
});
// @ts-expect-error
assert.deepEqual(ps[2].__svelte_meta.loc, {
file: 'main.svelte',
line: 11,
column: 0
});
}
});

@ -0,0 +1,11 @@
<p>before</p>
{#if false}
<p>during</p>
{:else if true}
<p>during</p>
{:else if false}
<p>during</p>
{/if}
<p>after</p>

@ -0,0 +1,14 @@
import { tick } from 'svelte';
import { test } from '../../test';
import { raf } from '../../../animation-helpers';
export default test({
async test({ assert, target }) {
const [btn] = target.querySelectorAll('button');
btn.click();
await tick();
raf.tick(100);
assert.htmlEqual(target.innerHTML, `<button>clear</button>`);
}
});

@ -0,0 +1,17 @@
<script>
import { fade } from 'svelte/transition';
let data = $state({ id: 1 });
</script>
<button onclick={() => (data = null)}>clear</button>
{#if data}
{#key data?.id}
<p transition:fade|global={{ duration: 100 }}>keyed</p>
{/key}
{#if data.id}
<p>sibling</p>
{/if}
{/if}

@ -3,7 +3,7 @@ import * as $ from 'svelte/internal/server';
export default function Async_const($$renderer) {
if (true) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
let a;
let b;
@ -22,7 +22,7 @@ export default function Async_const($$renderer) {
$$renderer.async([promises[1]], ($$renderer) => $$renderer.push(() => $.escape(b)));
$$renderer.push(`</p>`);
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
}
$$renderer.push(`<!--]-->`);

@ -22,7 +22,7 @@ export default function Async_if_alternate_hoisting($$anchor) {
};
$.if(node, ($$render) => {
if ($.get($$condition)) $$render(consequent); else $$render(alternate, false);
if ($.get($$condition)) $$render(consequent); else $$render(alternate, -1);
});
});

@ -4,10 +4,10 @@ import * as $ from 'svelte/internal/server';
export default function Async_if_alternate_hoisting($$renderer) {
$$renderer.child_block(async ($$renderer) => {
if ((await $.save(Promise.resolve(false)))()) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
$$renderer.push(async () => $.escape(await Promise.reject('no no no')));
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
$$renderer.push(async () => $.escape(await Promise.resolve('yes yes yes')));
}
});

@ -35,7 +35,7 @@ export default function Async_if_chain($$anchor) {
};
$.if(node, ($$render) => {
if (foo) $$render(consequent); else if (bar) $$render(consequent_1, 1); else $$render(alternate, false);
if (foo) $$render(consequent); else if (bar) $$render(consequent_1, 1); else $$render(alternate, -1);
});
});
@ -74,7 +74,7 @@ export default function Async_if_chain($$anchor) {
$.if(
node_2,
($$render) => {
if ($.get($$condition)) $$render(consequent_4); else $$render(alternate_1, false);
if ($.get($$condition)) $$render(consequent_4); else $$render(alternate_1, -1);
},
true
);
@ -84,7 +84,7 @@ export default function Async_if_chain($$anchor) {
};
$.if(node_1, ($$render) => {
if ($.get($$condition)) $$render(consequent_2); else if (bar) $$render(consequent_3, 1); else $$render(alternate_2, false);
if ($.get($$condition)) $$render(consequent_2); else if (bar) $$render(consequent_3, 1); else $$render(alternate_2, -1);
});
});
@ -123,7 +123,7 @@ export default function Async_if_chain($$anchor) {
$.if(
node_4,
($$render) => {
if ($.get($$condition)) $$render(consequent_7); else $$render(alternate_3, false);
if ($.get($$condition)) $$render(consequent_7); else $$render(alternate_3, -1);
},
true
);
@ -133,7 +133,7 @@ export default function Async_if_chain($$anchor) {
};
$.if(node_3, ($$render) => {
if ($.get($$condition)) $$render(consequent_5); else if (bar) $$render(consequent_6, 1); else $$render(alternate_4, false);
if ($.get($$condition)) $$render(consequent_5); else if (bar) $$render(consequent_6, 1); else $$render(alternate_4, -1);
});
});
@ -167,7 +167,7 @@ export default function Async_if_chain($$anchor) {
};
$.if(node_5, ($$render) => {
if (simple1) $$render(consequent_8); else if (simple2 > 10) $$render(consequent_9, 1); else if ($.get(d)) $$render(consequent_10, 2); else $$render(alternate_5, false);
if (simple1) $$render(consequent_8); else if (simple2 > 10) $$render(consequent_9, 1); else if ($.get(d)) $$render(consequent_10, 2); else $$render(alternate_5, -1);
});
}
@ -193,7 +193,7 @@ export default function Async_if_chain($$anchor) {
};
$.if(node_6, ($$render) => {
if ($.get(blocking) > 10) $$render(consequent_11); else if ($.get(blocking) > 5) $$render(consequent_12, 1); else $$render(alternate_6, false);
if ($.get(blocking) > 10) $$render(consequent_11); else if ($.get(blocking) > 5) $$render(consequent_12, 1); else $$render(alternate_6, -1);
});
});

@ -12,13 +12,13 @@ export default function Async_if_chain($$renderer) {
$$renderer.async_block([$$promises[0]], ($$renderer) => {
if (foo) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
$$renderer.push(`foo`);
} else if (bar) {
$$renderer.push('<!--[1-->');
$$renderer.push(`bar`);
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
$$renderer.push(`else`);
}
});
@ -27,20 +27,20 @@ export default function Async_if_chain($$renderer) {
$$renderer.async_block([$$promises[0]], async ($$renderer) => {
if ((await $.save(foo))()) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
$$renderer.push(`foo`);
} else if (bar) {
$$renderer.push('<!--[1-->');
$$renderer.push(`bar`);
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
$$renderer.child_block(async ($$renderer) => {
if ((await $.save(baz))()) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
$$renderer.push(`baz`);
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
$$renderer.push(`else`);
}
});
@ -53,20 +53,20 @@ export default function Async_if_chain($$renderer) {
$$renderer.async_block([$$promises[0]], async ($$renderer) => {
if ((await $.save(foo))() > 10) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
$$renderer.push(`foo`);
} else if (bar) {
$$renderer.push('<!--[1-->');
$$renderer.push(`bar`);
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
$$renderer.async_block([$$promises[0]], async ($$renderer) => {
if ((await $.save(foo))() > 5) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
$$renderer.push(`baz`);
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
$$renderer.push(`else`);
}
});
@ -78,7 +78,7 @@ export default function Async_if_chain($$renderer) {
$$renderer.push(`<!--]--> `);
if (simple1) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
$$renderer.push(`foo`);
} else if (simple2 > 10) {
$$renderer.push('<!--[1-->');
@ -87,7 +87,7 @@ export default function Async_if_chain($$renderer) {
$$renderer.push('<!--[2-->');
$$renderer.push(`baz`);
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
$$renderer.push(`else`);
}
@ -95,13 +95,13 @@ export default function Async_if_chain($$renderer) {
$$renderer.async_block([$$promises[0]], ($$renderer) => {
if (blocking() > 10) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
$$renderer.push(`foo`);
} else if (blocking() > 5) {
$$renderer.push('<!--[1-->');
$$renderer.push(`bar`);
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
$$renderer.push(`else`);
}
});

@ -22,7 +22,7 @@ export default function Async_if_hoisting($$anchor) {
};
$.if(node, ($$render) => {
if ($.get($$condition)) $$render(consequent); else $$render(alternate, false);
if ($.get($$condition)) $$render(consequent); else $$render(alternate, -1);
});
});

@ -4,10 +4,10 @@ import * as $ from 'svelte/internal/server';
export default function Async_if_hoisting($$renderer) {
$$renderer.child_block(async ($$renderer) => {
if ((await $.save(Promise.resolve(true)))()) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
$$renderer.push(async () => $.escape(await Promise.resolve('yes yes yes')));
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
$$renderer.push(async () => $.escape(await Promise.reject('no no no')));
}
});

@ -18,7 +18,7 @@ export default function Async_in_derived($$renderer, $$props) {
]);
if (true) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
let yes1;
let yes2;
@ -47,7 +47,7 @@ export default function Async_in_derived($$renderer, $$props) {
}
]);
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
}
$$renderer.push(`<!--]-->`);

@ -57,13 +57,13 @@ export default function Select_with_rich_content($$renderer) {
$$renderer.push(`<!--]--></select> <select>`);
if (show) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
$$renderer.option({}, ($$renderer) => {
$$renderer.push(`Visible`);
});
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
}
$$renderer.push(`<!--]--></select> <select><!---->`);
@ -148,7 +148,7 @@ export default function Select_with_rich_content($$renderer) {
$$renderer.push(`<!--]--></select> <select>`);
if (show) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
$$renderer.push(`<!--[-->`);
const each_array_4 = $.ensure_array_like(items);
@ -161,7 +161,7 @@ export default function Select_with_rich_content($$renderer) {
$$renderer.push(`<!--]-->`);
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
}
$$renderer.push(`<!--]--></select> <select>`);
@ -227,10 +227,10 @@ export default function Select_with_rich_content($$renderer) {
$$renderer.push(`<!--]--><!></select> <select>`);
if (show) {
$$renderer.push('<!--[-->');
$$renderer.push('<!--[0-->');
conditional_option($$renderer);
} else {
$$renderer.push('<!--[!-->');
$$renderer.push('<!--[-1-->');
}
$$renderer.push(`<!--]--><!></select>`);

@ -1293,8 +1293,8 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
enhanced-resolve@5.19.0:
resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
enhanced-resolve@5.20.0:
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
engines: {node: '>=10.13.0'}
enquirer@2.4.1:
@ -1604,8 +1604,8 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immutable@4.3.7:
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
immutable@4.3.8:
resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
@ -3589,7 +3589,7 @@ snapshots:
emoji-regex@9.2.2: {}
enhanced-resolve@5.19.0:
enhanced-resolve@5.20.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
@ -3685,7 +3685,7 @@ snapshots:
eslint-plugin-n@17.24.0(eslint@10.0.0)(typescript@5.5.4):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0)
enhanced-resolve: 5.19.0
enhanced-resolve: 5.20.0
eslint: 10.0.0
eslint-plugin-es-x: 7.8.0(eslint@10.0.0)
get-tsconfig: 4.13.6
@ -3974,7 +3974,7 @@ snapshots:
ignore@7.0.5: {}
immutable@4.3.7:
immutable@4.3.8:
optional: true
imurmurhash@0.1.4: {}
@ -4450,7 +4450,7 @@ snapshots:
sass@1.70.0:
dependencies:
chokidar: 3.6.0
immutable: 4.3.7
immutable: 4.3.8
source-map-js: 1.2.1
optional: true

Loading…
Cancel
Save