chore: rewrite set_style() to handle directives (#15418)

* add style attribute when needed

* set_style()

* to_style()

* remove `style=""`

* use cssTest for perfs

* base test

* test

* changeset

* revert dom.style.cssText

* format name

* use style.cssText + adapt test

* Apply suggestions from code review

suggestions from dummdidumm

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* fix priority

* lint

* yawn

* update test

* we can simplify some stuff now

* simplify

* more

* simplify some more

* more

* more

* more

* more

* more

* remove continue

* tweak

* tweak

* tweak

* skip hash argument where possible

* tweak

* tweak

* tweak

* tweak

---------

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/15453/head
adiGuba 6 months ago committed by GitHub
parent 2f685c1dba
commit 30562b8780
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: make `style:` directive and CSS handling more robust

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

@ -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' */
@ -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 {
@ -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')) {
@ -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;
@ -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'))
@ -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.
@ -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
@ -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));
}
/**
@ -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;
@ -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
);
@ -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;
}
}

@ -5,12 +5,7 @@ import { dev, locator } from '../../../../state.js';
import { is_text_attribute } from '../../../../utils/ast.js';
import * as b from '../../../../utils/builders.js';
import { determine_namespace_for_children } from '../../utils.js';
import {
build_attribute_value,
build_set_attributes,
build_set_class,
build_style_directives
} from './shared/element.js';
import { build_attribute_value, build_set_attributes, build_set_class } from './shared/element.js';
import { build_render_statement, get_expression_id } from './shared/utils.js';
/**
@ -77,40 +72,22 @@ export function SvelteElement(node, context) {
// Let bindings first, they can be used on attributes
context.state.init.push(...lets); // create computeds in the outer context; the dynamic element is the single child of this slot
// Then do attributes
let is_attributes_reactive = false;
if (
attributes.length === 1 &&
attributes[0].type === 'Attribute' &&
attributes[0].name.toLowerCase() === 'class' &&
is_text_attribute(attributes[0])
) {
// special case when there only a class attribute, without call expression
let { value, has_state } = build_attribute_value(
attributes[0].value,
context,
(value, metadata) => (metadata.has_call ? get_expression_id(context.state, value) : value)
);
is_attributes_reactive = build_set_class(
node,
element_id,
attributes[0],
value,
has_state,
class_directives,
inner_context,
false
);
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.
is_attributes_reactive = build_set_attributes(
build_set_attributes(
attributes,
class_directives,
style_directives,
inner_context,
node,
element_id,
@ -118,9 +95,6 @@ export function SvelteElement(node, context) {
);
}
// style directives must be applied last since they could override class/style attributes
build_style_directives(style_directives, element_id, inner_context, is_attributes_reactive);
const get_tag = b.thunk(/** @type {Expression} */ (context.visit(node.tag)));
if (dev) {

@ -1,4 +1,4 @@
/** @import { Expression, Identifier, ObjectExpression } from 'estree' */
/** @import { ArrayExpression, Expression, Identifier, ObjectExpression } from 'estree' */
/** @import { AST, ExpressionMetadata } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext } from '../../types' */
import { escape_html } from '../../../../../../escaping.js';
@ -6,13 +6,13 @@ import { normalize_attribute } from '../../../../../../utils.js';
import { is_ignored } from '../../../../../state.js';
import { is_event_attribute } from '../../../../../utils/ast.js';
import * as b from '../../../../../utils/builders.js';
import { build_getter } from '../../utils.js';
import { build_class_directives_object } from '../RegularElement.js';
import { build_class_directives_object, build_style_directives_object } from '../RegularElement.js';
import { build_template_chunk, get_expression_id } from './utils.js';
/**
* @param {Array<AST.Attribute | AST.SpreadAttribute>} attributes
* @param {AST.ClassDirective[]} class_directives
* @param {AST.StyleDirective[]} style_directives
* @param {ComponentContext} context
* @param {AST.RegularElement | AST.SvelteElement} element
* @param {Identifier} element_id
@ -21,6 +21,7 @@ import { build_template_chunk, get_expression_id } from './utils.js';
export function build_set_attributes(
attributes,
class_directives,
style_directives,
context,
element,
element_id,
@ -79,6 +80,18 @@ export function build_set_attributes(
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)
)
);
is_dynamic ||= style_directives.some((directive) => directive.metadata.expression.has_state);
}
const call = b.call(
'$.set_attributes',
element_id,
@ -94,54 +107,8 @@ export function build_set_attributes(
context.state.init.push(b.let(attributes_id));
const update = b.stmt(b.assignment('=', attributes_id, call));
context.state.update.push(update);
return true;
}
context.state.init.push(b.stmt(call));
return false;
}
/**
* Serializes each style directive into something like `$.set_style(element, style_property, value)`
* and adds it either to init or update, depending on whether or not the value or the attributes are dynamic.
* @param {AST.StyleDirective[]} style_directives
* @param {Identifier} element_id
* @param {ComponentContext} context
* @param {boolean} is_attributes_reactive
*/
export function build_style_directives(
style_directives,
element_id,
context,
is_attributes_reactive
) {
const state = context.state;
for (const directive of style_directives) {
const { has_state } = directive.metadata.expression;
let value =
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 update = b.stmt(
b.call(
'$.set_style',
element_id,
b.literal(directive.name),
value,
/** @type {Expression} */ (directive.modifiers.includes('important') ? b.true : undefined)
)
);
if (has_state || is_attributes_reactive) {
state.update.push(update);
} else {
state.init.push(update);
}
} else {
context.state.init.push(b.stmt(call));
}
}
@ -189,24 +156,16 @@ export function get_attribute_name(element, attribute) {
/**
* @param {AST.RegularElement | AST.SvelteElement} element
* @param {Identifier} node_id
* @param {AST.Attribute | null} attribute
* @param {Expression} value
* @param {boolean} has_state
* @param {AST.Attribute} attribute
* @param {AST.ClassDirective[]} class_directives
* @param {ComponentContext} context
* @param {boolean} is_html
* @returns {boolean}
*/
export function build_set_class(
element,
node_id,
attribute,
value,
has_state,
class_directives,
context,
is_html
) {
export function build_set_class(element, node_id, attribute, class_directives, context, is_html) {
let { value, has_state } = build_attribute_value(attribute.value, context, (value, metadata) =>
metadata.has_call ? get_expression_id(context.state, value) : value
);
if (attribute && attribute.metadata.needs_clsx) {
value = b.call('$.clsx', value);
}
@ -265,13 +224,48 @@ export function build_set_class(
set_class = b.assignment('=', previous_id, set_class);
}
const update = b.stmt(set_class);
(has_state ? context.state.update : context.state.init).push(b.stmt(set_class));
}
if (has_state) {
context.state.update.push(update);
return true;
/**
* @param {Identifier} node_id
* @param {AST.Attribute} attribute
* @param {AST.StyleDirective[]} style_directives
* @param {ComponentContext} context
*/
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
);
/** @type {Identifier | undefined} */
let previous_id;
/** @type {ObjectExpression | Identifier | undefined} */
let prev;
/** @type {ArrayExpression | ObjectExpression | undefined} */
let next;
if (style_directives.length) {
next = build_style_directives_object(style_directives, context);
has_state ||= style_directives.some((d) => d.metadata.expression.has_state);
if (has_state) {
previous_id = b.id(context.state.scope.generate('styles'));
context.state.init.push(b.declaration('let', [b.declarator(previous_id)]));
prev = previous_id;
} else {
prev = b.object([]);
}
}
/** @type {Expression} */
let set_style = b.call('$.set_style', node_id, value, prev, next);
if (previous_id) {
set_style = b.assignment('=', previous_id, set_style);
}
context.state.init.push(update);
return false;
(has_state ? context.state.update : context.state.init).push(b.stmt(set_style));
}

@ -1,4 +1,4 @@
/** @import { Expression, Literal, ObjectExpression } from 'estree' */
/** @import { ArrayExpression, Expression, Literal, ObjectExpression } from 'estree' */
/** @import { AST, Namespace } from '#compiler' */
/** @import { ComponentContext, ComponentServerTransformState } from '../../types.js' */
import {
@ -48,9 +48,6 @@ export function build_element_attributes(node, context) {
let content = null;
let has_spread = false;
// Use the index to keep the attributes order which is important for spreading
let class_index = -1;
let style_index = -1;
let events_to_capture = new Set();
for (const attribute of node.attributes) {
@ -86,7 +83,6 @@ export function build_element_attributes(node, context) {
// the defaultValue/defaultChecked properties don't exist as attributes
} else if (attribute.name !== 'defaultValue' && attribute.name !== 'defaultChecked') {
if (attribute.name === 'class') {
class_index = attributes.length;
if (attribute.metadata.needs_clsx) {
attributes.push({
...attribute,
@ -102,10 +98,6 @@ export function build_element_attributes(node, context) {
attributes.push(attribute);
}
} else {
if (attribute.name === 'style') {
style_index = attributes.length;
}
attributes.push(attribute);
}
}
@ -212,41 +204,30 @@ export function build_element_attributes(node, context) {
}
}
if ((node.metadata.scoped || class_directives.length) && !has_spread) {
const class_attribute = build_to_class(
node.metadata.scoped ? context.state.analysis.css.hash : null,
class_directives,
/** @type {AST.Attribute | null} */ (attributes[class_index] ?? null)
);
if (class_index === -1) {
attributes.push(class_attribute);
}
}
if (style_directives.length > 0 && !has_spread) {
build_style_directives(
style_directives,
/** @type {AST.Attribute | null} */ (attributes[style_index] ?? null),
context
);
if (style_index > -1) {
attributes.splice(style_index, 1);
}
}
if (has_spread) {
build_element_spread_attributes(node, attributes, style_directives, class_directives, context);
} else {
const css_hash = node.metadata.scoped ? context.state.analysis.css.hash : null;
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
if (attribute.value === true || is_text_attribute(attribute)) {
const name = get_attribute_name(node, attribute);
const literal_value = /** @type {Literal} */ (
const name = get_attribute_name(node, attribute);
const can_use_literal =
(name !== 'class' || class_directives.length === 0) &&
(name !== 'style' || style_directives.length === 0);
if (can_use_literal && (attribute.value === true || is_text_attribute(attribute))) {
let literal_value = /** @type {Literal} */ (
build_attribute_value(
attribute.value,
context,
WHITESPACE_INSENSITIVE_ATTRIBUTES.includes(name)
)
).value;
if (name === 'class' && css_hash) {
literal_value = (String(literal_value) + ' ' + css_hash).trim();
}
if (name !== 'class' || literal_value) {
context.state.template.push(
b.literal(
@ -258,10 +239,10 @@ export function build_element_attributes(node, context) {
)
);
}
continue;
}
const name = get_attribute_name(node, attribute);
const value = build_attribute_value(
attribute.value,
context,
@ -269,8 +250,15 @@ export function build_element_attributes(node, context) {
);
// pre-escape and inline literal attributes :
if (value.type === 'Literal' && typeof value.value === 'string') {
if (can_use_literal && value.type === 'Literal' && typeof value.value === 'string') {
if (name === 'class' && css_hash) {
value.value = (value.value + ' ' + css_hash).trim();
}
context.state.template.push(b.literal(` ${name}="${escape_html(value.value, true)}"`));
} else if (name === 'class') {
context.state.template.push(build_attr_class(class_directives, value, context, css_hash));
} else if (name === 'style') {
context.state.template.push(build_attr_style(style_directives, value, context));
} else {
context.state.template.push(
b.call('$.attr', b.literal(name), value, is_boolean_attribute(name) && b.true)
@ -379,100 +367,79 @@ function build_element_spread_attributes(
/**
*
* @param {string | null} hash
* @param {AST.ClassDirective[]} class_directives
* @param {AST.Attribute | null} class_attribute
* @returns
* @param {Expression} expression
* @param {ComponentContext} context
* @param {string | null} hash
*/
function build_to_class(hash, class_directives, class_attribute) {
if (class_attribute === null) {
class_attribute = create_attribute('class', -1, -1, []);
}
function build_attr_class(class_directives, expression, context, hash) {
/** @type {ObjectExpression | undefined} */
let classes;
let directives;
if (class_directives.length) {
classes = b.object(
directives = b.object(
class_directives.map((directive) =>
b.prop('init', b.literal(directive.name), directive.expression)
b.prop(
'init',
b.literal(directive.name),
/** @type {Expression} */ (context.visit(directive.expression, context.state))
)
)
);
}
/** @type {Expression} */
let class_name;
if (class_attribute.value === true) {
class_name = b.literal('');
} else if (Array.isArray(class_attribute.value)) {
if (class_attribute.value.length === 0) {
class_name = b.null;
} else {
class_name = class_attribute.value
.map((val) => (val.type === 'Text' ? b.literal(val.data) : val.expression))
.reduce((left, right) => b.binary('+', left, right));
}
} else {
class_name = class_attribute.value.expression;
}
let css_hash;
/** @type {Expression} */
let expression;
if (
hash &&
!classes &&
class_name.type === 'Literal' &&
(class_name.value === null || class_name.value === '' || typeof class_name.value === 'string')
) {
if (class_name.value === null || class_name.value === '') {
expression = b.literal(hash);
if (hash) {
if (expression.type === 'Literal' && typeof expression.value === 'string') {
expression.value = (expression.value + ' ' + hash).trim();
} else {
expression = b.literal(escape_html(class_name.value, true) + ' ' + hash);
css_hash = b.literal(hash);
}
} else {
expression = b.call('$.to_class', class_name, b.literal(hash), classes);
}
class_attribute.value = {
type: 'ExpressionTag',
start: -1,
end: -1,
expression: expression,
metadata: {
expression: create_expression_metadata()
}
};
return class_attribute;
return b.call('$.attr_class', expression, css_hash, directives);
}
/**
*
* @param {AST.StyleDirective[]} style_directives
* @param {AST.Attribute | null} style_attribute
* @param {Expression} expression
* @param {ComponentContext} context
*/
function build_style_directives(style_directives, style_attribute, context) {
const styles = style_directives.map((directive) => {
let value =
directive.value === true
? b.id(directive.name)
: build_attribute_value(directive.value, context, true);
if (directive.modifiers.includes('important')) {
value = b.binary('+', value, b.literal(' !important'));
function build_attr_style(style_directives, expression, context) {
/** @type {ArrayExpression | ObjectExpression | undefined} */
let directives;
if (style_directives.length) {
let normal_properties = [];
let important_properties = [];
for (const directive of style_directives) {
const expression =
directive.value === true
? b.id(directive.name)
: build_attribute_value(directive.value, context, true);
let name = directive.name;
if (name[0] !== '-' || name[1] !== '-') {
name = name.toLowerCase();
}
const property = b.init(directive.name, expression);
if (directive.modifiers.includes('important')) {
important_properties.push(property);
} else {
normal_properties.push(property);
}
}
return b.init(directive.name, value);
});
const arg =
style_attribute === null
? b.object(styles)
: b.call(
'$.merge_styles',
build_attribute_value(style_attribute.value, context, true),
b.object(styles)
);
context.state.template.push(b.call('$.add_styles', arg));
if (important_properties.length) {
directives = b.array([b.object(normal_properties), b.object(important_properties)]);
} else {
directives = b.object(normal_properties);
}
}
return b.call('$.attr_style', expression, directives);
}

@ -15,6 +15,7 @@ import {
} from '../../runtime.js';
import { clsx } from '../../../shared/attributes.js';
import { set_class } from './class.js';
import { set_style } from './style.js';
import { NAMESPACE_HTML } from '../../../../constants.js';
export const CLASS = Symbol('class');
@ -177,11 +178,6 @@ export function set_attribute(element, attribute, value, skip_warning) {
if (attributes[attribute] === (attributes[attribute] = value)) return;
if (attribute === 'style' && '__styles' in element) {
// reset styles to force style: directive to update
element.__styles = {};
}
if (attribute === 'loading') {
// @ts-expect-error
element[LOADING_ATTR_SYMBOL] = value;
@ -297,6 +293,10 @@ export function set_attributes(element, prev, next, css_hash, skip_warning = fal
next.class = null; /* force call to set_class() */
}
if (next[STYLE]) {
next.style ??= null; /* force call to set_style() */
}
var setters = get_setters(element);
// since key is captured we use const
@ -331,6 +331,13 @@ export function set_attributes(element, prev, next, css_hash, skip_warning = fal
continue;
}
if (key === 'style') {
set_style(element, value, prev?.[STYLE], next[STYLE]);
current[key] = value;
current[STYLE] = next[STYLE];
continue;
}
var prev_value = current[key];
if (value === prev_value) continue;

@ -1,22 +1,57 @@
import { to_style } from '../../../shared/attributes.js';
import { hydrating } from '../hydration.js';
/**
* @param {HTMLElement} dom
* @param {string} key
* @param {string} value
* @param {boolean} [important]
* @param {Element & ElementCSSInlineStyle} dom
* @param {Record<string, any>} prev
* @param {Record<string, any>} next
* @param {string} [priority]
*/
export function set_style(dom, key, value, important) {
// @ts-expect-error
var styles = (dom.__styles ??= {});
function update_styles(dom, prev = {}, next, priority) {
for (var key in next) {
var value = next[key];
if (styles[key] === value) {
return;
if (prev[key] !== value) {
if (next[key] == null) {
dom.style.removeProperty(key);
} else {
dom.style.setProperty(key, value, priority);
}
}
}
}
styles[key] = value;
/**
* @param {Element & ElementCSSInlineStyle} dom
* @param {string | null} value
* @param {Record<string, any> | [Record<string, any>, Record<string, any>]} [prev_styles]
* @param {Record<string, any> | [Record<string, any>, Record<string, any>]} [next_styles]
*/
export function set_style(dom, value, prev_styles, next_styles) {
// @ts-expect-error
var prev = dom.__style;
if (hydrating || prev !== value) {
var next_style_attr = to_style(value, next_styles);
if (value == null) {
dom.style.removeProperty(key);
} else {
dom.style.setProperty(key, value, important ? 'important' : '');
if (!hydrating || next_style_attr !== dom.getAttribute('style')) {
if (next_style_attr == null) {
dom.removeAttribute('style');
} else {
dom.style.cssText = next_style_attr;
}
}
// @ts-expect-error
dom.__style = value;
} else if (next_styles) {
if (Array.isArray(next_styles)) {
update_styles(dom, prev_styles?.[0], next_styles[0]);
update_styles(dom, prev_styles?.[1], next_styles[1], 'important');
} else {
update_styles(dom, prev_styles, next_styles);
}
}
return next_styles;
}

@ -48,7 +48,7 @@ export function init_operations() {
// @ts-expect-error
element_prototype.__attributes = null;
// @ts-expect-error
element_prototype.__styles = null;
element_prototype.__style = undefined;
// @ts-expect-error
element_prototype.__e = undefined;

@ -2,7 +2,7 @@
/** @import { Component, Payload, RenderOutput } from '#server' */
/** @import { Store } from '#shared' */
export { FILENAME, HMR } from '../../constants.js';
import { attr, clsx, to_class } from '../shared/attributes.js';
import { attr, clsx, to_class, to_style } from '../shared/attributes.js';
import { is_promise, noop } from '../shared/utils.js';
import { subscribe_to_store } from '../../store/utils.js';
import {
@ -210,9 +210,7 @@ export function css_props(payload, is_html, props, component, dynamic = false) {
*/
export function spread_attributes(attrs, css_hash, classes, styles, flags = 0) {
if (styles) {
attrs.style = attrs.style
? style_object_to_string(merge_styles(/** @type {string} */ (attrs.style), styles))
: style_object_to_string(styles);
attrs.style = to_style(attrs.style, styles);
}
if (attrs.class) {
@ -286,35 +284,23 @@ function style_object_to_string(style_object) {
.join(' ');
}
/** @param {Record<string, string>} style_object */
export function add_styles(style_object) {
const styles = style_object_to_string(style_object);
return styles ? ` style="${styles}"` : '';
/**
* @param {any} value
* @param {string | undefined} [hash]
* @param {Record<string, boolean>} [directives]
*/
export function attr_class(value, hash, directives) {
var result = to_class(value, hash, directives);
return result ? ` class="${escape_html(result, true)}"` : '';
}
/**
* @param {string} attribute
* @param {Record<string, string>} styles
* @param {any} value
* @param {Record<string,any>|[Record<string,any>,Record<string,any>]} [directives]
*/
export function merge_styles(attribute, styles) {
/** @type {Record<string, string>} */
var merged = {};
if (attribute) {
for (var declaration of attribute.split(';')) {
var i = declaration.indexOf(':');
var name = declaration.slice(0, i).trim();
var value = declaration.slice(i + 1).trim();
if (name !== '') merged[name] = value;
}
}
for (name in styles) {
merged[name] = styles[name];
}
return merged;
export function attr_style(value, directives) {
var result = to_style(value, directives);
return result ? ` style="${escape_html(result, true)}"` : '';
}
/**
@ -549,7 +535,7 @@ export function props_id(payload) {
return uid;
}
export { attr, clsx, to_class };
export { attr, clsx };
export { html } from './blocks/html.js';

@ -22,7 +22,7 @@ const replacements = {
* @returns {string}
*/
export function attr(name, value, is_boolean = false) {
if (value == null || (!value && is_boolean) || (value === '' && name === 'class')) return '';
if (value == null || (!value && is_boolean)) return '';
const normalized = (name in replacements && replacements[name].get(value)) || value;
const assignment = is_boolean ? '' : `="${escape_html(normalized, true)}"`;
return ` ${name}${assignment}`;
@ -82,3 +82,138 @@ export function to_class(value, hash, directives) {
return classname === '' ? null : classname;
}
/**
*
* @param {Record<string,any>} styles
* @param {boolean} important
*/
function append_styles(styles, important = false) {
var separator = important ? ' !important;' : ';';
var css = '';
for (var key in styles) {
var value = styles[key];
if (value != null && value !== '') {
css += ' ' + key + ': ' + value + separator;
}
}
return css;
}
/**
* @param {string} name
* @returns {string}
*/
function to_css_name(name) {
if (name[0] !== '-' || name[1] !== '-') {
return name.toLowerCase();
}
return name;
}
/**
* @param {any} value
* @param {Record<string, any> | [Record<string, any>, Record<string, any>]} [styles]
* @returns {string | null}
*/
export function to_style(value, styles) {
if (styles) {
var new_style = '';
/** @type {Record<string,any> | undefined} */
var normal_styles;
/** @type {Record<string,any> | undefined} */
var important_styles;
if (Array.isArray(styles)) {
normal_styles = styles[0];
important_styles = styles[1];
} else {
normal_styles = styles;
}
if (value) {
value = String(value)
.replaceAll(/\s*\/\*.*?\*\/\s*/g, '')
.trim();
/** @type {boolean | '"' | "'"} */
var in_str = false;
var in_apo = 0;
var in_comment = false;
var reserved_names = [];
if (normal_styles) {
reserved_names.push(...Object.keys(normal_styles).map(to_css_name));
}
if (important_styles) {
reserved_names.push(...Object.keys(important_styles).map(to_css_name));
}
var start_index = 0;
var name_index = -1;
const len = value.length;
for (var i = 0; i < len; i++) {
var c = value[i];
if (in_comment) {
if (c === '/' && value[i - 1] === '*') {
in_comment = false;
}
} else if (in_str) {
if (in_str === c) {
in_str = false;
}
} else if (c === '/' && value[i + 1] === '*') {
in_comment = true;
} else if (c === '"' || c === "'") {
in_str = c;
} else if (c === '(') {
in_apo++;
} else if (c === ')') {
in_apo--;
}
if (!in_comment && in_str === false && in_apo === 0) {
if (c === ':' && name_index === -1) {
name_index = i;
} else if (c === ';' || i === len - 1) {
if (name_index !== -1) {
var name = to_css_name(value.substring(start_index, name_index).trim());
if (!reserved_names.includes(name)) {
if (c !== ';') {
i++;
}
var property = value.substring(start_index, i).trim();
new_style += ' ' + property + ';';
}
}
start_index = i + 1;
name_index = -1;
}
}
}
}
if (normal_styles) {
new_style += append_styles(normal_styles);
}
if (important_styles) {
new_style += append_styles(important_styles, true);
}
new_style = new_style.trim();
return new_style === '' ? null : new_style;
}
return value == null ? null : String(value);
}

@ -2,6 +2,6 @@ import { test } from '../../test';
export default test({
html: `
<p style=""></p>
<p></p>
`
});

@ -2,7 +2,7 @@ import { ok, test } from '../../test';
export default test({
html: `
<p style="opacity: 0.5; color: red">color: red</p>
<p style="opacity: 0.5; color: red;">color: red;</p>
`,
test({ assert, component, target, window }) {

@ -1,5 +1,5 @@
<script>
export let styles = `color: red`;
export let styles = `color: red;`;
</script>
<p style="opacity: 0.5; {styles}">{styles}</p>

@ -2,7 +2,7 @@ import { test } from '../../test';
import { flushSync } from 'svelte';
export default test({
html: `<div style="background-color: red">Hello world</div><button>Make blue</button`,
html: `<div style="background-color: red;">Hello world</div><button>Make blue</button`,
async test({ assert, target, component }) {
const [b1] = target.querySelectorAll('button');
@ -11,7 +11,7 @@ export default test({
});
assert.htmlEqual(
target.innerHTML,
`<div style="background-color: blue">Hello world</div><button>Make blue</button`
`<div style="background-color: blue;">Hello world</div><button>Make blue</button`
);
}
});

@ -4,6 +4,6 @@
const getColor = () => color;
</script>
<div style="background-color: {getColor()}">Hello world</div>
<div style="background-color: {getColor()};">Hello world</div>
<button onclick={() => color = 'blue'}>Make blue</button>

@ -0,0 +1,95 @@
import { flushSync, tick } from 'svelte';
import { test } from '../../test';
// This test counts mutations on hydration
// set_style() should not mutate style on hydration, except if mismatch
export default test({
mode: ['server', 'hydrate'],
server_props: {
browser: false
},
props: {
browser: true
},
ssrHtml: `
<main id="main" style="color: black;">
<div style="color: red; font-size: 18px !important;"></div>
<div style="border: 1px solid; color: red; font-size: 18px !important;"></div>
<div style="border: 1px solid; color: red; font-size: 18px !important;"></div>
<div style="border: 1px solid; color: red; font-size: 18px !important;"></div>
<div style="background:blue; background: linear-gradient(0, white 0%, red 100%); color: red; font-size: 18px !important;"></div>
<div style="border: 1px solid; color: red; font-size: 18px !important;"></div>
<div style="background: url(https://placehold.co/100x100?text=;&font=roboto); color: red; font-size: 18px !important;"></div>
<div style="background: url(&quot;https://placehold.co/100x100?text=;&font=roboto&quot;); color: red; font-size: 18px !important;"></div>
<div style="background: url('https://placehold.co/100x100?text=;&font=roboto'); color: red; font-size: 18px !important;"></div>
</main>
`,
html: `
<main id="main" style="color: white;">
<div style="color: red; font-size: 18px !important;"></div>
<div style="border: 1px solid; color: red; font-size: 18px !important;"></div>
<div style="border: 1px solid; color: red; font-size: 18px !important;"></div>
<div style="border: 1px solid; color: red; font-size: 18px !important;"></div>
<div style="background:blue; background: linear-gradient(0, white 0%, red 100%); color: red; font-size: 18px !important;"></div>
<div style="border: 1px solid; color: red; font-size: 18px !important;"></div>
<div style="background: url(https://placehold.co/100x100?text=;&font=roboto); color: red; font-size: 18px !important;"></div>
<div style="background: url(&quot;https://placehold.co/100x100?text=;&font=roboto&quot;); color: red; font-size: 18px !important;"></div>
<div style="background: url('https://placehold.co/100x100?text=;&font=roboto'); color: red; font-size: 18px !important;"></div>
</main>
`,
async test({ target, assert, component, instance }) {
flushSync();
tick();
assert.deepEqual(instance.get_and_clear_mutations(), ['MAIN']);
let divs = target.querySelectorAll('div');
// Note : we cannot compare HTML because set_style() use dom.style.cssText
// which can alter the format of the attribute...
divs.forEach((d) => assert.equal(d.style.margin, ''));
divs.forEach((d) => assert.equal(d.style.color, 'red'));
divs.forEach((d) => assert.equal(d.style.fontSize, '18px'));
component.margin = '1px';
flushSync();
assert.deepEqual(
instance.get_and_clear_mutations(),
['DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV'],
'margin'
);
divs.forEach((d) => assert.equal(d.style.margin, '1px'));
component.color = 'yellow';
flushSync();
assert.deepEqual(
instance.get_and_clear_mutations(),
['DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV'],
'color'
);
divs.forEach((d) => assert.equal(d.style.color, 'yellow'));
component.fontSize = '10px';
flushSync();
assert.deepEqual(
instance.get_and_clear_mutations(),
['DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV'],
'fontSize'
);
divs.forEach((d) => assert.equal(d.style.fontSize, '10px'));
component.fontSize = null;
flushSync();
assert.deepEqual(
instance.get_and_clear_mutations(),
['DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV'],
'fontSize'
);
divs.forEach((d) => assert.equal(d.style.fontSize, ''));
}
});

@ -0,0 +1,54 @@
<script>
let {
margin = null,
color = 'red',
fontSize = '18px',
style1 = 'border: 1px solid',
style2 = 'border: 1px solid; margin: 1em',
style3 = 'color:blue; border: 1px solid; color: green;',
style4 = 'background:blue; background: linear-gradient(0, white 0%, red 100%)',
style5 = 'border: 1px solid; /* width: 100px; height: 100%; color: green */',
style6 = 'background: url(https://placehold.co/100x100?text=;&font=roboto);',
style7 = 'background: url("https://placehold.co/100x100?text=;&font=roboto");',
style8 = "background: url('https://placehold.co/100x100?text=;&font=roboto');",
browser
} = $props();
let mutations = [];
let observer;
if (browser) {
observer = new MutationObserver(update_mutation_records);
observer.observe(document.querySelector('#main'), { attributes: true, subtree: true });
$effect(() => {
return () => observer.disconnect();
});
}
function update_mutation_records(results) {
for (const r of results) {
mutations.push(r.target.nodeName);
}
}
export function get_and_clear_mutations() {
update_mutation_records(observer.takeRecords());
const result = mutations;
mutations = [];
return result;
}
</script>
<main id="main" style:color={browser?'white':'black'}>
<div style:margin style:color style:font-size|important={fontSize}></div>
<div style={style1} style:margin style:color style:font-size|important={fontSize}></div>
<div style={style2} style:margin style:color style:font-size|important={fontSize}></div>
<div style={style3} style:margin style:color style:font-size|important={fontSize}></div>
<div style={style4} style:margin style:color style:font-size|important={fontSize}></div>
<div style={style5} style:margin style:color style:font-size|important={fontSize}></div>
<div style={style6} style:margin style:color style:font-size|important={fontSize}></div>
<div style={style7} style:margin style:color style:font-size|important={fontSize}></div>
<div style={style8} style:margin style:color style:font-size|important={fontSize}></div>
</main>

@ -3,6 +3,7 @@ import { test } from '../../test';
const style_1 = 'invalid-key:0; margin:4px;;color: green ;color:blue ';
const style_2 = ' other-key : 0 ; padding:2px; COLOR:green; color: blue';
const style_2_normalized = 'padding: 2px; color: blue;';
// https://github.com/sveltejs/svelte/issues/15309
export default test({
@ -10,7 +11,7 @@ export default test({
style: style_1
},
html: `
ssrHtml: `
<div style="${style_1}"></div>
<div style="${style_1}"></div>
@ -25,11 +26,11 @@ export default test({
assert.htmlEqual(
target.innerHTML,
`
<div style="${style_2}"></div>
<div style="${style_2}"></div>
<div style="${style_2_normalized}"></div>
<div style="${style_2_normalized}"></div>
<custom-element style="${style_2}"></custom-element>
<custom-element style="${style_2}"></custom-element>
<custom-element style="${style_2_normalized}"></custom-element>
<custom-element style="${style_2_normalized}"></custom-element>
`
);

Loading…
Cancel
Save