perf: optimize compiler analysis phase (#17823)

## Summary

Two optimizations to the compiler's analysis phase:

- **Cache `ignore_stack` snapshots instead of `structuredClone` on every
node.** The universal `_` visitor in the analysis walk runs on every AST
node and calls `structuredClone(ignore_stack)` each time. In practice,
`svelte-ignore` comments are rare (0–5 per component), so 99%+ of nodes
deep-clone an unchanged stack. This adds a copy-on-write cache that only
re-creates the snapshot when `push_ignore`/`pop_ignore` actually change
the stack.

- **Walk the CSS stylesheet once instead of once per element.**
`prune()` was called in a loop for each element, each time doing a full
`walk()` of the stylesheet AST. This restructures the loop so the
stylesheet is walked once, and the element iteration happens inside the
`ComplexSelector` visitor.

## Benchmarks

Compiled each component 500 times (after 50 warmup iterations),
measuring average time per `compile()` call:

| Component | Before | After | Speedup |
|---|---|---|---|
| `has` (80+ CSS selectors, 12 elements) | 3.405 ms | 2.680 ms | **21%
faster** |
| `siblings-combinator-each-nested` (65 CSS rules, 15 elements) | 2.034
ms | 1.575 ms | **23% faster** |
| synthetic (100 CSS rules, 50 elements) | 10.099 ms | 4.564 ms | **55%
faster** |

The CSS pruning optimization scales with `elements × CSS rules` — the
more elements a component has, the bigger the win since we go from N
stylesheet walks down to 1. The `structuredClone` fix helps every
component regardless of CSS, eliminating ~500–2000 deep clones per
compile (one per AST node) and replacing them with 0–5 (one per
`svelte-ignore` comment).

For typical real-world components with 10–20 elements and some CSS,
expect roughly **20–30% faster compilation** in the analysis phase.

## Test plan

- [x] Full test suite passes (7329 tests, 0 failures)
- [x] CSS pruning tests pass (selector matching, scoping, unused rule
detection)
- [x] `svelte-ignore` behavior unchanged (snapshot is consumed read-only
via `.has()`/`.some()`)


🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
pull/17812/head
Mathias Picker 5 months ago committed by GitHub
parent b6faa2a905
commit 1043f79d1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
perf: optimize compiler analysis phase

@ -125,9 +125,9 @@ const seen = new Set();
/**
*
* @param {Compiler.AST.CSS.StyleSheet} stylesheet
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
* @param {Iterable<Compiler.AST.RegularElement | Compiler.AST.SvelteElement>} elements
*/
export function prune(stylesheet, element) {
export function prune(stylesheet, elements) {
walk(/** @type {Compiler.AST.CSS.Node} */ (stylesheet), null, {
Rule(node, context) {
if (node.metadata.is_global_block) {
@ -139,17 +139,19 @@ export function prune(stylesheet, element) {
ComplexSelector(node) {
const selectors = get_relative_selectors(node);
seen.clear();
for (const element of elements) {
seen.clear();
if (
apply_selector(
selectors,
/** @type {Compiler.AST.CSS.Rule} */ (node.metadata.rule),
element,
BACKWARD
)
) {
node.metadata.used = true;
if (
apply_selector(
selectors,
/** @type {Compiler.AST.CSS.Rule} */ (node.metadata.rule),
element,
BACKWARD
)
) {
node.metadata.used = true;
}
}
// note: we don't call context.next() here, we only recurse into

@ -21,7 +21,7 @@ import { prune } from './css/css-prune.js';
import { hash, is_rune } from '../../../utils.js';
import { warn_unused } from './css/css-warn.js';
import { extract_svelte_ignore } from '../../utils/extract_svelte_ignore.js';
import { ignore_map, ignore_stack, pop_ignore, push_ignore } from '../../state.js';
import { ignore_map, get_ignore_snapshot, pop_ignore, push_ignore } from '../../state.js';
import { ArrowFunctionExpression } from './visitors/ArrowFunctionExpression.js';
import { AssignmentExpression } from './visitors/AssignmentExpression.js';
import { AnimateDirective } from './visitors/AnimateDirective.js';
@ -134,7 +134,7 @@ const visitors = {
push_ignore(ignores);
}
ignore_map.set(node, structuredClone(ignore_stack));
ignore_map.set(node, get_ignore_snapshot());
const scope = state.scopes.get(node);
next(scope !== undefined && scope !== state.scope ? { ...state, scope } : state);
@ -856,9 +856,7 @@ export function analyze_component(root, source, options) {
analyze_css(analysis.css.ast, analysis);
// mark nodes as scoped/unused/empty etc
for (const node of analysis.elements) {
prune(analysis.css.ast, node);
}
prune(analysis.css.ast, analysis.elements);
const { comment } = analysis.css.ast.content;
const should_ignore_unused =

@ -82,16 +82,38 @@ export let ignore_stack = [];
*/
export let ignore_map = new Map();
/**
* Cached snapshot of the ignore_stack. Only re-created when the stack changes
* (i.e. when push_ignore or pop_ignore is called), avoiding a structuredClone
* on every node visit during analysis.
* @type {Set<string>[] | null}
*/
let cached_ignore_snapshot = null;
/**
* Returns a snapshot of the current ignore_stack, reusing a cached copy
* when the stack hasn't changed since the last call.
* @returns {Set<string>[]}
*/
export function get_ignore_snapshot() {
if (cached_ignore_snapshot === null) {
cached_ignore_snapshot = ignore_stack.map((s) => new Set(s));
}
return cached_ignore_snapshot;
}
/**
* @param {string[]} ignores
*/
export function push_ignore(ignores) {
const next = new Set([...(ignore_stack.at(-1) || []), ...ignores]);
ignore_stack.push(next);
cached_ignore_snapshot = null;
}
export function pop_ignore() {
ignore_stack.pop();
cached_ignore_snapshot = null;
}
/**
@ -141,4 +163,5 @@ export function adjust(state) {
ignore_stack = [];
ignore_map.clear();
cached_ignore_snapshot = null;
}

Loading…
Cancel
Save