From 791d5e332c08d808bc51e90d7c60cc8b05bfa1f4 Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:17:43 +0100 Subject: [PATCH] perf: cache element interactivity and source line splitting (#17839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Two small compiler optimizations that reduce redundant work: - **Cache `element_interactivity` per element in a11y checks**: `check_element` was calling `element_interactivity()` up to 10 times per element (via `is_interactive_element`, `is_non_interactive_element`, `is_static_element` wrappers), each time re-iterating schema arrays. Now computed once after building the attribute map and reused. The now-unused wrapper functions are removed. - **Split source lines once in `state.set_source`**: Every compiler warning called `get_code_frame` which split the entire source string with `source.split('\n')`. Now the split happens once in `set_source()` and is exported as `state.source_lines`, naturally cleared by `reset()`. ## Benchmark Synthetic component (80 state vars, 30 each blocks, ~1300 lines): ``` Min Best3 Median Before 66.55ms 67.03ms 73.44ms After 61.14ms 61.90ms 70.63ms Improvement 8.1% 7.7% 3.8% ``` Realistic component (~80 lines, ~25 elements, few warnings): **~1-4%** improvement. The a11y cache scales with element count, the source.split saving scales with warning count. ## Test plan - [x] All 326 validator tests pass (includes all a11y tests) - [x] All 5671 runtime tests pass - [x] 145 compiler-error tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- .changeset/calm-clouds-wave.md | 5 ++ .../2-analyze/visitors/shared/a11y/index.js | 57 +++++-------------- packages/svelte/src/compiler/state.js | 8 +++ .../src/compiler/utils/compile_diagnostic.js | 7 +-- 4 files changed, 31 insertions(+), 46 deletions(-) create mode 100644 .changeset/calm-clouds-wave.md diff --git a/.changeset/calm-clouds-wave.md b/.changeset/calm-clouds-wave.md new file mode 100644 index 0000000000..35f13d7ecb --- /dev/null +++ b/.changeset/calm-clouds-wave.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: cache element interactivity and source line splitting in compiler diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js index 45de8b10a1..be3af1e59f 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/a11y/index.js @@ -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} 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} 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} 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 diff --git a/packages/svelte/src/compiler/state.js b/packages/svelte/src/compiler/state.js index c380143f4f..5ae001ec50 100644 --- a/packages/svelte/src/compiler/state.js +++ b/packages/svelte/src/compiler/state.js @@ -32,6 +32,12 @@ export let component_name = ''; */ 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 = []; diff --git a/packages/svelte/src/compiler/utils/compile_diagnostic.js b/packages/svelte/src/compiler/utils/compile_diagnostic.js index c5df49e01c..95d028ee35 100644 --- a/packages/svelte/src/compiler/utils/compile_diagnostic.js +++ b/packages/svelte/src/compiler/utils/compile_diagnostic.js @@ -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); } } }