Skip to content
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

chore: rewrite set_style() to handle directives #15418

Merged
merged 37 commits into from
Mar 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ebc286e
add style attribute when needed
adiguba Mar 2, 2025
4a3561b
set_style()
adiguba Mar 2, 2025
555f11c
to_style()
adiguba Mar 2, 2025
526a2b8
remove `style=""`
adiguba Mar 2, 2025
7a4886e
use cssTest for perfs
adiguba Mar 2, 2025
6af6978
base test
adiguba Mar 2, 2025
7804a1f
test
adiguba Mar 2, 2025
fce4169
changeset
adiguba Mar 2, 2025
84b1d7f
revert dom.style.cssText
adiguba Mar 2, 2025
d43d14a
format name
adiguba Mar 3, 2025
6895d88
use style.cssText + adapt test
adiguba Mar 3, 2025
ca07d89
Apply suggestions from code review
adiguba Mar 4, 2025
962e8bb
fix priority
adiguba Mar 4, 2025
c960c3f
Merge branch 'dev/style' of https://github.com/adiguba/svelte into de…
adiguba Mar 4, 2025
c3e9c39
lint
adiguba Mar 4, 2025
ae8975b
Merge branch 'main' into dev/style
Rich-Harris Mar 5, 2025
6a66c28
merge main
Rich-Harris Mar 5, 2025
3deff5b
yawn
Rich-Harris Mar 5, 2025
8c619af
update test
Rich-Harris Mar 5, 2025
e55db6c
we can simplify some stuff now
Rich-Harris Mar 5, 2025
5398e15
simplify
Rich-Harris Mar 5, 2025
8057475
more
Rich-Harris Mar 5, 2025
af918b2
simplify some more
Rich-Harris Mar 5, 2025
542363c
more
Rich-Harris Mar 5, 2025
1aa17c5
more
Rich-Harris Mar 5, 2025
2cc4c1a
more
Rich-Harris Mar 5, 2025
b079256
more
Rich-Harris Mar 5, 2025
2115c8c
more
Rich-Harris Mar 5, 2025
db72611
remove continue
Rich-Harris Mar 5, 2025
abbe46f
tweak
Rich-Harris Mar 5, 2025
84d7d74
tweak
Rich-Harris Mar 5, 2025
35c5003
tweak
Rich-Harris Mar 5, 2025
2bf0a22
skip hash argument where possible
Rich-Harris Mar 5, 2025
a7a17b6
tweak
Rich-Harris Mar 5, 2025
c7c1ade
tweak
Rich-Harris Mar 5, 2025
52667cc
tweak
Rich-Harris Mar 5, 2025
4894e3d
tweak
Rich-Harris Mar 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strange-planes-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: make `style:` directive and CSS handling more robust
26 changes: 24 additions & 2 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -769,17 +769,24 @@ export function analyze_component(root, source, options) {
}

let has_class = false;
let has_style = false;
let has_spread = false;
let has_class_directive = false;
let has_style_directive = false;

for (const attribute of node.attributes) {
// The spread method appends the hash to the end of the class attribute on its own
if (attribute.type === 'SpreadAttribute') {
has_spread = true;
break;
} else if (attribute.type === 'Attribute') {
has_class ||= attribute.name.toLowerCase() === 'class';
has_style ||= attribute.name.toLowerCase() === 'style';
} else if (attribute.type === 'ClassDirective') {
has_class_directive = true;
} else if (attribute.type === 'StyleDirective') {
has_style_directive = true;
}
has_class_directive ||= attribute.type === 'ClassDirective';
has_class ||= attribute.type === 'Attribute' && attribute.name.toLowerCase() === 'class';
}

// We need an empty class to generate the set_class() or class="" correctly
Expand All @@ -796,6 +803,21 @@ export function analyze_component(root, source, options) {
])
);
}

// We need an empty style to generate the set_style() correctly
if (!has_spread && !has_style && has_style_directive) {
node.attributes.push(
create_attribute('style', -1, -1, [
{
type: 'Text',
data: '',
raw: '',
start: -1,
end: -1
}
])
);
}
}

