perf: cache element interactivity and source line splitting (#17839)

## 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 <noreply@anthropic.com>
pull/17843/head
Mathias Picker 5 months ago committed by GitHub
parent 4aa3777271
commit 791d5e332c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: cache element interactivity and source line splitting in compiler

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

@ -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 = [];

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

Loading…
Cancel
Save