Skip to content

fix: stable attachments #15961

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
May 28, 2025
5 changes: 5 additions & 0 deletions .changeset/soft-jobs-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: avoid rerunning attachments when unrelated spread attributes change
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { build_getter } from '../utils.js';
import {
get_attribute_name,
build_attribute_value,
build_set_attributes,
build_attribute_effect,
build_set_class,
build_set_style
} from './shared/element.js';
Expand Down Expand Up @@ -201,37 +201,7 @@ export function RegularElement(node, context) {
const node_id = context.state.node;

if (has_spread) {
const attributes_id = b.id(context.state.scope.generate('attributes'));

build_set_attributes(
attributes,
class_directives,
style_directives,
context,
node,
node_id,
attributes_id
);

// If value binding exists, that one takes care of calling $.init_select
if (node.name === 'select' && !bindings.has('value')) {
context.state.init.push(
b.stmt(b.call('$.init_select', node_id, b.thunk(b.member(attributes_id, 'value'))))
);

context.state.update.push(
b.if(
b.binary('in', b.literal('value'), attributes_id),
b.block([
// This ensures a one-way street to the DOM in case it's <select {value}>
// and not <select bind:value>. We need it in addition to $.init_select
// because the select value is not reflected as an attribute, so the
// mutation observer wouldn't notice.
b.stmt(b.call('$.select_option', node_id, b.member(attributes_id, 'value')))
])
)
);
}
build_attribute_effect(attributes, class_directives, style_directives, context, node, node_id);
} else {
/** If true, needs `__value` for inputs */
const needs_special_value_handling =
Expand Down Expand Up @@ -290,7 +260,8 @@ export function RegularElement(node, context) {
const { value, has_state } = build_attribute_value(
attribute.value,
context,
(value, metadata) => (metadata.has_call ? get_expression_id(context.state, value) : value)
(value, metadata) =>
metadata.has_call ? get_expression_id(context.state.expressions, value) : value
);

const update = build_element_attribute_update(node, node_id, name, value, attributes);
Expand Down Expand Up @@ -482,10 +453,11 @@ function setup_select_synchronization(value_binding, context) {

/**
* @param {AST.ClassDirective[]} class_directives
* @param {Expression[]} expressions
* @param {ComponentContext} context
* @return {ObjectExpression | Identifier}
*/
export function build_class_directives_object(class_directives, context) {
export function build_class_directives_object(class_directives, expressions, context) {
let properties = [];
let has_call_or_state = false;

Expand All @@ -497,15 +469,16 @@ export function build_class_directives_object(class_directives, context) {

const directives = b.object(properties);

return has_call_or_state ? get_expression_id(context.state, directives) : directives;
return has_call_or_state ? get_expression_id(expressions, directives) : directives;
}

/**
* @param {AST.StyleDirective[]} style_directives
* @param {Expression[]} expressions
* @param {ComponentContext} context
* @return {ObjectExpression | ArrayExpression}}
*/
export function build_style_directives_object(style_directives, context) {
export function build_style_directives_object(style_directives, expressions, context) {
let normal_properties = [];
let important_properties = [];

Expand All @@ -514,7 +487,7 @@ export function build_style_directives_object(style_directives, context) {
directive.value === true
? build_getter({ name: directive.name, type: 'Identifier' }, context.state)
: build_attribute_value(directive.value, context, (value, metadata) =>
metadata.has_call ? get_expression_id(context.state, value) : value
metadata.has_call ? get_expression_id(expressions, value) : value
).value;
const property = b.init(directive.name, expression);

Expand Down Expand Up @@ -653,7 +626,7 @@ function build_element_special_value_attribute(element, node_id, attribute, cont
? // if is a select with value we will also invoke `init_select` which need a reference before the template effect so we memoize separately
is_select_with_value
? memoize_expression(state, value)
: get_expression_id(state, value)
: get_expression_id(state.expressions, value)
: value
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { dev, locator } from '../../../../state.js';
import { is_text_attribute } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { determine_namespace_for_children } from '../../utils.js';
import { build_attribute_value, build_set_attributes, build_set_class } from './shared/element.js';
import {
build_attribute_value,
build_attribute_effect,
build_set_class
} from './shared/element.js';
import { build_render_statement } from './shared/utils.js';

/**
Expand Down Expand Up @@ -80,18 +84,15 @@ export function SvelteElement(node, context) {
) {
build_set_class(node, element_id, attributes[0], class_directives, inner_context, false);
} else if (attributes.length) {
const attributes_id = b.id(context.state.scope.generate('attributes'));

// Always use spread because we don't know whether the element is a custom element or not,
// therefore we need to do the "how to set an attribute" logic at runtime.
build_set_attributes(
build_attribute_effect(
attributes,
class_directives,
style_directives,
inner_context,
node,
element_id,
attributes_id
element_id
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,30 @@ import { build_template_chunk, get_expression_id } from './utils.js';
* @param {ComponentContext} context
* @param {AST.RegularElement | AST.SvelteElement} element
* @param {Identifier} element_id
* @param {Identifier} attributes_id
*/
export function build_set_attributes(
export function build_attribute_effect(
attributes,
class_directives,
style_directives,
context,
element,
element_id,
attributes_id
element_id
) {
let is_dynamic = false;

/** @type {ObjectExpression['properties']} */
const values = [];

/** @type {Expression[]} */
const expressions = [];

/** @param {Expression} value */
function memoize(value) {
return b.id(`$${expressions.push(value) - 1}`);
}

for (const attribute of attributes) {
if (attribute.type === 'Attribute') {
const { value, has_state } = build_attribute_value(
attribute.value,
context,
(value, metadata) => (metadata.has_call ? get_expression_id(context.state, value) : value)
const { value } = build_attribute_value(attribute.value, context, (value, metadata) =>
metadata.has_call ? memoize(value) : value
);

if (
Expand All @@ -51,16 +53,11 @@ export function build_set_attributes(
} else {
values.push(b.init(attribute.name, value));
}

is_dynamic ||= has_state;
} else {
// objects could contain reactive getters -> play it safe and always assume spread attributes are reactive
is_dynamic = true;

let value = /** @type {Expression} */ (context.visit(attribute));

if (attribute.metadata.expression.has_call) {
value = get_expression_id(context.state, value);
value = memoize(value);
}

values.push(b.spread(value));
Expand All @@ -72,44 +69,38 @@ export function build_set_attributes(
b.prop(
'init',
b.array([b.id('$.CLASS')]),
build_class_directives_object(class_directives, context)
build_class_directives_object(class_directives, expressions, context)
)
);

is_dynamic ||=
class_directives.find((directive) => directive.metadata.expression.has_state) !== null;
}

if (style_directives.length) {
values.push(
b.prop(
'init',
b.array([b.id('$.STYLE')]),
build_style_directives_object(style_directives, context)
build_style_directives_object(style_directives, expressions, context)
)
);

is_dynamic ||= style_directives.some((directive) => directive.metadata.expression.has_state);
}

const call = b.call(
'$.set_attributes',
element_id,
is_dynamic ? attributes_id : b.null,
b.object(values),
element.metadata.scoped &&
context.state.analysis.css.hash !== '' &&
b.literal(context.state.analysis.css.hash),
is_ignored(element, 'hydration_attribute_changed') && b.true
context.state.init.push(
b.stmt(
b.call(
'$.attribute_effect',
element_id,
b.arrow(
expressions.map((_, i) => b.id(`$${i}`)),
b.object(values)
),
expressions.length > 0 && b.array(expressions.map((expression) => b.thunk(expression))),
element.metadata.scoped &&
context.state.analysis.css.hash !== '' &&
b.literal(context.state.analysis.css.hash),
is_ignored(element, 'hydration_attribute_changed') && b.true
)
)
);

if (is_dynamic) {
context.state.init.push(b.let(attributes_id));
const update = b.stmt(b.assignment('=', attributes_id, call));
context.state.update.push(update);
} else {
context.state.init.push(b.stmt(call));
}
}

/**
Expand Down Expand Up @@ -167,7 +158,7 @@ export function build_set_class(element, node_id, attribute, class_directives, c
value = b.call('$.clsx', value);
}

return metadata.has_call ? get_expression_id(context.state, value) : value;
return metadata.has_call ? get_expression_id(context.state.expressions, value) : value;
});

/** @type {Identifier | undefined} */
Expand All @@ -180,7 +171,7 @@ export function build_set_class(element, node_id, attribute, class_directives, c
let next;

if (class_directives.length) {
next = build_class_directives_object(class_directives, context);
next = build_class_directives_object(class_directives, context.state.expressions, context);
has_state ||= class_directives.some((d) => d.metadata.expression.has_state);

if (has_state) {
Expand Down Expand Up @@ -235,7 +226,7 @@ export function build_set_class(element, node_id, attribute, class_directives, c
*/
export function build_set_style(node_id, attribute, style_directives, context) {
let { value, has_state } = build_attribute_value(attribute.value, context, (value, metadata) =>
metadata.has_call ? get_expression_id(context.state, value) : value
metadata.has_call ? get_expression_id(context.state.expressions, value) : value
);

/** @type {Identifier | undefined} */
Expand All @@ -248,7 +239,7 @@ export function build_set_style(node_id, attribute, style_directives, context) {
let next;

if (style_directives.length) {
next = build_style_directives_object(style_directives, context);
next = build_style_directives_object(style_directives, context.state.expressions, context);
has_state ||= style_directives.some((d) => d.metadata.expression.has_state);

if (has_state) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ export function memoize_expression(state, value) {
}

/**
*
* @param {ComponentClientTransformState} state
* Pushes `value` into `expressions` and returns a new id
* @param {Expression[]} expressions
* @param {Expression} value
*/
export function get_expression_id(state, value) {
return b.id(`$${state.expressions.push(value) - 1}`);
export function get_expression_id(expressions, value) {
return b.id(`$${expressions.push(value) - 1}`);
}

/**
Expand All @@ -40,7 +40,8 @@ export function build_template_chunk(
values,
visit,
state,
memoize = (value, metadata) => (metadata.has_call ? get_expression_id(state, value) : value)
memoize = (value, metadata) =>
metadata.has_call ? get_expression_id(state.expressions, value) : value
) {
/** @type {Expression[]} */
const expressions = [];
Expand Down
Loading