// TODO
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** @import { Expression, ExpressionStatement, Identifier, MemberExpression, ObjectExpression, Statement } from 'estree' */
/** @import { ArrayExpression, Expression, ExpressionStatement, Identifier, MemberExpression, ObjectExpression, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { SourceLocation } from '#shared' */
/** @import { ComponentClientTransformState, ComponentContext } from '../types' */
Expand All @@ -20,9 +20,9 @@ import { build_getter } from '../utils.js';
import {
get_attribute_name,
build_attribute_value,
build_style_directives,
build_set_attributes,
build_set_class
build_set_class,
build_set_style
} from './shared/element.js';
import { process_children } from './shared/fragment.js';
import {
Expand Down Expand Up @@ -215,13 +215,18 @@ export function RegularElement(node, context) {

const node_id = context.state.node;

// Then do attributes
let is_attributes_reactive = has_spread;

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

build_set_attributes(attributes, class_directives, context, node, node_id, attributes_id);
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')) {
Expand Down Expand Up @@ -262,11 +267,13 @@ export function RegularElement(node, context) {
}

const name = get_attribute_name(node, attribute);

if (
!is_custom_element &&
!cannot_be_set_statically(attribute.name) &&
(attribute.value === true || is_text_attribute(attribute)) &&
(name !== 'class' || class_directives.length === 0)
(name !== 'class' || class_directives.length === 0) &&
(name !== 'style' || style_directives.length === 0)
) {
let value = is_text_attribute(attribute) ? attribute.value[0].data : true;

Expand All @@ -287,27 +294,30 @@ export function RegularElement(node, context) {
}`
);
}
continue;
}
} else if (name === 'autofocus') {
let { value } = build_attribute_value(attribute.value, context);
context.state.init.push(b.stmt(b.call('$.autofocus', node_id, value)));
} else if (name === 'class') {
const is_html = context.state.metadata.namespace === 'html' && node.name !== 'svg';
build_set_class(node, node_id, attribute, class_directives, context, is_html);
} else if (name === 'style') {
build_set_style(node_id, attribute, style_directives, context);
} else if (is_custom_element) {
build_custom_element_attribute_update_assignment(node_id, attribute, context);
} else {
const { value, has_state } = build_attribute_value(
attribute.value,
context,
(value, metadata) => (metadata.has_call ? get_expression_id(context.state, value) : value)
);

const update = build_element_attribute_update(node, node_id, name, value, attributes);

const is =
is_custom_element && name !== 'class'
? build_custom_element_attribute_update_assignment(node_id, attribute, context)
: build_element_attribute_update_assignment(
node,
node_id,
attribute,
attributes,
class_directives,
context
);
if (is) is_attributes_reactive = true;
(has_state ? context.state.update : context.state.init).push(b.stmt(update));
}
}
}

// style directives must be applied last since they could override class/style attributes
build_style_directives(style_directives, node_id, context, is_attributes_reactive);

if (
is_load_error_element(node.name) &&
(has_spread || has_use || lookup.has('onload') || lookup.has('onerror'))
Expand Down Expand Up @@ -519,6 +529,36 @@ export function build_class_directives_object(class_directives, context) {
return b.object(properties);
}

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

for (const directive of style_directives) {
const expression =
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
).value;
const property = b.init(directive.name, expression);

if (directive.modifiers.includes('important')) {
important_properties.push(property);
} else {
normal_properties.push(property);
}
}

return important_properties.length
? b.array([b.object(normal_properties), b.object(important_properties)])
: b.object(normal_properties);
}

/**
* Serializes an assignment to an element property by adding relevant statements to either only
* the init or the the init and update arrays, depending on whether or not the value is dynamic.
Expand All @@ -543,73 +583,29 @@ export function build_class_directives_object(class_directives, context) {
* Returns true if attribute is deemed reactive, false otherwise.
* @param {AST.RegularElement} element
* @param {Identifier} node_id
* @param {AST.Attribute} attribute
* @param {string} name
* @param {Expression} value
* @param {Array<AST.Attribute | AST.SpreadAttribute>} attributes
* @param {AST.ClassDirective[]} class_directives
* @param {ComponentContext} context
* @returns {boolean}
*/
function build_element_attribute_update_assignment(
element,
node_id,
attribute,
attributes,
class_directives,
context
) {
const state = context.state;
const name = get_attribute_name(element, attribute);
const is_svg = context.state.metadata.namespace === 'svg' || element.name === 'svg';
const is_mathml = context.state.metadata.namespace === 'mathml';

const is_autofocus = name === 'autofocus';

let { value, has_state } = build_attribute_value(attribute.value, context, (value, metadata) =>
metadata.has_call
? // if it's autofocus we will not add this to a template effect so we don't want to get the expression id
// but separately memoize the expression
is_autofocus
? memoize_expression(state, value)
: get_expression_id(state, value)
: value
);
function build_element_attribute_update(element, node_id, name, value, attributes) {
if (name === 'muted') {
// Special case for Firefox who needs it set as a property in order to work
return b.assignment('=', b.member(node_id, b.id('muted')), value);
}

if (is_autofocus) {
state.init.push(b.stmt(b.call('$.autofocus', node_id, value)));
return false;
if (name === 'value') {
return b.call('$.set_value', node_id, value);
}

// Special case for Firefox who needs it set as a property in order to work
if (name === 'muted') {
if (!has_state) {
state.init.push(b.stmt(b.assignment('=', b.member(node_id, b.id('muted')), value)));
return false;
}
state.update.push(b.stmt(b.assignment('=', b.member(node_id, b.id('muted')), value)));
return false;
if (name === 'checked') {
return b.call('$.set_checked', node_id, value);
}

/** @type {Statement} */
let update;
if (name === 'selected') {
return b.call('$.set_selected', node_id, value);
}

if (name === 'class') {
return build_set_class(
element,
node_id,
attribute,
value,
has_state,
class_directives,
context,
!is_svg && !is_mathml
);
} else if (name === 'value') {
update = b.stmt(b.call('$.set_value', node_id, value));
} else if (name === 'checked') {
update = b.stmt(b.call('$.set_checked', node_id, value));
} else if (name === 'selected') {
update = b.stmt(b.call('$.set_selected', node_id, value));
} else if (
if (
// If we would just set the defaultValue property, it would override the value property,
// because it is set in the template which implicitly means it's also setting the default value,
// and if one updates the default value while the input is pristine it will also update the
Expand All @@ -620,62 +616,49 @@ function build_element_attribute_update_assignment(
) ||
(element.name === 'textarea' && element.fragment.nodes.length > 0))
) {
update = b.stmt(b.call('$.set_default_value', node_id, value));
} else if (
return b.call('$.set_default_value', node_id, value);
}

if (
// See defaultValue comment
name === 'defaultChecked' &&
attributes.some(
(attr) => attr.type === 'Attribute' && attr.name === 'checked' && attr.value === true
)
) {
update = b.stmt(b.call('$.set_default_checked', node_id, value));
} else if (is_dom_property(name)) {
update = b.stmt(b.assignment('=', b.member(node_id, name), value));
} else {
const callee = name.startsWith('xlink') ? '$.set_xlink_attribute' : '$.set_attribute';
update = b.stmt(
b.call(
callee,
node_id,
b.literal(name),
value,
is_ignored(element, 'hydration_attribute_changed') && b.true
)
);
return b.call('$.set_default_checked', node_id, value);
}

if (has_state) {
state.update.push(update);
return true;
} else {
state.init.push(update);
return false;
if (is_dom_property(name)) {
return b.assignment('=', b.member(node_id, name), value);
}

return b.call(
name.startsWith('xlink') ? '$.set_xlink_attribute' : '$.set_attribute',
node_id,
b.literal(name),
value,
is_ignored(element, 'hydration_attribute_changed') && b.true
);
}

/**
* Like `build_element_attribute_update_assignment` but without any special attribute treatment.
* Like `build_element_attribute_update` but without any special attribute treatment.
* @param {Identifier} node_id
* @param {AST.Attribute} attribute
* @param {ComponentContext} context
* @returns {boolean}
*/
function build_custom_element_attribute_update_assignment(node_id, attribute, context) {
const state = context.state;
const name = attribute.name; // don't lowercase, as we set the element's property, which might be case sensitive
let { value, has_state } = build_attribute_value(attribute.value, context);
const { value, has_state } = build_attribute_value(attribute.value, context);

const update = b.stmt(b.call('$.set_custom_element_data', node_id, b.literal(name), value));
// don't lowercase name, as we set the element's property, which might be case sensitive
const call = b.call('$.set_custom_element_data', node_id, b.literal(attribute.name), value);

if (has_state) {
// this is different from other updates — it doesn't get grouped,
// because set_custom_element_data may not be idempotent
state.init.push(b.stmt(b.call('$.template_effect', b.thunk(update.expression))));
return true;
} else {
state.init.push(update);
return false;
}
// this is different from other updates — it doesn't get grouped,
// because set_custom_element_data may not be idempotent
const update = has_state ? b.call('$.template_effect', b.thunk(call)) : call;

context.state.init.push(b.stmt(update));
}

/**
Expand All @@ -686,7 +669,6 @@ function build_custom_element_attribute_update_assignment(node_id, attribute, co
* @param {Identifier} node_id
* @param {AST.Attribute} attribute
* @param {ComponentContext} context
* @returns {boolean}
*/
function build_element_special_value_attribute(element, node_id, attribute, context) {
const state = context.state;
Expand All @@ -699,7 +681,7 @@ function build_element_special_value_attribute(element, node_id, attribute, cont
metadata.has_call
? // 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(context.state, value)
? memoize_expression(state, value)
: get_expression_id(state, value)
: value
);
Expand Down Expand Up @@ -743,9 +725,7 @@ function build_element_special_value_attribute(element, node_id, attribute, cont
value,
update
);
return true;
} else {
state.init.push(update);
return false;
}
}
Loading