diff --git a/.changeset/quiet-berries-explode.md b/.changeset/quiet-berries-explode.md new file mode 100644 index 0000000000..5bc9c4247e --- /dev/null +++ b/.changeset/quiet-berries-explode.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: more efficient spread attributes in SSR output diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 2cb251f120..d57f848f1e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -24,7 +24,13 @@ import { import { create_attribute, is_custom_element_node, is_element_node } from '../../nodes.js'; import { binding_properties } from '../../bindings.js'; import { regex_starts_with_newline, regex_whitespaces_strict } from '../../patterns.js'; -import { DOMBooleanAttributes, HYDRATION_END, HYDRATION_START } from '../../../../constants.js'; +import { + DOMBooleanAttributes, + ELEMENT_IS_NAMESPACED, + ELEMENT_PRESERVE_ATTRIBUTE_CASE, + HYDRATION_END, + HYDRATION_START +} from '../../../../constants.js'; import { escape_html } from '../../../../escaping.js'; import { sanitize_template_string } from '../../../utils/sanitize_template_string.js'; import { BLOCK_CLOSE, BLOCK_CLOSE_ELSE } from '../../../../internal/server/hydration.js'; @@ -889,42 +895,29 @@ function serialize_element_spread_attributes( class_directives, context ) { - /** @type {import('estree').Expression[]} */ - const values = []; - - for (const attribute of attributes) { - if (attribute.type === 'Attribute') { - const name = get_attribute_name(element, attribute, context); - const value = serialize_attribute_value( - attribute.value, - context, - WhitespaceInsensitiveAttributes.includes(name) - ); - values.push(b.object([b.prop('init', b.literal(name), value)])); - } else { - values.push(/** @type {import('estree').Expression} */ (context.visit(attribute))); - } - } + let classes; + let styles; + let flags = 0; - const lowercase_attributes = - element.metadata.svg || - element.metadata.mathml || - (element.type === 'RegularElement' && is_custom_element_node(element)) - ? b.false - : b.true; + if (class_directives.length > 0 || context.state.analysis.css.hash) { + const properties = class_directives.map((directive) => + b.init( + directive.name, + directive.expression.type === 'Identifier' && directive.expression.name === directive.name + ? b.id(directive.name) + : /** @type {import('estree').Expression} */ (context.visit(directive.expression)) + ) + ); - const is_html = element.metadata.svg || element.metadata.mathml ? b.false : b.true; + if (context.state.analysis.css.hash) { + properties.unshift(b.init(context.state.analysis.css.hash, b.literal(true))); + } - /** @type {import('estree').Expression[]} */ - const args = [ - b.array(values), - lowercase_attributes, - is_html, - b.literal(context.state.analysis.css.hash) - ]; + classes = b.object(properties); + } - if (style_directives.length > 0 || class_directives.length > 0) { - const styles = style_directives.map((directive) => + if (style_directives.length > 0) { + const properties = style_directives.map((directive) => b.init( directive.name, directive.value === true @@ -932,25 +925,33 @@ function serialize_element_spread_attributes( : serialize_attribute_value(directive.value, context, true) ) ); - const expressions = class_directives.map((directive) => - b.conditional(directive.expression, b.literal(directive.name), b.literal('')) - ); - const classes = expressions.length - ? b.call( - b.member( - b.call(b.member(b.array(expressions), b.id('filter')), b.id('Boolean')), - b.id('join') - ), - b.literal(' ') - ) - : b.literal(''); - args.push( - b.object([ - b.init('styles', styles.length === 0 ? b.literal(null) : b.object(styles)), - b.init('classes', classes) - ]) - ); + + styles = b.object(properties); } + + if (element.metadata.svg || element.metadata.mathml) { + flags |= ELEMENT_IS_NAMESPACED | ELEMENT_PRESERVE_ATTRIBUTE_CASE; + } else if (is_custom_element_node(element)) { + flags |= ELEMENT_PRESERVE_ATTRIBUTE_CASE; + } + + const object = b.object( + attributes.map((attribute) => { + if (attribute.type === 'Attribute') { + const name = get_attribute_name(element, attribute, context); + const value = serialize_attribute_value( + attribute.value, + context, + WhitespaceInsensitiveAttributes.includes(name) + ); + return b.prop('init', b.key(name), value); + } + + return b.spread(/** @type {import('estree').Expression} */ (context.visit(attribute))); + }) + ); + + const args = [object, classes, styles, flags ? b.literal(flags) : undefined]; context.state.template.push(t_expression(b.call('$.spread_attributes', ...args))); } diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js index 33c4d9fba6..63c09986c8 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -21,11 +21,11 @@ export function is_element_node(node) { } /** - * @param {import('#compiler').RegularElement} node + * @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node * @returns {boolean} */ export function is_custom_element_node(node) { - return node.name.includes('-'); + return node.type === 'RegularElement' && node.name.includes('-'); } /** diff --git a/packages/svelte/src/constants.js b/packages/svelte/src/constants.js index 9f1974f2ce..1083561d81 100644 --- a/packages/svelte/src/constants.js +++ b/packages/svelte/src/constants.js @@ -24,6 +24,9 @@ export const HYDRATION_END = ']'; export const HYDRATION_END_ELSE = `${HYDRATION_END}!`; // used to indicate that an `{:else}...` block was rendered export const HYDRATION_ERROR = {}; +export const ELEMENT_IS_NAMESPACED = 1; +export const ELEMENT_PRESERVE_ATTRIBUTE_CASE = 1 << 1; + export const UNINITIALIZED = Symbol(); /** List of elements that require raw contents and should not have SSR comments put in them */ diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index f55a72846c..bf9d5d1c57 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -1,6 +1,12 @@ import { is_promise, noop } from '../shared/utils.js'; import { subscribe_to_store } from '../../store/utils.js'; -import { UNINITIALIZED, DOMBooleanAttributes, RawTextElements } from '../../constants.js'; +import { + UNINITIALIZED, + DOMBooleanAttributes, + RawTextElements, + ELEMENT_PRESERVE_ATTRIBUTE_CASE, + ELEMENT_IS_NAMESPACED +} from '../../constants.js'; import { escape_html } from '../../escaping.js'; import { DEV } from 'esm-env'; import { current_component, pop, push } from './context.js'; @@ -178,66 +184,49 @@ export function css_props(payload, is_html, props, component) { } /** - * @param {Record[]} attrs - * @param {boolean} lowercase_attributes - * @param {boolean} is_html - * @param {string} class_hash - * @param {{ styles: Record | null; classes: string }} [additional] + * @param {Record} attrs + * @param {Record} [classes] + * @param {Record} [styles] + * @param {number} [flags] * @returns {string} */ -export function spread_attributes(attrs, lowercase_attributes, is_html, class_hash, additional) { - /** @type {Record} */ - const merged_attrs = {}; - let key; - - for (let i = 0; i < attrs.length; i++) { - const obj = attrs[i]; - for (key in obj) { - // omit functions and internal svelte properties - const prefix = key[0] + key[1]; // this is faster than key.slice(0, 2) - if (typeof obj[key] !== 'function' && prefix !== '$$') { - merged_attrs[key] = obj[key]; - } - } - } - - const styles = additional?.styles; +export function spread_attributes(attrs, classes, styles, flags = 0) { if (styles) { - if ('style' in merged_attrs) { - merged_attrs.style = style_object_to_string( - merge_styles(/** @type {string} */ (merged_attrs.style), styles) - ); - } else { - merged_attrs.style = style_object_to_string(styles); - } + attrs.style = attrs.style + ? style_object_to_string(merge_styles(/** @type {string} */ (attrs.style), styles)) + : style_object_to_string(styles); } - if (class_hash) { - if ('class' in merged_attrs) { - merged_attrs.class += ` ${class_hash}`; - } else { - merged_attrs.class = class_hash; - } - } - const classes = additional?.classes; if (classes) { - if ('class' in merged_attrs) { - merged_attrs.class += ` ${classes}`; - } else { - merged_attrs.class = classes; + const classlist = attrs.class ? [attrs.class] : []; + + for (const key in classes) { + if (classes[key]) { + classlist.push(key); + } } + + attrs.class = classlist.join(' '); } let attr_str = ''; let name; - for (name in merged_attrs) { + const is_html = (flags & ELEMENT_IS_NAMESPACED) === 0; + const lowercase = (flags & ELEMENT_PRESERVE_ATTRIBUTE_CASE) === 0; + + for (name in attrs) { + // omit functions, internal svelte properties and invalid attribute names + if (typeof attrs[name] === 'function') continue; + if (name[0] === '$' && name[1] === '$') continue; // faster than name.startsWith('$$') if (INVALID_ATTR_NAME_CHAR_REGEX.test(name)) continue; - if (lowercase_attributes) { + + if (lowercase) { name = name.toLowerCase(); } + const is_boolean = is_html && DOMBooleanAttributes.includes(name); - attr_str += attr(name, merged_attrs[name], is_boolean); + attr_str += attr(name, attrs[name], is_boolean); } return attr_str;