From 48d2e83b15ac3b7e2e59bcf5c9bc212cd795c946 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 14 Nov 2024 12:39:08 +0100 Subject: [PATCH 01/18] WIP --- packages/svelte/package.json | 1 + .../src/compiler/phases/2-analyze/index.js | 2 ++ .../phases/2-analyze/visitors/Attribute.js | 6 ++++++ .../client/visitors/RegularElement.js | 8 ++++++- .../server/visitors/shared/element.js | 21 ++++++++++++++++--- packages/svelte/src/compiler/phases/nodes.js | 3 ++- .../svelte/src/compiler/types/template.d.ts | 2 ++ .../src/internal/client/dom/elements/class.js | 5 +++-- packages/svelte/src/internal/client/index.js | 1 + packages/svelte/src/internal/server/index.js | 1 + pnpm-lock.yaml | 9 ++++++++ 11 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 760272680e37..9fcd1eaab011 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -142,6 +142,7 @@ "acorn-typescript": "^1.4.13", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", + "clsx": "^2.1.1", "esm-env": "^1.0.0", "esrap": "^1.2.2", "is-reference": "^3.0.2", diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 9e4f72a66d7a..b16210332cf3 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -739,6 +739,8 @@ export function analyze_component(root, source, options) { if (attribute.type !== 'Attribute') continue; if (attribute.name.toLowerCase() !== 'class') continue; + // The dynamic class method appends the hash to the end of the class attribute on its own + if (attribute.metadata.is_dynamic_class) continue outer; class_attribute = attribute; } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js index 2a281a1aa3d9..0daa85e47cb3 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js @@ -24,6 +24,12 @@ export function Attribute(node, context) { } } + // class={[...]} or class={{...}} or `class={x}` need clsx to resolve the classes + if (node.name === 'class' && !Array.isArray(node.value) && node.value !== true) { + mark_subtree_dynamic(context.path); + node.metadata.is_dynamic_class = true; + } + if (node.value !== true) { for (const chunk of get_attribute_chunks(node.value)) { if (chunk.type !== 'ExpressionTag') continue; 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 4d3cebcee6fe..46948193138b 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 @@ -544,6 +544,10 @@ function build_element_attribute_update_assignment(element, node_id, attribute, let update; if (name === 'class') { + if (attribute.metadata.is_dynamic_class) { + value = b.call('$.clsx', value); + } + if (attribute.metadata.expression.has_state && has_call) { // ensure we're not creating a separate template effect for this so that // potential class directives are added to the same effect and therefore always apply @@ -552,11 +556,13 @@ function build_element_attribute_update_assignment(element, node_id, attribute, value = b.call('$.get', id); has_call = false; } + update = b.stmt( b.call( is_svg ? '$.set_svg_class' : is_mathml ? '$.set_mathml_class' : '$.set_class', node_id, - value + value, + attribute.metadata.is_dynamic_class ? b.literal(context.state.analysis.css.hash) : undefined ) ); } else if (name === 'value') { 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 c386c4f7c05e..0702ae208d18 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 @@ -85,10 +85,25 @@ export function build_element_attributes(node, context) { } else { if (attribute.name === 'class') { class_index = attributes.length; - } else if (attribute.name === 'style') { - style_index = attributes.length; + if (attribute.metadata.is_dynamic_class) { + attributes.push({ + ...attribute, + value: { + .../** @type {AST.ExpressionTag} */ (attribute.value), + expression: b.call( + '$.clsx', + /** @type {AST.ExpressionTag} */ (attribute.value).expression, + b.literal(context.state.analysis.css.hash) + ) + } + }); + } + } else { + if (attribute.name === 'style') { + style_index = attributes.length; + } + attributes.push(attribute); } - attributes.push(attribute); } } else if (attribute.type === 'BindDirective') { if (attribute.name === 'value' && node.name === 'select') continue; diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js index ead525aaa149..89bb18ff5409 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -46,7 +46,8 @@ export function create_attribute(name, start, end, value) { parent: null, metadata: { expression: create_expression_metadata(), - delegated: null + delegated: null, + is_dynamic_class: false } }; } diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index fd1824d3b3db..e48b1de016f9 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -452,6 +452,8 @@ export namespace AST { expression: ExpressionMetadata; /** May be set if this is an event attribute */ delegated: null | DelegatedEvent; + /** May be `true` if this is a `class` attribute that needs `clsx` */ + is_dynamic_class: boolean; }; } diff --git a/packages/svelte/src/internal/client/dom/elements/class.js b/packages/svelte/src/internal/client/dom/elements/class.js index 22f3da0f44f9..bad88735b577 100644 --- a/packages/svelte/src/internal/client/dom/elements/class.js +++ b/packages/svelte/src/internal/client/dom/elements/class.js @@ -61,12 +61,13 @@ export function set_mathml_class(dom, value) { /** * @param {HTMLElement} dom * @param {string} value + * @param {string} [hash] * @returns {void} */ -export function set_class(dom, value) { +export function set_class(dom, value, hash) { // @ts-expect-error need to add __className to patched prototype var prev_class_name = dom.__className; - var next_class_name = to_class(value); + var next_class_name = to_class(value) + (hash != null ? ' ' + hash : ''); if (hydrating && dom.className === next_class_name) { // In case of hydration don't reset the class as it's already correct. diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index c401867a0f0b..de8f2c007ab5 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -1,3 +1,4 @@ +export { clsx } from 'clsx'; export { FILENAME, HMR, NAMESPACE_SVG } from '../../constants.js'; export { cleanup_styles } from './dev/css.js'; export { add_locations } from './dev/elements.js'; diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 9a66095de482..121be70c28ff 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -1,6 +1,7 @@ /** @import { ComponentType, SvelteComponent } from 'svelte' */ /** @import { Component, Payload, RenderOutput } from '#server' */ /** @import { Store } from '#shared' */ +export { clsx } from 'clsx'; export { FILENAME, HMR } from '../../constants.js'; import { is_promise, noop } from '../shared/utils.js'; import { subscribe_to_store } from '../../store/utils.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f33a207fc04..e25122cd8806 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: axobject-query: specifier: ^4.1.0 version: 4.1.0 + clsx: + specifier: ^2.1.1 + version: 2.1.1 esm-env: specifier: ^1.0.0 version: 1.0.0 @@ -1211,6 +1214,10 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + codemirror@6.0.1: resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} @@ -3950,6 +3957,8 @@ snapshots: ci-info@3.9.0: {} + clsx@2.1.1: {} + codemirror@6.0.1(@lezer/common@1.2.1): dependencies: '@codemirror/autocomplete': 6.18.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.33.0)(@lezer/common@1.2.1) From 485dd9bff69ff5b78ba72084e7066cbf866d3b9f Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sun, 15 Dec 2024 13:19:00 +0100 Subject: [PATCH 02/18] missed --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85a735ba4e94..6ea9f4829a53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -893,6 +893,10 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3058,6 +3062,8 @@ snapshots: ci-info@3.9.0: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 From 0ccf5b26ef0bb6ed0f8a8f28803aeae385ae6859 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sun, 15 Dec 2024 13:28:16 +0100 Subject: [PATCH 03/18] fix --- .../phases/3-transform/server/visitors/shared/element.js | 4 ++++ 1 file changed, 4 insertions(+) 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 e612cb831c3f..789df5919625 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 @@ -86,6 +86,7 @@ export function build_element_attributes(node, context) { } else if (attribute.name !== 'defaultValue' && attribute.name !== 'defaultChecked') { if (attribute.name === 'class') { class_index = attributes.length; + if (attribute.metadata.is_dynamic_class) { attributes.push({ ...attribute, @@ -98,11 +99,14 @@ export function build_element_attributes(node, context) { ) } }); + } else { + attributes.push(attribute); } } else { if (attribute.name === 'style') { style_index = attributes.length; } + attributes.push(attribute); } } From 53584918023b740c2864bdcb7768bb4627997276 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sun, 15 Dec 2024 13:31:51 +0100 Subject: [PATCH 04/18] fix --- packages/svelte/src/internal/client/dom/elements/class.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/dom/elements/class.js b/packages/svelte/src/internal/client/dom/elements/class.js index bad88735b577..bf153ebfbea3 100644 --- a/packages/svelte/src/internal/client/dom/elements/class.js +++ b/packages/svelte/src/internal/client/dom/elements/class.js @@ -67,7 +67,7 @@ export function set_mathml_class(dom, value) { export function set_class(dom, value, hash) { // @ts-expect-error need to add __className to patched prototype var prev_class_name = dom.__className; - var next_class_name = to_class(value) + (hash != null ? ' ' + hash : ''); + var next_class_name = to_class(value) + (hash ? ' ' + hash : ''); if (hydrating && dom.className === next_class_name) { // In case of hydration don't reset the class as it's already correct. From 7664aee83ff792979dd6af589ee188ce12a2b3bc Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 17 Dec 2024 21:04:51 +0100 Subject: [PATCH 05/18] rename, smooth over incompatibilities --- .../src/compiler/phases/2-analyze/index.js | 2 +- .../phases/2-analyze/visitors/Attribute.js | 11 +++++++++-- .../client/visitors/RegularElement.js | 4 ++-- .../server/visitors/shared/element.js | 18 ++++++++++++------ packages/svelte/src/compiler/phases/nodes.js | 2 +- .../svelte/src/compiler/types/template.d.ts | 2 +- .../src/internal/client/dom/elements/class.js | 19 +++++++++++-------- packages/svelte/src/internal/client/index.js | 3 +-- packages/svelte/src/internal/server/index.js | 5 ++--- .../svelte/src/internal/shared/attributes.js | 14 ++++++++++++++ .../_config.js | 2 +- .../_config.js | 2 +- .../_config.js | 2 +- .../_config.js | 2 +- 14 files changed, 58 insertions(+), 30 deletions(-) diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 9fb9ec7999c7..983a0ac9d1d9 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -770,7 +770,7 @@ export function analyze_component(root, source, options) { if (attribute.type !== 'Attribute') continue; if (attribute.name.toLowerCase() !== 'class') continue; // The dynamic class method appends the hash to the end of the class attribute on its own - if (attribute.metadata.is_dynamic_class) continue outer; + if (attribute.metadata.needs_clsx) continue outer; class_attribute = attribute; } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js index 12977979d569..9d801e095e8d 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js @@ -39,9 +39,16 @@ export function Attribute(node, context) { } // class={[...]} or class={{...}} or `class={x}` need clsx to resolve the classes - if (node.name === 'class' && !Array.isArray(node.value) && node.value !== true) { + if ( + node.name === 'class' && + !Array.isArray(node.value) && + node.value !== true && + node.value.expression.type !== 'Literal' && + node.value.expression.type !== 'TemplateLiteral' && + node.value.expression.type !== 'BinaryExpression' + ) { mark_subtree_dynamic(context.path); - node.metadata.is_dynamic_class = true; + node.metadata.needs_clsx = true; } if (node.value !== true) { 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 d5e986812335..59a6fafbc5d7 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 @@ -553,7 +553,7 @@ function build_element_attribute_update_assignment( let update; if (name === 'class') { - if (attribute.metadata.is_dynamic_class) { + if (attribute.metadata.needs_clsx) { value = b.call('$.clsx', value); } @@ -571,7 +571,7 @@ function build_element_attribute_update_assignment( is_svg ? '$.set_svg_class' : is_mathml ? '$.set_mathml_class' : '$.set_class', node_id, value, - attribute.metadata.is_dynamic_class ? b.literal(context.state.analysis.css.hash) : undefined + attribute.metadata.needs_clsx ? b.literal(context.state.analysis.css.hash) : undefined ) ); } else if (name === 'value') { 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 789df5919625..d0d800d3cbc5 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 @@ -87,16 +87,22 @@ export function build_element_attributes(node, context) { if (attribute.name === 'class') { class_index = attributes.length; - if (attribute.metadata.is_dynamic_class) { + if (attribute.metadata.needs_clsx) { + const clsx_value = b.call( + '$.clsx', + /** @type {AST.ExpressionTag} */ (attribute.value).expression + ); attributes.push({ ...attribute, value: { .../** @type {AST.ExpressionTag} */ (attribute.value), - expression: b.call( - '$.clsx', - /** @type {AST.ExpressionTag} */ (attribute.value).expression, - b.literal(context.state.analysis.css.hash) - ) + expression: context.state.analysis.css.hash + ? b.binary( + '+', + b.binary('+', clsx_value, b.literal(' ')), + b.literal(context.state.analysis.css.hash) + ) + : clsx_value } }); } else { diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js index 7645af75b756..5066833feb8e 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -46,7 +46,7 @@ export function create_attribute(name, start, end, value) { metadata: { expression: create_expression_metadata(), delegated: null, - is_dynamic_class: false + needs_clsx: false } }; } diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 203df4ea5635..8be9aed17723 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -483,7 +483,7 @@ export namespace AST { /** May be set if this is an event attribute */ delegated: null | DelegatedEvent; /** May be `true` if this is a `class` attribute that needs `clsx` */ - is_dynamic_class: boolean; + needs_clsx: boolean; }; } diff --git a/packages/svelte/src/internal/client/dom/elements/class.js b/packages/svelte/src/internal/client/dom/elements/class.js index bf153ebfbea3..62ffb6d14b5c 100644 --- a/packages/svelte/src/internal/client/dom/elements/class.js +++ b/packages/svelte/src/internal/client/dom/elements/class.js @@ -3,12 +3,13 @@ import { hydrating } from '../hydration.js'; /** * @param {SVGElement} dom * @param {string} value + * @param {string} [hash] * @returns {void} */ -export function set_svg_class(dom, value) { +export function set_svg_class(dom, value, hash) { // @ts-expect-error need to add __className to patched prototype var prev_class_name = dom.__className; - var next_class_name = to_class(value); + var next_class_name = to_class(value, hash); if (hydrating && dom.getAttribute('class') === next_class_name) { // In case of hydration don't reset the class as it's already correct. @@ -32,12 +33,13 @@ export function set_svg_class(dom, value) { /** * @param {MathMLElement} dom * @param {string} value + * @param {string} [hash] * @returns {void} */ -export function set_mathml_class(dom, value) { +export function set_mathml_class(dom, value, hash) { // @ts-expect-error need to add __className to patched prototype var prev_class_name = dom.__className; - var next_class_name = to_class(value); + var next_class_name = to_class(value, hash); if (hydrating && dom.getAttribute('class') === next_class_name) { // In case of hydration don't reset the class as it's already correct. @@ -67,7 +69,7 @@ export function set_mathml_class(dom, value) { export function set_class(dom, value, hash) { // @ts-expect-error need to add __className to patched prototype var prev_class_name = dom.__className; - var next_class_name = to_class(value) + (hash ? ' ' + hash : ''); + var next_class_name = to_class(value, hash); if (hydrating && dom.className === next_class_name) { // In case of hydration don't reset the class as it's already correct. @@ -80,7 +82,7 @@ export function set_class(dom, value, hash) { // Removing the attribute when the value is only an empty string causes // peformance issues vs simply making the className an empty string. So // we should only remove the class if the the value is nullish. - if (value == null) { + if (value == null && !hash) { dom.removeAttribute('class'); } else { dom.className = next_class_name; @@ -94,10 +96,11 @@ export function set_class(dom, value, hash) { /** * @template V * @param {V} value + * @param {string} [hash] * @returns {string | V} */ -function to_class(value) { - return value == null ? '' : value; +function to_class(value, hash) { + return (value == null ? '' : value) + (hash ? ' ' + hash : ''); } /** diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 589795d217cf..3b9add7f61ed 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -1,4 +1,3 @@ -export { clsx } from 'clsx'; export { FILENAME, HMR, NAMESPACE_SVG } from '../../constants.js'; export { assign, assign_and, assign_or, assign_nullish } from './dev/assign.js'; export { cleanup_styles } from './dev/css.js'; @@ -161,7 +160,7 @@ export { $window as window, $document as document } from './dom/operations.js'; -export { attr } from '../shared/attributes.js'; +export { attr, clsx } from '../shared/attributes.js'; export { snapshot } from '../shared/clone.js'; export { noop, fallback } from '../shared/utils.js'; export { diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 3e4be373c386..72b46302403a 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -1,9 +1,8 @@ /** @import { ComponentType, SvelteComponent } from 'svelte' */ /** @import { Component, Payload, RenderOutput } from '#server' */ /** @import { Store } from '#shared' */ -export { clsx } from 'clsx'; export { FILENAME, HMR } from '../../constants.js'; -import { attr } from '../shared/attributes.js'; +import { attr, clsx } from '../shared/attributes.js'; import { is_promise, noop } from '../shared/utils.js'; import { subscribe_to_store } from '../../store/utils.js'; import { @@ -523,7 +522,7 @@ export function once(get_value) { }; } -export { attr }; +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 867d6ba5d378..a561501bf4f6 100644 --- a/packages/svelte/src/internal/shared/attributes.js +++ b/packages/svelte/src/internal/shared/attributes.js @@ -1,4 +1,5 @@ import { escape_html } from '../../escaping.js'; +import { clsx as _clsx } from 'clsx'; /** * `
` should be rendered as `
` and _not_ @@ -26,3 +27,16 @@ export function attr(name, value, is_boolean = false) { const assignment = is_boolean ? '' : `="${escape_html(normalized, true)}"`; return ` ${name}${assignment}`; } + +/** + * Small wrapper around clsx to preserve Svelte's (weird) handling of falsy values. + * TODO Svelte 6 revisit this, and likely turn all falsy values into the empty string (what clsx also does) + * @param {any} value + */ +export function clsx(value) { + if (typeof value === 'object') { + return _clsx(value); + } else { + return value ?? ''; + } +} diff --git a/packages/svelte/tests/runtime-legacy/samples/attribute-null-classname-no-style/_config.js b/packages/svelte/tests/runtime-legacy/samples/attribute-null-classname-no-style/_config.js index a7518f7e6cf2..b2f039618153 100644 --- a/packages/svelte/tests/runtime-legacy/samples/attribute-null-classname-no-style/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/attribute-null-classname-no-style/_config.js @@ -40,7 +40,7 @@ export default test({ assert.equal(div.className, 'true'); component.testName = {}; - assert.equal(div.className, '[object Object]'); + assert.equal(div.className, ''); component.testName = ''; assert.equal(div.className, ''); diff --git a/packages/svelte/tests/runtime-legacy/samples/attribute-null-classname-with-style/_config.js b/packages/svelte/tests/runtime-legacy/samples/attribute-null-classname-with-style/_config.js index dbab9fd42b98..c8710f9038b9 100644 --- a/packages/svelte/tests/runtime-legacy/samples/attribute-null-classname-with-style/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/attribute-null-classname-with-style/_config.js @@ -32,7 +32,7 @@ export default test({ assert.equal(div.className, 'true svelte-x1o6ra'); component.testName = {}; - assert.equal(div.className, '[object Object] svelte-x1o6ra'); + assert.equal(div.className, ' svelte-x1o6ra'); component.testName = ''; assert.equal(div.className, ' svelte-x1o6ra'); diff --git a/packages/svelte/tests/runtime-legacy/samples/attribute-null-func-classname-no-style/_config.js b/packages/svelte/tests/runtime-legacy/samples/attribute-null-func-classname-no-style/_config.js index a7518f7e6cf2..b2f039618153 100644 --- a/packages/svelte/tests/runtime-legacy/samples/attribute-null-func-classname-no-style/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/attribute-null-func-classname-no-style/_config.js @@ -40,7 +40,7 @@ export default test({ assert.equal(div.className, 'true'); component.testName = {}; - assert.equal(div.className, '[object Object]'); + assert.equal(div.className, ''); component.testName = ''; assert.equal(div.className, ''); diff --git a/packages/svelte/tests/runtime-legacy/samples/attribute-null-func-classname-with-style/_config.js b/packages/svelte/tests/runtime-legacy/samples/attribute-null-func-classname-with-style/_config.js index 20426dcf4b4b..8d0f411b8fd2 100644 --- a/packages/svelte/tests/runtime-legacy/samples/attribute-null-func-classname-with-style/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/attribute-null-func-classname-with-style/_config.js @@ -40,7 +40,7 @@ export default test({ assert.equal(div.className, 'true svelte-x1o6ra'); component.testName = {}; - assert.equal(div.className, '[object Object] svelte-x1o6ra'); + assert.equal(div.className, ' svelte-x1o6ra'); component.testName = ''; assert.equal(div.className, ' svelte-x1o6ra'); From 922c5445da4b6161a267e30ee619fb5f1e50109b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 17 Dec 2024 23:25:42 +0100 Subject: [PATCH 06/18] spread support + test --- .../client/dom/elements/attributes.js | 5 +++ packages/svelte/src/internal/server/index.js | 4 ++ .../runtime-runes/samples/clsx/_config.js | 43 +++++++++++++++++++ .../runtime-runes/samples/clsx/child.svelte | 5 +++ .../runtime-runes/samples/clsx/main.svelte | 33 ++++++++++++++ 5 files changed, 90 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/clsx/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/clsx/child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/clsx/main.svelte diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index 9c62d684c183..6656532986d7 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -13,6 +13,7 @@ import { set_active_effect, set_active_reaction } from '../../runtime.js'; +import { clsx } from '../../../shared/attributes.js'; /** * The value/checked attribute in the template actually corresponds to the defaultValue property, so we need @@ -267,6 +268,10 @@ export function set_attributes( } } + if (next.class) { + next.class = clsx(next.class); + } + if (css_hash !== undefined) { next.class = next.class ? next.class + ' ' + css_hash : css_hash; } diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 72b46302403a..89b3c33df887 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -195,6 +195,10 @@ export function spread_attributes(attrs, classes, styles, flags = 0) { : style_object_to_string(styles); } + if (attrs.class) { + attrs.class = clsx(attrs.class); + } + if (classes) { const classlist = attrs.class ? [attrs.class] : []; diff --git a/packages/svelte/tests/runtime-runes/samples/clsx/_config.js b/packages/svelte/tests/runtime-runes/samples/clsx/_config.js new file mode 100644 index 000000000000..e0813d0e6c40 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/clsx/_config.js @@ -0,0 +1,43 @@ +import { test } from '../../test'; + +export default test({ + html: ` +
+
+
+
+
+ +
child
+
child
+
child
+
child
+
child
+ + + `, + test({ assert, target }) { + const button = target.querySelector('button'); + + button?.click(); + + assert.htmlEqual( + target.innerHTML, + ` +
+
+
+
+
+ +
child
+
child
+
child
+
child
+
child
+ + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/clsx/child.svelte b/packages/svelte/tests/runtime-runes/samples/clsx/child.svelte new file mode 100644 index 000000000000..1b8be697c052 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/clsx/child.svelte @@ -0,0 +1,5 @@ + + +
child
diff --git a/packages/svelte/tests/runtime-runes/samples/clsx/main.svelte b/packages/svelte/tests/runtime-runes/samples/clsx/main.svelte new file mode 100644 index 000000000000..bf68b42e1108 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/clsx/main.svelte @@ -0,0 +1,33 @@ + + +
+
+
+
+
+ + + + + + + + + + From 343bef10be44b1119deed20d12c6d7cb854825e5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 17 Dec 2024 23:49:54 +0100 Subject: [PATCH 07/18] docs --- .../docs/03-template-syntax/16-class.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/documentation/docs/03-template-syntax/16-class.md b/documentation/docs/03-template-syntax/16-class.md index cecbc7cf2041..02839bfd60be 100644 --- a/documentation/docs/03-template-syntax/16-class.md +++ b/documentation/docs/03-template-syntax/16-class.md @@ -2,6 +2,64 @@ title: class: --- +Svelte provides ergonomic helpers to conditionally set classes on elements. + +## class + +Since Svelte 5.15, you can pass an object or array to the `class` attribute to conditionally set classes on elements. The logic is as follows: + +- Primitive: All truthy values are added, all falsy not +- `Object`: All truthy keys are added to the element class +- `Array`: Objects and primitives are handled according to the two previous descriptions, nested arrays are flattened + +```svelte + +
...
+
...
+
...
+``` + +You can use this to conditionally set many classes at once, including those that have special characters. + +```svelte + +
...
+
...
+``` + +Since `class` itself takes these values, you can use the same syntax on component properties when forwarding those to the `class` attribute. + +```svelte + + + + +``` + +```svelte + + + + + +``` + +Under the hood this is using [`clsx`](https://github.com/lukeed/clsx), so if you need more details on the syntax, you can visit its documentation. + +## class: + The `class:` directive is a convenient way to conditionally set classes on elements, as an alternative to using conditional expressions inside `class` attributes: ```svelte @@ -21,3 +79,5 @@ Multiple `class:` directives can be added to a single element: ```svelte
...
``` + +> [!NOTE] Since Svelte 5.15, you have the same expressive power with extra features on the `class` attribute itself, so use that instead if possible From 9a281066d72950fd9476ce1fda94c629e47866ef Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 18 Dec 2024 18:24:27 +0100 Subject: [PATCH 08/18] types --- packages/svelte/elements.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index 8800b65172dc..604403f0a261 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -741,7 +741,7 @@ export interface HTMLAttributes extends AriaAttributes, D accesskey?: string | undefined | null; autocapitalize?: 'characters' | 'off' | 'on' | 'none' | 'sentences' | 'words' | undefined | null; autofocus?: boolean | undefined | null; - class?: string | undefined | null; + class?: string | import('clsx').ClassArray | import('clsx').ClassDictionary | undefined | null; contenteditable?: Booleanish | 'inherit' | 'plaintext-only' | undefined | null; contextmenu?: string | undefined | null; dir?: 'ltr' | 'rtl' | 'auto' | undefined | null; @@ -1522,7 +1522,7 @@ export interface SvelteWindowAttributes extends HTMLAttributes { export interface SVGAttributes extends AriaAttributes, DOMAttributes { // Attributes which also defined in HTMLAttributes className?: string | undefined | null; - class?: string | undefined | null; + class?: string | import('clsx').ClassArray | import('clsx').ClassDictionary | undefined | null; color?: string | undefined | null; height?: number | string | undefined | null; id?: string | undefined | null; From 7840a2ce86b7972d753b22493a97df19972d3373 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 18 Dec 2024 18:32:44 +0100 Subject: [PATCH 09/18] implement CSS pruning for array/object expressions --- .../phases/2-analyze/css/css-prune.js | 2 +- .../compiler/phases/2-analyze/css/utils.js | 34 ++++++++++++++++--- .../css/samples/clsx-can-prune/_config.js | 20 +++++++++++ .../css/samples/clsx-can-prune/expected.css | 9 +++++ .../css/samples/clsx-can-prune/input.svelte | 16 +++++++++ .../samples/clsx-cannot-prune-1/expected.css | 2 ++ .../samples/clsx-cannot-prune-1/input.svelte | 5 +++ .../samples/clsx-cannot-prune-2/expected.css | 2 ++ .../samples/clsx-cannot-prune-2/input.svelte | 5 +++ .../samples/clsx-cannot-prune-3/expected.css | 2 ++ .../samples/clsx-cannot-prune-3/input.svelte | 5 +++ 11 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 packages/svelte/tests/css/samples/clsx-can-prune/_config.js create mode 100644 packages/svelte/tests/css/samples/clsx-can-prune/expected.css create mode 100644 packages/svelte/tests/css/samples/clsx-can-prune/input.svelte create mode 100644 packages/svelte/tests/css/samples/clsx-cannot-prune-1/expected.css create mode 100644 packages/svelte/tests/css/samples/clsx-cannot-prune-1/input.svelte create mode 100644 packages/svelte/tests/css/samples/clsx-cannot-prune-2/expected.css create mode 100644 packages/svelte/tests/css/samples/clsx-cannot-prune-2/input.svelte create mode 100644 packages/svelte/tests/css/samples/clsx-cannot-prune-3/expected.css create mode 100644 packages/svelte/tests/css/samples/clsx-cannot-prune-3/input.svelte diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js index 35bc675166ae..d5e0e82724df 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js @@ -731,7 +731,7 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv /** @type {string[]} */ let prev_values = []; for (const chunk of chunks) { - const current_possible_values = get_possible_values(chunk); + const current_possible_values = get_possible_values(chunk, name === 'class'); // impossible to find out all combinations if (!current_possible_values) return true; diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/utils.js b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js index d3fd71ec395b..6cb63215be80 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/css/utils.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js @@ -4,14 +4,37 @@ const UNKNOWN = {}; /** * @param {Node} node + * @param {boolean} is_class * @param {Set} set */ -function gather_possible_values(node, set) { +function gather_possible_values(node, is_class, set) { if (node.type === 'Literal') { set.add(String(node.value)); } else if (node.type === 'ConditionalExpression') { - gather_possible_values(node.consequent, set); - gather_possible_values(node.alternate, set); + gather_possible_values(node.consequent, is_class, set); + gather_possible_values(node.alternate, is_class, set); + } else if (is_class && node.type === 'ArrayExpression') { + for (const entry of node.elements) { + if (entry) { + gather_possible_values(entry, is_class, set); + } else { + set.add(UNKNOWN); + } + } + } else if (is_class && node.type === 'ObjectExpression') { + for (const property of node.properties) { + if ( + property.type === 'Property' && + !property.computed && + (property.key.type === 'Identifier' || property.key.type === 'Literal') + ) { + set.add( + property.key.type === 'Identifier' ? property.key.name : String(property.key.value) + ); + } else { + set.add(UNKNOWN); + } + } } else { set.add(UNKNOWN); } @@ -19,15 +42,16 @@ function gather_possible_values(node, set) { /** * @param {AST.Text | AST.ExpressionTag} chunk + * @param {boolean} is_class * @returns {Set | null} */ -export function get_possible_values(chunk) { +export function get_possible_values(chunk, is_class) { const values = new Set(); if (chunk.type === 'Text') { values.add(chunk.data); } else { - gather_possible_values(chunk.expression, values); + gather_possible_values(chunk.expression, is_class, values); } if (values.has(UNKNOWN)) return null; diff --git a/packages/svelte/tests/css/samples/clsx-can-prune/_config.js b/packages/svelte/tests/css/samples/clsx-can-prune/_config.js new file mode 100644 index 000000000000..0fdeb6282ece --- /dev/null +++ b/packages/svelte/tests/css/samples/clsx-can-prune/_config.js @@ -0,0 +1,20 @@ +import { test } from '../../test'; + +export default test({ + warnings: [ + { + code: 'css_unused_selector', + message: 'Unused CSS selector ".unused"\nhttps://svelte.dev/e/css_unused_selector', + start: { + line: 15, + column: 1, + character: 325 + }, + end: { + line: 15, + column: 8, + character: 332 + } + } + ] +}); diff --git a/packages/svelte/tests/css/samples/clsx-can-prune/expected.css b/packages/svelte/tests/css/samples/clsx-can-prune/expected.css new file mode 100644 index 000000000000..530859f6a6ad --- /dev/null +++ b/packages/svelte/tests/css/samples/clsx-can-prune/expected.css @@ -0,0 +1,9 @@ + + .used1.svelte-xyz { color: green; } + .used2.svelte-xyz { color: green; } + .used3.svelte-xyz { color: green; } + .used4.svelte-xyz { color: green; } + .used5.svelte-xyz { color: green; } + .used6.svelte-xyz { color: green; } + + /* (unused) .unused { color: red; }*/ diff --git a/packages/svelte/tests/css/samples/clsx-can-prune/input.svelte b/packages/svelte/tests/css/samples/clsx-can-prune/input.svelte new file mode 100644 index 000000000000..17b77f0737ac --- /dev/null +++ b/packages/svelte/tests/css/samples/clsx-can-prune/input.svelte @@ -0,0 +1,16 @@ +

+

+

+

+

+ + diff --git a/packages/svelte/tests/css/samples/clsx-cannot-prune-1/expected.css b/packages/svelte/tests/css/samples/clsx-cannot-prune-1/expected.css new file mode 100644 index 000000000000..243bdb2b82a6 --- /dev/null +++ b/packages/svelte/tests/css/samples/clsx-cannot-prune-1/expected.css @@ -0,0 +1,2 @@ + + .x.svelte-xyz { color: green; } diff --git a/packages/svelte/tests/css/samples/clsx-cannot-prune-1/input.svelte b/packages/svelte/tests/css/samples/clsx-cannot-prune-1/input.svelte new file mode 100644 index 000000000000..d9de0bdfd47b --- /dev/null +++ b/packages/svelte/tests/css/samples/clsx-cannot-prune-1/input.svelte @@ -0,0 +1,5 @@ +

hello world

+ + diff --git a/packages/svelte/tests/css/samples/clsx-cannot-prune-2/expected.css b/packages/svelte/tests/css/samples/clsx-cannot-prune-2/expected.css new file mode 100644 index 000000000000..243bdb2b82a6 --- /dev/null +++ b/packages/svelte/tests/css/samples/clsx-cannot-prune-2/expected.css @@ -0,0 +1,2 @@ + + .x.svelte-xyz { color: green; } diff --git a/packages/svelte/tests/css/samples/clsx-cannot-prune-2/input.svelte b/packages/svelte/tests/css/samples/clsx-cannot-prune-2/input.svelte new file mode 100644 index 000000000000..212deab4f060 --- /dev/null +++ b/packages/svelte/tests/css/samples/clsx-cannot-prune-2/input.svelte @@ -0,0 +1,5 @@ +

hello world

+ + diff --git a/packages/svelte/tests/css/samples/clsx-cannot-prune-3/expected.css b/packages/svelte/tests/css/samples/clsx-cannot-prune-3/expected.css new file mode 100644 index 000000000000..243bdb2b82a6 --- /dev/null +++ b/packages/svelte/tests/css/samples/clsx-cannot-prune-3/expected.css @@ -0,0 +1,2 @@ + + .x.svelte-xyz { color: green; } diff --git a/packages/svelte/tests/css/samples/clsx-cannot-prune-3/input.svelte b/packages/svelte/tests/css/samples/clsx-cannot-prune-3/input.svelte new file mode 100644 index 000000000000..2dfa1afd18aa --- /dev/null +++ b/packages/svelte/tests/css/samples/clsx-cannot-prune-3/input.svelte @@ -0,0 +1,5 @@ +

hello world

+ + From 9f2a2570151ab6b408630a774ea836e67241237e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Dec 2024 14:14:31 -0500 Subject: [PATCH 10/18] beefier static analysis --- .../compiler/phases/2-analyze/css/utils.js | 51 ++++++++++++++++--- .../css/samples/clsx-can-prune/_config.js | 8 +-- .../css/samples/clsx-can-prune/expected.css | 3 ++ .../css/samples/clsx-can-prune/input.svelte | 9 ++++ 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/utils.js b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js index 6cb63215be80..5acd71a7bf9d 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/css/utils.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js @@ -6,19 +6,56 @@ const UNKNOWN = {}; * @param {Node} node * @param {boolean} is_class * @param {Set} set + * @param {boolean} is_nested */ -function gather_possible_values(node, is_class, set) { +function gather_possible_values(node, is_class, set, is_nested = false) { + if (set.has(UNKNOWN)) { + // no point traversing any further + return; + } + if (node.type === 'Literal') { set.add(String(node.value)); } else if (node.type === 'ConditionalExpression') { - gather_possible_values(node.consequent, is_class, set); - gather_possible_values(node.alternate, is_class, set); + gather_possible_values(node.consequent, is_class, set, is_nested); + gather_possible_values(node.alternate, is_class, set, is_nested); + } else if (node.type === 'LogicalExpression') { + if (node.operator === '&&') { + // && is a special case, because the only way the left + // hand value can be included is if it's falsy. this is + // a bit of extra work but it's worth it because + // `class={[condition && 'blah']}` is common, + // and we don't want to deopt on `condition` + const left = new Set(); + gather_possible_values(node.left, is_class, left, is_nested); + + if (left.has(UNKNOWN)) { + // add all non-nullish falsy values, unless this is a `class` attribute that + // will be processed by cslx, in which case falsy values are removed, unless + // they're not inside an array/object (TODO 6.0 remove that last part) + if (!is_class || !is_nested) { + set.add(''); + set.add(false); + set.add(NaN); + set.add(0); // -0 and 0n are also falsy, but stringify to '0' + } + } else { + for (const value of left) { + if (!value) { + set.add(value); + } + } + } + + gather_possible_values(node.right, is_class, set, is_nested); + } else { + gather_possible_values(node.left, is_class, set, is_nested); + gather_possible_values(node.right, is_class, set, is_nested); + } } else if (is_class && node.type === 'ArrayExpression') { for (const entry of node.elements) { if (entry) { - gather_possible_values(entry, is_class, set); - } else { - set.add(UNKNOWN); + gather_possible_values(entry, is_class, set, true); } } } else if (is_class && node.type === 'ObjectExpression') { @@ -43,7 +80,7 @@ function gather_possible_values(node, is_class, set) { /** * @param {AST.Text | AST.ExpressionTag} chunk * @param {boolean} is_class - * @returns {Set | null} + * @returns {Set | null} */ export function get_possible_values(chunk, is_class) { const values = new Set(); diff --git a/packages/svelte/tests/css/samples/clsx-can-prune/_config.js b/packages/svelte/tests/css/samples/clsx-can-prune/_config.js index 0fdeb6282ece..43c80a318d52 100644 --- a/packages/svelte/tests/css/samples/clsx-can-prune/_config.js +++ b/packages/svelte/tests/css/samples/clsx-can-prune/_config.js @@ -6,14 +6,14 @@ export default test({ code: 'css_unused_selector', message: 'Unused CSS selector ".unused"\nhttps://svelte.dev/e/css_unused_selector', start: { - line: 15, + line: 24, column: 1, - character: 325 + character: 548 }, end: { - line: 15, + line: 24, column: 8, - character: 332 + character: 555 } } ] diff --git a/packages/svelte/tests/css/samples/clsx-can-prune/expected.css b/packages/svelte/tests/css/samples/clsx-can-prune/expected.css index 530859f6a6ad..620db0f022aa 100644 --- a/packages/svelte/tests/css/samples/clsx-can-prune/expected.css +++ b/packages/svelte/tests/css/samples/clsx-can-prune/expected.css @@ -5,5 +5,8 @@ .used4.svelte-xyz { color: green; } .used5.svelte-xyz { color: green; } .used6.svelte-xyz { color: green; } + .used7.svelte-xyz { color: green; } + .used8.svelte-xyz { color: green; } + .used9.svelte-xyz { color: green; } /* (unused) .unused { color: red; }*/ diff --git a/packages/svelte/tests/css/samples/clsx-can-prune/input.svelte b/packages/svelte/tests/css/samples/clsx-can-prune/input.svelte index 17b77f0737ac..2c75a39f9996 100644 --- a/packages/svelte/tests/css/samples/clsx-can-prune/input.svelte +++ b/packages/svelte/tests/css/samples/clsx-can-prune/input.svelte @@ -1,8 +1,14 @@ + +

+

+

From 1a860e099cd5f03c3b074202b4765b3f9fea612e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Dec 2024 14:30:36 -0500 Subject: [PATCH 11/18] lint --- .../svelte/src/compiler/phases/2-analyze/css/css-prune.js | 2 +- packages/svelte/src/compiler/phases/2-analyze/css/utils.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js index d5e0e82724df..ca7476ef7fc1 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js @@ -784,7 +784,7 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv prev_values.push(current_possible_value); } }); - if (prev_values.length < current_possible_values.size) { + if (prev_values.length < current_possible_values.length) { prev_values.push(' '); } if (prev_values.length > 20) { diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/utils.js b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js index 5acd71a7bf9d..45ba06e55e3e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/css/utils.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js @@ -80,7 +80,7 @@ function gather_possible_values(node, is_class, set, is_nested = false) { /** * @param {AST.Text | AST.ExpressionTag} chunk * @param {boolean} is_class - * @returns {Set | null} + * @returns {string[] | null} */ export function get_possible_values(chunk, is_class) { const values = new Set(); @@ -92,7 +92,7 @@ export function get_possible_values(chunk, is_class) { } if (values.has(UNKNOWN)) return null; - return values; + return [...values].map((value) => String(value)); } /** From c43f4069136f34fc304ef45ffc8be9fa8ad0753b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Dec 2024 14:36:10 -0500 Subject: [PATCH 12/18] rename doc --- documentation/docs/03-template-syntax/16-class.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/03-template-syntax/16-class.md b/documentation/docs/03-template-syntax/16-class.md index 02839bfd60be..0904d56ac009 100644 --- a/documentation/docs/03-template-syntax/16-class.md +++ b/documentation/docs/03-template-syntax/16-class.md @@ -1,5 +1,5 @@ --- -title: class: +title: class --- Svelte provides ergonomic helpers to conditionally set classes on elements. From 14d8a1f4654e74f8ffd9b069da35e12cadea0e57 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Dec 2024 14:36:35 -0500 Subject: [PATCH 13/18] move class after all directive docs --- .../docs/03-template-syntax/{16-class.md => 18-class.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename documentation/docs/03-template-syntax/{16-class.md => 18-class.md} (100%) diff --git a/documentation/docs/03-template-syntax/16-class.md b/documentation/docs/03-template-syntax/18-class.md similarity index 100% rename from documentation/docs/03-template-syntax/16-class.md rename to documentation/docs/03-template-syntax/18-class.md From f78cde807405a23b2b18996174a05ceddc6ae1fc Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Dec 2024 15:35:50 -0500 Subject: [PATCH 14/18] tweak docs - clarify top-level falsy values, stagger examples, demonstrate composition, discourage class: more strongly --- .../docs/03-template-syntax/18-class.md | 95 ++++++++++--------- 1 file changed, 51 insertions(+), 44 deletions(-) diff --git a/documentation/docs/03-template-syntax/18-class.md b/documentation/docs/03-template-syntax/18-class.md index 0904d56ac009..9582a582d935 100644 --- a/documentation/docs/03-template-syntax/18-class.md +++ b/documentation/docs/03-template-syntax/18-class.md @@ -2,82 +2,89 @@ title: class --- -Svelte provides ergonomic helpers to conditionally set classes on elements. +There are two ways to set classes on elements: the `class` attribute, and the `class:` directive. -## class +## Attributes -Since Svelte 5.15, you can pass an object or array to the `class` attribute to conditionally set classes on elements. The logic is as follows: - -- Primitive: All truthy values are added, all falsy not -- `Object`: All truthy keys are added to the element class -- `Array`: Objects and primitives are handled according to the two previous descriptions, nested arrays are flattened +Primitive values are treated like any other attribute: ```svelte - -
...
-
...
-
...
+
...
``` -You can use this to conditionally set many classes at once, including those that have special characters. +> [!NOTE] +> For historical reasons, falsy values (like `false` and `NaN`) are stringified (`class="false"`), though `class={undefined}` (or `null`) cause the attribute to be omitted altogether. In a future version of Svelte, all falsy values will cause `class` to be omitted. -```svelte - -
...
-
...
-``` +### Objects and arrays + +Since Svelte 5.15, `class` can be an object or array, and is converted to a string using [clsx](https://github.com/lukeed/clsx/). -Since `class` itself takes these values, you can use the same syntax on component properties when forwarding those to the `class` attribute. +If the value is an object, the truthy keys are added: ```svelte - - + +
...
``` +If the value is an array, the truthy values are combined: + +```svelte + +
...
+``` + +Note that whether we're using the array or object form, we can set multiple classes simultaneously with a single condition, which is particularly useful if you're using things like Tailwind. + +Arrays can contain arrays and objects, and clsx will flatten them. This is useful for combining local classes with props, for example: + ```svelte - - ``` -Under the hood this is using [`clsx`](https://github.com/lukeed/clsx), so if you need more details on the syntax, you can visit its documentation. - -## class: - -The `class:` directive is a convenient way to conditionally set classes on elements, as an alternative to using conditional expressions inside `class` attributes: +The user of this component has the same flexibility to use a mixture of objects, arrays and strings: ```svelte - -
...
-
...
+ + + + ``` -As with other directives, we can use a shorthand when the name of the class coincides with the value: +## The `class:` directive + +Prior to Svelte 5.15, the `class:` directive was the most convenient way to set classes on elements conditionally. ```svelte -
...
+ +
...
+
...
``` -Multiple `class:` directives can be added to a single element: +As with other directives, we can use a shorthand when the name of the class coincides with the value: ```svelte -
...
+
...
``` -> [!NOTE] Since Svelte 5.15, you have the same expressive power with extra features on the `class` attribute itself, so use that instead if possible +> [!NOTE] Unless you're using an older version of Svelte, consider avoiding `class:`, since the attribute is more powerful and composable. From 9a90b89441dcc4a2b3b4edb6adf984a7a4863209 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Dec 2024 16:47:55 -0500 Subject: [PATCH 15/18] changeset --- .changeset/thin-panthers-sing.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thin-panthers-sing.md diff --git a/.changeset/thin-panthers-sing.md b/.changeset/thin-panthers-sing.md new file mode 100644 index 000000000000..223de002c605 --- /dev/null +++ b/.changeset/thin-panthers-sing.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: allow `class` attribute to be an object or array, using `clsx` From 9dabf4449af9b123822a003f594dcf342435f966 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Dec 2024 16:55:28 -0500 Subject: [PATCH 16/18] fix --- packages/svelte/src/compiler/phases/2-analyze/css/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/utils.js b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js index 45ba06e55e3e..d7544b55c063 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/css/utils.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js @@ -41,7 +41,7 @@ function gather_possible_values(node, is_class, set, is_nested = false) { } } else { for (const value of left) { - if (!value) { + if (!value && value != undefined && (!is_class || !is_nested)) { set.add(value); } } From 39687b446c1dada7cfdf2abaf49f658271ece248 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 19 Dec 2024 19:18:56 -0500 Subject: [PATCH 17/18] Update documentation/docs/03-template-syntax/18-class.md Co-authored-by: Conduitry --- documentation/docs/03-template-syntax/18-class.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/03-template-syntax/18-class.md b/documentation/docs/03-template-syntax/18-class.md index 9582a582d935..eaf93b55d220 100644 --- a/documentation/docs/03-template-syntax/18-class.md +++ b/documentation/docs/03-template-syntax/18-class.md @@ -17,7 +17,7 @@ Primitive values are treated like any other attribute: ### Objects and arrays -Since Svelte 5.15, `class` can be an object or array, and is converted to a string using [clsx](https://github.com/lukeed/clsx/). +Since Svelte 5.15, `class` can be an object or array, and is converted to a string using [clsx](https://github.com/lukeed/clsx). If the value is an object, the truthy keys are added: From 7650bb421d227866fd71f37c1c69964da2f8bde6 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 24 Dec 2024 08:21:09 +0100 Subject: [PATCH 18/18] Apply suggestions from code review --- documentation/docs/03-template-syntax/18-class.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/docs/03-template-syntax/18-class.md b/documentation/docs/03-template-syntax/18-class.md index eaf93b55d220..880a34e9ec53 100644 --- a/documentation/docs/03-template-syntax/18-class.md +++ b/documentation/docs/03-template-syntax/18-class.md @@ -17,7 +17,7 @@ Primitive values are treated like any other attribute: ### Objects and arrays -Since Svelte 5.15, `class` can be an object or array, and is converted to a string using [clsx](https://github.com/lukeed/clsx). +Since Svelte 5.16, `class` can be an object or array, and is converted to a string using [clsx](https://github.com/lukeed/clsx). If the value is an object, the truthy keys are added: @@ -73,7 +73,7 @@ The user of this component has the same flexibility to use a mixture of objects, ## The `class:` directive -Prior to Svelte 5.15, the `class:` directive was the most convenient way to set classes on elements conditionally. +Prior to Svelte 5.16, the `class:` directive was the most convenient way to set classes on elements conditionally. ```svelte