diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index b6d28598e3..e59a200003 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -514,7 +514,73 @@ export function set_node_value(node, value) { // --- Helpers for style attribute string manipulation (custom renderer) --- -// TODO: check if this can be improved? +/** + * Split only at top-level semicolons, preserving CSS strings, functions and comments. + * @param {string} style_string + * @returns {string[]} trimmed, non-empty declarations (without trailing `;`) + */ +function split_style_declarations(style_string) { + /** @type {string[]} */ + var declarations = []; + + /** @type {false | '"' | "'"} */ + var in_str = false; + var depth = 0; + var in_comment = false; + var start = 0; + + for (var i = 0; i < style_string.length; i++) { + var c = style_string[i]; + + if (in_comment) { + if (c === '/' && style_string[i - 1] === '*') { + in_comment = false; + } + continue; + } + + if (c === '\\') { + i++; + continue; + } + + if (in_str) { + if (c === in_str) { + in_str = false; + } + continue; + } + + if (c === '/' && style_string[i + 1] === '*') { + in_comment = true; + } else if (c === '"' || c === "'") { + in_str = c; + } else if (c === '(') { + depth++; + } else if (c === ')') { + if (depth > 0) depth--; + } else if (c === ';' && depth === 0) { + var declaration = style_string.slice(start, i).trim(); + if (declaration) declarations.push(declaration); + start = i + 1; + } + } + + var last = style_string.slice(start).trim(); + if (last) declarations.push(last); + + return declarations; +} + +/** + * @param {string} declaration + * @returns {string | null} + */ +function declaration_property(declaration) { + var colon_index = declaration.indexOf(':'); + if (colon_index === -1) return null; + return declaration.slice(0, colon_index).trim(); +} /** * @param {string} style_string @@ -525,26 +591,22 @@ export function set_node_value(node, value) { */ function set_style_property_in_string(style_string, property, value, priority) { var declaration = property + ': ' + value + (priority ? ' !' + priority : ''); - var parts = style_string.split(';'); + var parts = split_style_declarations(style_string); var found = false; for (var i = 0; i < parts.length; i++) { - var colon_index = parts[i].indexOf(':'); - if (colon_index !== -1 && parts[i].substring(0, colon_index).trim() === property) { - parts[i] = ' ' + declaration; + if (declaration_property(parts[i]) === property) { + parts[i] = declaration; found = true; break; } } if (!found) { - parts.push(' ' + declaration); + parts.push(declaration); } - return parts - .map((p) => p.trim()) - .filter(Boolean) - .join('; '); + return parts.join('; '); } /** @@ -553,15 +615,8 @@ function set_style_property_in_string(style_string, property, value, priority) { * @returns {string} */ function remove_style_property_in_string(style_string, property) { - return style_string - .split(';') - .filter((part) => { - var colon_index = part.indexOf(':'); - if (colon_index === -1) return false; - return part.substring(0, colon_index).trim() !== property; - }) - .map((p) => p.trim()) - .filter(Boolean) + return split_style_declarations(style_string) + .filter((part) => declaration_property(part) !== property) .join('; '); } diff --git a/packages/svelte/tests/custom-renderers/style-string.test.ts b/packages/svelte/tests/custom-renderers/style-string.test.ts new file mode 100644 index 0000000000..830dc53259 --- /dev/null +++ b/packages/svelte/tests/custom-renderers/style-string.test.ts @@ -0,0 +1,135 @@ +import { afterEach, assert, beforeEach, describe, it } from 'vitest'; +import { + style_set_property, + style_remove_property +} from '../../src/internal/client/dom/operations.js'; +import { set_renderer } from '../../src/internal/client/custom-renderer/state.js'; + +function make_element(style = '') { + return { attributes: { style } as Record }; +} + +const fake_renderer = { + getAttribute(element: any, name: string) { + return element.attributes[name] ?? null; + }, + setAttribute(element: any, name: string, value: string) { + element.attributes[name] = String(value); + } +} as any; + +function style_of(element: any) { + return element.attributes.style; +} + +describe('custom renderer style string manipulation', () => { + beforeEach(() => { + set_renderer(fake_renderer); + }); + + afterEach(() => { + set_renderer(null); + }); + + it('sets a property on an empty style', () => { + const el = make_element(); + style_set_property(el as any, 'color', 'red'); + assert.equal(style_of(el), 'color: red'); + }); + + it('updates an existing property, preserving unrelated ones', () => { + const el = make_element('color: red; background: blue'); + style_set_property(el as any, 'color', 'green'); + assert.equal(style_of(el), 'color: green; background: blue'); + }); + + it('appends a new property, preserving unrelated ones', () => { + const el = make_element('color: red'); + style_set_property(el as any, 'background', 'blue'); + assert.equal(style_of(el), 'color: red; background: blue'); + }); + + it('supports the important priority', () => { + const el = make_element('color: red'); + style_set_property(el as any, 'color', 'green', 'important'); + assert.equal(style_of(el), 'color: green !important'); + }); + + it('removes a property, preserving unrelated ones', () => { + const el = make_element('color: red; background: blue'); + style_remove_property(el as any, 'color'); + assert.equal(style_of(el), 'background: blue'); + }); + + it('removing a non-existent property is a no-op', () => { + const el = make_element('color: red'); + style_remove_property(el as any, 'background'); + assert.equal(style_of(el), 'color: red'); + }); + + it('removing the only property yields an empty style', () => { + const el = make_element('color: red'); + style_remove_property(el as any, 'color'); + assert.equal(style_of(el), ''); + }); + + describe('preserves quoted semicolons', () => { + it('when updating an unrelated property', () => { + const el = make_element('content: "a;b"; color: red'); + style_set_property(el as any, 'color', 'green'); + assert.equal(style_of(el), 'content: "a;b"; color: green'); + }); + + it('when removing an unrelated property', () => { + const el = make_element('content: "a;b"; color: red'); + style_remove_property(el as any, 'color'); + assert.equal(style_of(el), 'content: "a;b"'); + }); + + it('with single-quoted values', () => { + const el = make_element("content: 'x;y;z'; color: red"); + style_set_property(el as any, 'color', 'green'); + assert.equal(style_of(el), "content: 'x;y;z'; color: green"); + }); + }); + + describe('preserves data URLs', () => { + it('with semicolons inside url(...)', () => { + const el = make_element('background: url(data:image/png;base64,AAAA); color: red'); + style_set_property(el as any, 'color', 'green'); + assert.equal(style_of(el), 'background: url(data:image/png;base64,AAAA); color: green'); + }); + + it('when removing the data URL property', () => { + const el = make_element('background: url(data:image/png;base64,AAAA); color: red'); + style_remove_property(el as any, 'background'); + assert.equal(style_of(el), 'color: red'); + }); + }); + + describe('preserves escaped quotes', () => { + it('escaped quote inside a quoted value does not close the string', () => { + const el = make_element('content: "a\\";b"; color: red'); + style_set_property(el as any, 'color', 'green'); + assert.equal(style_of(el), 'content: "a\\";b"; color: green'); + }); + + it('escaped backslash before a quote', () => { + const el = make_element('content: "a\\\\"; color: red'); + style_remove_property(el as any, 'color'); + assert.equal(style_of(el), 'content: "a\\\\"'); + }); + }); + + it('ignores semicolons inside comments', () => { + const el = make_element('color: red /* a;b */; background: blue'); + style_set_property(el as any, 'background', 'green'); + assert.equal(style_of(el), 'color: red /* a;b */; background: green'); + }); + + it('preserves escaped semicolons outside strings', () => { + const el = make_element('--text: a\\;b; color: red'); + style_remove_property(el as any, 'color'); + assert.equal(style_of(el), '--text: a\\;b'); + }); +});