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_class = false;
let has_style = false;
let has_spread = false; let has_spread = false;
let has_class_directive = false; let has_class_directive = false;
let has_style_directive = false;
for (const attribute of node.attributes) { for (const attribute of node.attributes) {
// The spread method appends the hash to the end of the class attribute on its own // The spread method appends the hash to the end of the class attribute on its own
if (attribute.type === 'SpreadAttribute') { if (attribute.type === 'SpreadAttribute') {
has_spread = true; has_spread = true;
break; 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 // 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 // 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 { AST } from '#compiler' */
/** @import { SourceLocation } from '#shared' */ /** @import { SourceLocation } from '#shared' */
/** @import { ComponentClientTransformState, ComponentContext } from '../types' */ /** @import { ComponentClientTransformState, ComponentContext } from '../types' */
@ -20,9 +20,9 @@ import { build_getter } from '../utils.js';
import { import {
get_attribute_name, get_attribute_name,
build_attribute_value, build_attribute_value,
build_style_directives,
build_set_attributes, build_set_attributes,
build_set_class build_set_class,
build_set_style
} from './shared/element.js'; } from './shared/element.js';
import { process_children } from './shared/fragment.js'; import { process_children } from './shared/fragment.js';
import { import {
@ -215,13 +215,18 @@ export function RegularElement(node, context) {
const node_id = context.state.node; const node_id = context.state.node;
// Then do attributes
let is_attributes_reactive = has_spread;
if (has_spread) { if (has_spread) {
const attributes_id = b.id(context.state.scope.generate('attributes')); 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 value binding exists, that one takes care of calling $.init_select
if (node.name === 'select' && !bindings.has('value')) { if (node.name === 'select' && !bindings.has('value')) {
@ -262,11 +267,13 @@ export function RegularElement(node, context) {
} }
const name = get_attribute_name(node, attribute); const name = get_attribute_name(node, attribute);
if ( if (
!is_custom_element && !is_custom_element &&
!cannot_be_set_statically(attribute.name) && !cannot_be_set_statically(attribute.name) &&
(attribute.value === true || is_text_attribute(attribute)) && (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; 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 = (has_state ? context.state.update : context.state.init).push(b.stmt(update));
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;
} }
} }
// 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 ( if (
is_load_error_element(node.name) && is_load_error_element(node.name) &&
(has_spread || has_use || lookup.has('onload') || lookup.has('onerror')) (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); 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 * 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. * 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. * Returns true if attribute is deemed reactive, false otherwise.
* @param {AST.RegularElement} element * @param {AST.RegularElement} element
* @param {Identifier} node_id * @param {Identifier} node_id
* @param {AST.Attribute} attribute * @param {string} name
* @param {Expression} value
* @param {Array<AST.Attribute | AST.SpreadAttribute>} attributes * @param {Array<AST.Attribute | AST.SpreadAttribute>} attributes
* @param {AST.ClassDirective[]} class_directives
* @param {ComponentContext} context
* @returns {boolean}
*/ */
function build_element_attribute_update_assignment( function build_element_attribute_update(element, node_id, name, value, attributes) {
element, if (name === 'muted') {
node_id, // Special case for Firefox who needs it set as a property in order to work
attribute, return b.assignment('=', b.member(node_id, b.id('muted')), value);
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
);
if (is_autofocus) { if (name === 'value') {
state.init.push(b.stmt(b.call('$.autofocus', node_id, value))); return b.call('$.set_value', node_id, value);
return false;
} }
// Special case for Firefox who needs it set as a property in order to work if (name === 'checked') {
if (name === 'muted') { return b.call('$.set_checked', node_id, value);
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;
} }
/** @type {Statement} */ if (name === 'selected') {
let update; return b.call('$.set_selected', node_id, value);
}
if (name === 'class') { if (
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 we would just set the defaultValue property, it would override the value property, // 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, // 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 // 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)) (element.name === 'textarea' && element.fragment.nodes.length > 0))
) { ) {
update = b.stmt(b.call('$.set_default_value', node_id, value)); return b.call('$.set_default_value', node_id, value);
} else if ( }
if (
// See defaultValue comment // See defaultValue comment
name === 'defaultChecked' && name === 'defaultChecked' &&
attributes.some( attributes.some(
(attr) => attr.type === 'Attribute' && attr.name === 'checked' && attr.value === true (attr) => attr.type === 'Attribute' && attr.name === 'checked' && attr.value === true
) )
) { ) {
update = b.stmt(b.call('$.set_default_checked', node_id, value)); return 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
)
);
} }
if (has_state) { if (is_dom_property(name)) {
state.update.push(update); return b.assignment('=', b.member(node_id, name), value);
return true;
} else {
state.init.push(update);
return false;
} }
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 {Identifier} node_id
* @param {AST.Attribute} attribute * @param {AST.Attribute} attribute
* @param {ComponentContext} context * @param {ComponentContext} context
* @returns {boolean}
*/ */
function build_custom_element_attribute_update_assignment(node_id, attribute, context) { function build_custom_element_attribute_update_assignment(node_id, attribute, context) {
const state = context.state; const { value, has_state } = build_attribute_value(attribute.value, context);
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 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,
// this is different from other updates — it doesn't get grouped, // because set_custom_element_data may not be idempotent
// because set_custom_element_data may not be idempotent const update = has_state ? b.call('$.template_effect', b.thunk(call)) : call;
state.init.push(b.stmt(b.call('$.template_effect', b.thunk(update.expression))));
return true; context.state.init.push(b.stmt(update));
} else {
state.init.push(update);
return false;
}
} }
/** /**
@ -686,7 +669,6 @@ function build_custom_element_attribute_update_assignment(node_id, attribute, co
* @param {Identifier} node_id * @param {Identifier} node_id
* @param {AST.Attribute} attribute * @param {AST.Attribute} attribute
* @param {ComponentContext} context * @param {ComponentContext} context
* @returns {boolean}
*/ */
function build_element_special_value_attribute(element, node_id, attribute, context) { function build_element_special_value_attribute(element, node_id, attribute, context) {
const state = context.state; const state = context.state;
@ -699,7 +681,7 @@ function build_element_special_value_attribute(element, node_id, attribute, cont
metadata.has_call 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 ? // 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 is_select_with_value
? memoize_expression(context.state, value) ? memoize_expression(state, value)
: get_expression_id(state, value) : get_expression_id(state, value)
: value : value
); );
@ -743,9 +725,7 @@ function build_element_special_value_attribute(element, node_id, attribute, cont
value, value,
update update
); );
return true;
} else { } else {
state.init.push(update); 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 { is_text_attribute } from '../../../../utils/ast.js';
import * as b from '../../../../utils/builders.js'; import * as b from '../../../../utils/builders.js';
import { determine_namespace_for_children } from '../../utils.js'; import { determine_namespace_for_children } from '../../utils.js';
import { import { build_attribute_value, build_set_attributes, build_set_class } from './shared/element.js';
build_attribute_value,
build_set_attributes,
build_set_class,
build_style_directives
} from './shared/element.js';
import { build_render_statement, get_expression_id } from './shared/utils.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 // 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 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 ( if (
attributes.length === 1 && attributes.length === 1 &&
attributes[0].type === 'Attribute' && attributes[0].type === 'Attribute' &&
attributes[0].name.toLowerCase() === 'class' && attributes[0].name.toLowerCase() === 'class' &&
is_text_attribute(attributes[0]) is_text_attribute(attributes[0])
) { ) {
// special case when there only a class attribute, without call expression build_set_class(node, element_id, attributes[0], class_directives, inner_context, false);
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
);
} else if (attributes.length) { } else if (attributes.length) {
const attributes_id = b.id(context.state.scope.generate('attributes')); 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, // 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. // therefore we need to do the "how to set an attribute" logic at runtime.
is_attributes_reactive = build_set_attributes( build_set_attributes(
attributes, attributes,
class_directives, class_directives,
style_directives,
inner_context, inner_context,
node, node,
element_id, 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))); const get_tag = b.thunk(/** @type {Expression} */ (context.visit(node.tag)));
if (dev) { 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 { AST, ExpressionMetadata } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext } from '../../types' */ /** @import { ComponentClientTransformState, ComponentContext } from '../../types' */
import { escape_html } from '../../../../../../escaping.js'; import { escape_html } from '../../../../../../escaping.js';
@ -6,13 +6,13 @@ import { normalize_attribute } from '../../../../../../utils.js';
import { is_ignored } from '../../../../../state.js'; import { is_ignored } from '../../../../../state.js';
import { is_event_attribute } from '../../../../../utils/ast.js'; import { is_event_attribute } from '../../../../../utils/ast.js';
import * as b from '../../../../../utils/builders.js'; import * as b from '../../../../../utils/builders.js';
import { build_getter } from '../../utils.js'; import { build_class_directives_object, build_style_directives_object } from '../RegularElement.js';
import { build_class_directives_object } from '../RegularElement.js';
import { build_template_chunk, get_expression_id } from './utils.js'; import { build_template_chunk, get_expression_id } from './utils.js';
/** /**
* @param {Array<AST.Attribute | AST.SpreadAttribute>} attributes * @param {Array<AST.Attribute | AST.SpreadAttribute>} attributes
* @param {AST.ClassDirective[]} class_directives * @param {AST.ClassDirective[]} class_directives
* @param {AST.StyleDirective[]} style_directives
* @param {ComponentContext} context * @param {ComponentContext} context
* @param {AST.RegularElement | AST.SvelteElement} element * @param {AST.RegularElement | AST.SvelteElement} element
* @param {Identifier} element_id * @param {Identifier} element_id
@ -21,6 +21,7 @@ import { build_template_chunk, get_expression_id } from './utils.js';
export function build_set_attributes( export function build_set_attributes(
attributes, attributes,
class_directives, class_directives,
style_directives,
context, context,
element, element,
element_id, element_id,
@ -79,6 +80,18 @@ export function build_set_attributes(
class_directives.find((directive) => directive.metadata.expression.has_state) !== null; 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( const call = b.call(
'$.set_attributes', '$.set_attributes',
element_id, element_id,
@ -94,54 +107,8 @@ export function build_set_attributes(
context.state.init.push(b.let(attributes_id)); context.state.init.push(b.let(attributes_id));
const update = b.stmt(b.assignment('=', attributes_id, call)); const update = b.stmt(b.assignment('=', attributes_id, call));
context.state.update.push(update); context.state.update.push(update);
return true; } else {
} context.state.init.push(b.stmt(call));
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);
}
} }
} }
@ -189,24 +156,16 @@ export function get_attribute_name(element, attribute) {
/** /**
* @param {AST.RegularElement | AST.SvelteElement} element * @param {AST.RegularElement | AST.SvelteElement} element
* @param {Identifier} node_id * @param {Identifier} node_id
* @param {AST.Attribute | null} attribute * @param {AST.Attribute} attribute
* @param {Expression} value
* @param {boolean} has_state
* @param {AST.ClassDirective[]} class_directives * @param {AST.ClassDirective[]} class_directives
* @param {ComponentContext} context * @param {ComponentContext} context
* @param {boolean} is_html * @param {boolean} is_html
* @returns {boolean}
*/ */
export function build_set_class( export function build_set_class(element, node_id, attribute, class_directives, context, is_html) {
element, let { value, has_state } = build_attribute_value(attribute.value, context, (value, metadata) =>
node_id, metadata.has_call ? get_expression_id(context.state, value) : value
attribute, );
value,
has_state,
class_directives,
context,
is_html
) {
if (attribute && attribute.metadata.needs_clsx) { if (attribute && attribute.metadata.needs_clsx) {
value = b.call('$.clsx', value); value = b.call('$.clsx', value);
} }
@ -265,13 +224,48 @@ export function build_set_class(
set_class = b.assignment('=', previous_id, 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); * @param {Identifier} node_id
return true; * @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); (has_state ? context.state.update : context.state.init).push(b.stmt(set_style));
return false;
} }

@ -1,4 +1,4 @@
/** @import { Expression, Literal, ObjectExpression } from 'estree' */ /** @import { ArrayExpression, Expression, Literal, ObjectExpression } from 'estree' */
/** @import { AST, Namespace } from '#compiler' */ /** @import { AST, Namespace } from '#compiler' */
/** @import { ComponentContext, ComponentServerTransformState } from '../../types.js' */ /** @import { ComponentContext, ComponentServerTransformState } from '../../types.js' */
import { import {
@ -48,9 +48,6 @@ export function build_element_attributes(node, context) {
let content = null; let content = null;
let has_spread = false; 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(); let events_to_capture = new Set();
for (const attribute of node.attributes) { 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 // the defaultValue/defaultChecked properties don't exist as attributes
} else if (attribute.name !== 'defaultValue' && attribute.name !== 'defaultChecked') { } else if (attribute.name !== 'defaultValue' && attribute.name !== 'defaultChecked') {
if (attribute.name === 'class') { if (attribute.name === 'class') {
class_index = attributes.length;
if (attribute.metadata.needs_clsx) { if (attribute.metadata.needs_clsx) {
attributes.push({ attributes.push({
...attribute, ...attribute,
@ -102,10 +98,6 @@ export function build_element_attributes(node, context) {
attributes.push(attribute); attributes.push(attribute);
} }
} else { } else {
if (attribute.name === 'style') {
style_index = attributes.length;
}
attributes.push(attribute); 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) { if (has_spread) {
build_element_spread_attributes(node, attributes, style_directives, class_directives, context); build_element_spread_attributes(node, attributes, style_directives, class_directives, context);
} else { } else {
const css_hash = node.metadata.scoped ? context.state.analysis.css.hash : null;
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) { for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
if (attribute.value === true || is_text_attribute(attribute)) { const name = get_attribute_name(node, attribute);
const name = get_attribute_name(node, attribute); const can_use_literal =
const literal_value = /** @type {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( build_attribute_value(
attribute.value, attribute.value,
context, context,
WHITESPACE_INSENSITIVE_ATTRIBUTES.includes(name) WHITESPACE_INSENSITIVE_ATTRIBUTES.includes(name)
) )
).value; ).value;
if (name === 'class' && css_hash) {
literal_value = (String(literal_value) + ' ' + css_hash).trim();
}
if (name !== 'class' || literal_value) { if (name !== 'class' || literal_value) {
context.state.template.push( context.state.template.push(
b.literal( b.literal(
@ -258,10 +239,10 @@ export function build_element_attributes(node, context) {
) )
); );
} }
continue; continue;
} }
const name = get_attribute_name(node, attribute);
const value = build_attribute_value( const value = build_attribute_value(
attribute.value, attribute.value,
context, context,
@ -269,8 +250,15 @@ export function build_element_attributes(node, context) {
); );
// pre-escape and inline literal attributes : // 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)}"`)); 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 { } else {
context.state.template.push( context.state.template.push(
b.call('$.attr', b.literal(name), value, is_boolean_attribute(name) && b.true) 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.ClassDirective[]} class_directives
* @param {AST.Attribute | null} class_attribute * @param {Expression} expression
* @returns * @param {ComponentContext} context
* @param {string | null} hash
*/ */
function build_to_class(hash, class_directives, class_attribute) { function build_attr_class(class_directives, expression, context, hash) {
if (class_attribute === null) {
class_attribute = create_attribute('class', -1, -1, []);
}
/** @type {ObjectExpression | undefined} */ /** @type {ObjectExpression | undefined} */
let classes; let directives;
if (class_directives.length) { if (class_directives.length) {
classes = b.object( directives = b.object(
class_directives.map((directive) => 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 css_hash;
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;
}
/** @type {Expression} */ if (hash) {
let expression; if (expression.type === 'Literal' && typeof expression.value === 'string') {
expression.value = (expression.value + ' ' + hash).trim();
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);
} else { } 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 = { return b.call('$.attr_class', expression, css_hash, directives);
type: 'ExpressionTag',
start: -1,
end: -1,
expression: expression,
metadata: {
expression: create_expression_metadata()
}
};
return class_attribute;
} }
/** /**
*
* @param {AST.StyleDirective[]} style_directives * @param {AST.StyleDirective[]} style_directives
* @param {AST.Attribute | null} style_attribute * @param {Expression} expression
* @param {ComponentContext} context * @param {ComponentContext} context
*/ */
function build_style_directives(style_directives, style_attribute, context) { function build_attr_style(style_directives, expression, context) {
const styles = style_directives.map((directive) => { /** @type {ArrayExpression | ObjectExpression | undefined} */
let value = let directives;
directive.value === true
? b.id(directive.name) if (style_directives.length) {
: build_attribute_value(directive.value, context, true); let normal_properties = [];
if (directive.modifiers.includes('important')) { let important_properties = [];
value = b.binary('+', value, b.literal(' !important'));
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'; } from '../../runtime.js';
import { clsx } from '../../../shared/attributes.js'; import { clsx } from '../../../shared/attributes.js';
import { set_class } from './class.js'; import { set_class } from './class.js';
import { set_style } from './style.js';
import { NAMESPACE_HTML } from '../../../../constants.js'; import { NAMESPACE_HTML } from '../../../../constants.js';
export const CLASS = Symbol('class'); 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 (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') { if (attribute === 'loading') {
// @ts-expect-error // @ts-expect-error
element[LOADING_ATTR_SYMBOL] = value; 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() */ next.class = null; /* force call to set_class() */
} }
if (next[STYLE]) {
next.style ??= null; /* force call to set_style() */
}
var setters = get_setters(element); var setters = get_setters(element);
// since key is captured we use const // since key is captured we use const
@ -331,6 +331,13 @@ export function set_attributes(element, prev, next, css_hash, skip_warning = fal
continue; 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]; var prev_value = current[key];
if (value === prev_value) continue; 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 {Element & ElementCSSInlineStyle} dom
* @param {string} key * @param {Record<string, any>} prev
* @param {string} value * @param {Record<string, any>} next
* @param {boolean} [important] * @param {string} [priority]
*/ */
export function set_style(dom, key, value, important) { function update_styles(dom, prev = {}, next, priority) {
// @ts-expect-error for (var key in next) {
var styles = (dom.__styles ??= {}); var value = next[key];
if (styles[key] === value) { if (prev[key] !== value) {
return; 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) { if (!hydrating || next_style_attr !== dom.getAttribute('style')) {
dom.style.removeProperty(key); if (next_style_attr == null) {
} else { dom.removeAttribute('style');
dom.style.setProperty(key, value, important ? 'important' : ''); } 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 // @ts-expect-error
element_prototype.__attributes = null; element_prototype.__attributes = null;
// @ts-expect-error // @ts-expect-error
element_prototype.__styles = null; element_prototype.__style = undefined;
// @ts-expect-error // @ts-expect-error
element_prototype.__e = undefined; element_prototype.__e = undefined;

@ -2,7 +2,7 @@
/** @import { Component, Payload, RenderOutput } from '#server' */ /** @import { Component, Payload, RenderOutput } from '#server' */
/** @import { Store } from '#shared' */ /** @import { Store } from '#shared' */
export { FILENAME, HMR } from '../../constants.js'; 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 { is_promise, noop } from '../shared/utils.js';
import { subscribe_to_store } from '../../store/utils.js'; import { subscribe_to_store } from '../../store/utils.js';
import { 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) { export function spread_attributes(attrs, css_hash, classes, styles, flags = 0) {
if (styles) { if (styles) {
attrs.style = attrs.style attrs.style = to_style(attrs.style, styles);
? style_object_to_string(merge_styles(/** @type {string} */ (attrs.style), styles))
: style_object_to_string(styles);
} }
if (attrs.class) { if (attrs.class) {
@ -286,35 +284,23 @@ function style_object_to_string(style_object) {
.join(' '); .join(' ');
} }
/** @param {Record<string, string>} style_object */ /**
export function add_styles(style_object) { * @param {any} value
const styles = style_object_to_string(style_object); * @param {string | undefined} [hash]
return styles ? ` style="${styles}"` : ''; * @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 {any} value
* @param {Record<string, string>} styles * @param {Record<string,any>|[Record<string,any>,Record<string,any>]} [directives]
*/ */
export function merge_styles(attribute, styles) { export function attr_style(value, directives) {
/** @type {Record<string, string>} */ var result = to_style(value, directives);
var merged = {}; return result ? ` style="${escape_html(result, true)}"` : '';
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;
} }
/** /**
@ -549,7 +535,7 @@ export function props_id(payload) {
return uid; return uid;
} }
export { attr, clsx, to_class }; export { attr, clsx };
export { html } from './blocks/html.js'; export { html } from './blocks/html.js';

@ -22,7 +22,7 @@ const replacements = {
* @returns {string} * @returns {string}
*/ */
export function attr(name, value, is_boolean = false) { 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 normalized = (name in replacements && replacements[name].get(value)) || value;
const assignment = is_boolean ? '' : `="${escape_html(normalized, true)}"`; const assignment = is_boolean ? '' : `="${escape_html(normalized, true)}"`;
return ` ${name}${assignment}`; return ` ${name}${assignment}`;
@ -82,3 +82,138 @@ export function to_class(value, hash, directives) {
return classname === '' ? null : classname; 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({ export default test({
html: ` html: `
<p style=""></p> <p></p>
` `
}); });

@ -2,7 +2,7 @@ import { ok, test } from '../../test';
export default test({ export default test({
html: ` 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 }) { test({ assert, component, target, window }) {

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

@ -2,7 +2,7 @@ import { test } from '../../test';
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
export default test({ 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 }) { async test({ assert, target, component }) {
const [b1] = target.querySelectorAll('button'); const [b1] = target.querySelectorAll('button');
@ -11,7 +11,7 @@ export default test({
}); });
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, 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; const getColor = () => color;
</script> </script>
<div style="background-color: {getColor()}">Hello world</div> <div style="background-color: {getColor()};">Hello world</div>
<button onclick={() => color = 'blue'}>Make blue</button> <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_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 = ' other-key : 0 ; padding:2px; COLOR:green; color: blue';
const style_2_normalized = 'padding: 2px; color: blue;';
// https://github.com/sveltejs/svelte/issues/15309 // https://github.com/sveltejs/svelte/issues/15309
export default test({ export default test({
@ -10,7 +11,7 @@ export default test({
style: style_1 style: style_1
}, },
html: ` ssrHtml: `
<div style="${style_1}"></div> <div style="${style_1}"></div>
<div style="${style_1}"></div> <div style="${style_1}"></div>
@ -25,11 +26,11 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
target.innerHTML, target.innerHTML,
` `
<div style="${style_2}"></div> <div style="${style_2_normalized}"></div>
<div style="${style_2}"></div> <div style="${style_2_normalized}"></div>
<custom-element style="${style_2}"></custom-element> <custom-element style="${style_2_normalized}"></custom-element>
<custom-element style="${style_2}"></custom-element> <custom-element style="${style_2_normalized}"></custom-element>
` `
); );

Loading…
Cancel
Save