Skip to content

Commit 1043f79

Browse files
MathiasWPclaude
andauthored
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 <[email protected]>
1 parent b6faa2a commit 1043f79

4 files changed

Lines changed: 45 additions & 17 deletions

File tree

.changeset/tidy-brooms-train.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
perf: optimize compiler analysis phase

packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,9 @@ const seen = new Set();
125125
/**
126126
*
127127
* @param {Compiler.AST.CSS.StyleSheet} stylesheet
128-
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
128+
* @param {Iterable<Compiler.AST.RegularElement | Compiler.AST.SvelteElement>} elements
129129
*/
130-
export function prune(stylesheet, element) {
130+
export function prune(stylesheet, elements) {
131131
walk(/** @type {Compiler.AST.CSS.Node} */ (stylesheet), null, {
132132
Rule(node, context) {
133133
if (node.metadata.is_global_block) {
@@ -139,17 +139,19 @@ export function prune(stylesheet, element) {
139139
ComplexSelector(node) {
140140
const selectors = get_relative_selectors(node);
141141

142-
seen.clear();
142+
for (const element of elements) {
143+
seen.clear();
143144

144-
if (
145-
apply_selector(
146-
selectors,
147-
/** @type {Compiler.AST.CSS.Rule} */ (node.metadata.rule),
148-
element,
149-
BACKWARD
150-
)
151-
) {
152-
node.metadata.used = true;
145+
if (
146+
apply_selector(
147+
selectors,
148+
/** @type {Compiler.AST.CSS.Rule} */ (node.metadata.rule),
149+
element,
150+
BACKWARD
151+
)
152+
) {
153+
node.metadata.used = true;
154+
}
153155
}
154156

155157
// note: we don't call context.next() here, we only recurse into

packages/svelte/src/compiler/phases/2-analyze/index.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { prune } from './css/css-prune.js';
2121
import { hash, is_rune } from '../../../utils.js';
2222
import { warn_unused } from './css/css-warn.js';
2323
import { extract_svelte_ignore } from '../../utils/extract_svelte_ignore.js';
24-
import { ignore_map, ignore_stack, pop_ignore, push_ignore } from '../../state.js';
24+
import { ignore_map, get_ignore_snapshot, pop_ignore, push_ignore } from '../../state.js';
2525
import { ArrowFunctionExpression } from './visitors/ArrowFunctionExpression.js';
2626
import { AssignmentExpression } from './visitors/AssignmentExpression.js';
2727
import { AnimateDirective } from './visitors/AnimateDirective.js';
@@ -134,7 +134,7 @@ const visitors = {
134134
push_ignore(ignores);
135135
}
136136

137-
ignore_map.set(node, structuredClone(ignore_stack));
137+
ignore_map.set(node, get_ignore_snapshot());
138138

139139
const scope = state.scopes.get(node);
140140
next(scope !== undefined && scope !== state.scope ? { ...state, scope } : state);
@@ -856,9 +856,7 @@ export function analyze_component(root, source, options) {
856856
analyze_css(analysis.css.ast, analysis);
857857

858858
// mark nodes as scoped/unused/empty etc
859-
for (const node of analysis.elements) {
860-
prune(analysis.css.ast, node);
861-
}
859+
prune(analysis.css.ast, analysis.elements);
862860

863861
const { comment } = analysis.css.ast.content;
864862
const should_ignore_unused =

packages/svelte/src/compiler/state.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,38 @@ export let ignore_stack = [];
8282
*/
8383
export let ignore_map = new Map();
8484

85+
/**
86+
* Cached snapshot of the ignore_stack. Only re-created when the stack changes
87+
* (i.e. when push_ignore or pop_ignore is called), avoiding a structuredClone
88+
* on every node visit during analysis.
89+
* @type {Set<string>[] | null}
90+
*/
91+
let cached_ignore_snapshot = null;
92+
93+
/**
94+
* Returns a snapshot of the current ignore_stack, reusing a cached copy
95+
* when the stack hasn't changed since the last call.
96+
* @returns {Set<string>[]}
97+
*/
98+
export function get_ignore_snapshot() {
99+
if (cached_ignore_snapshot === null) {
100+
cached_ignore_snapshot = ignore_stack.map((s) => new Set(s));
101+
}
102+
return cached_ignore_snapshot;
103+
}
104+
85105
/**
86106
* @param {string[]} ignores
87107
*/
88108
export function push_ignore(ignores) {
89109
const next = new Set([...(ignore_stack.at(-1) || []), ...ignores]);
90110
ignore_stack.push(next);
111+
cached_ignore_snapshot = null;
91112
}
92113

93114
export function pop_ignore() {
94115
ignore_stack.pop();
116+
cached_ignore_snapshot = null;
95117
}
96118

97119
/**
@@ -141,4 +163,5 @@ export function adjust(state) {
141163

142164
ignore_stack = [];
143165
ignore_map.clear();
166+
cached_ignore_snapshot = null;
144167
}

0 commit comments

Comments
 (0)