diff --git a/.changeset/strange-planes-shout.md b/.changeset/strange-planes-shout.md new file mode 100644 index 000000000000..58ef25274022 --- /dev/null +++ b/.changeset/strange-planes-shout.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: make `style:` directive and CSS handling more robust diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 322293bf6b91..1f636c32df6d 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -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 diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index 3dd303921369..6122dc4e0e66 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -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} 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; } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js index 3250c2439290..115eb6ccc11e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js @@ -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) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js index db8f2e4aa083..e0eb04d8236a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js @@ -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} 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)); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js index 281d8f061768..4a5becfb2fc6 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js @@ -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); } diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index cebc9173bab2..5a5d5d7c9b20 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -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; diff --git a/packages/svelte/src/internal/client/dom/elements/style.js b/packages/svelte/src/internal/client/dom/elements/style.js index 34531029c9c6..3e05eec30efa 100644 --- a/packages/svelte/src/internal/client/dom/elements/style.js +++ b/packages/svelte/src/internal/client/dom/elements/style.js @@ -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} prev + * @param {Record} 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 | [Record, Record]} [prev_styles] + * @param {Record | [Record, Record]} [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; } diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index f6ac92456e78..0ad9045b2062 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -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; diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 2591dbe4eaab..6098b496c5ac 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -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} 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} [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} styles + * @param {any} value + * @param {Record|[Record,Record]} [directives] */ -export function merge_styles(attribute, styles) { - /** @type {Record} */ - 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'; diff --git a/packages/svelte/src/internal/shared/attributes.js b/packages/svelte/src/internal/shared/attributes.js index 89cc17e51b9d..c8758c1d4d4d 100644 --- a/packages/svelte/src/internal/shared/attributes.js +++ b/packages/svelte/src/internal/shared/attributes.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} 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 | [Record, Record]} [styles] + * @returns {string | null} + */ +export function to_style(value, styles) { + if (styles) { + var new_style = ''; + + /** @type {Record | undefined} */ + var normal_styles; + + /** @type {Record | 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); +} diff --git a/packages/svelte/tests/runtime-legacy/samples/inline-style-directive-spread-and-attr-empty/_config.js b/packages/svelte/tests/runtime-legacy/samples/inline-style-directive-spread-and-attr-empty/_config.js index 9ff0007c3713..04c9868ac378 100644 --- a/packages/svelte/tests/runtime-legacy/samples/inline-style-directive-spread-and-attr-empty/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/inline-style-directive-spread-and-attr-empty/_config.js @@ -2,6 +2,6 @@ import { test } from '../../test'; export default test({ html: ` -

+

` }); diff --git a/packages/svelte/tests/runtime-legacy/samples/inline-style-optimisation-bailout/_config.js b/packages/svelte/tests/runtime-legacy/samples/inline-style-optimisation-bailout/_config.js index adcdc4706d88..e9965b2b1e26 100644 --- a/packages/svelte/tests/runtime-legacy/samples/inline-style-optimisation-bailout/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/inline-style-optimisation-bailout/_config.js @@ -2,7 +2,7 @@ import { ok, test } from '../../test'; export default test({ html: ` -

color: red

+

color: red;

`, test({ assert, component, target, window }) { diff --git a/packages/svelte/tests/runtime-legacy/samples/inline-style-optimisation-bailout/main.svelte b/packages/svelte/tests/runtime-legacy/samples/inline-style-optimisation-bailout/main.svelte index 35b768547e25..e07adaa1c9d8 100644 --- a/packages/svelte/tests/runtime-legacy/samples/inline-style-optimisation-bailout/main.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/inline-style-optimisation-bailout/main.svelte @@ -1,5 +1,5 @@

{styles}

\ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-style-attr/_config.js b/packages/svelte/tests/runtime-runes/samples/dynamic-style-attr/_config.js index f6829721795c..20092ddadf34 100644 --- a/packages/svelte/tests/runtime-runes/samples/dynamic-style-attr/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-style-attr/_config.js @@ -2,7 +2,7 @@ import { test } from '../../test'; import { flushSync } from 'svelte'; export default test({ - html: `
Hello world
diff --git a/packages/svelte/tests/runtime-runes/samples/style-directive-mutations/_config.js b/packages/svelte/tests/runtime-runes/samples/style-directive-mutations/_config.js new file mode 100644 index 000000000000..bd76e4e6b929 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/style-directive-mutations/_config.js @@ -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: ` +
+
+
+
+
+
+
+
+
+
+
+ `, + + html: ` +
+
+
+
+
+
+
+
+
+
+
+ `, + + 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, '')); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/style-directive-mutations/main.svelte b/packages/svelte/tests/runtime-runes/samples/style-directive-mutations/main.svelte new file mode 100644 index 000000000000..ae4da8ae37c7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/style-directive-mutations/main.svelte @@ -0,0 +1,54 @@ + + +
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/svelte/tests/runtime-runes/samples/style-update/_config.js b/packages/svelte/tests/runtime-runes/samples/style-update/_config.js index f0b7f2648e6d..52690a431a4d 100644 --- a/packages/svelte/tests/runtime-runes/samples/style-update/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/style-update/_config.js @@ -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: `
@@ -25,11 +26,11 @@ export default test({ assert.htmlEqual( target.innerHTML, ` -
-
+
+
- - + + ` );