Merge branch 'main' into refactor-scheduling

pull/17805/head
Rich Harris 6 months ago
commit b8a3767bc5

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: optimize CSS selector pruning

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: preserve original boundary errors when keyed each rows are removed during async updates

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: avoid O(n²) name scanning in scope `generate` and `unique`

@ -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') {

@ -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;

@ -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;

@ -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,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>
Loading…
Cancel
Save