diff --git a/src/tests/transformers/shorthandCss.test.ts b/src/tests/transformers/shorthandCss.test.ts index 9c8df424..884fa02d 100644 --- a/src/tests/transformers/shorthandCss.test.ts +++ b/src/tests/transformers/shorthandCss.test.ts @@ -1,6 +1,22 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { shorthandCss, type ShorthandCssOptions } from '../../transformers/shorthandCss.ts' +/** + * Count postcss runs: the transformer builds a fresh postcss + * processor per style value it merges, which calls this + * plugin creator once. + */ +const mergeLonghandCalls = vi.hoisted(() => ({ count: 0 })) + +vi.mock('postcss-merge-longhand', async (importOriginal) => { + const { default: plugin } = await importOriginal() + const counted = Object.assign((...args: Parameters) => { + mergeLonghandCalls.count++ + return plugin(...args) + }, { postcss: true as const }) + return { default: counted } +}) + function run(html: string, options?: ShorthandCssOptions | boolean): string { if (options === false) return html const opts = (options === true || options == null) ? {} : options @@ -18,6 +34,33 @@ describe('shorthandCss', () => { expect(result).not.toContain('margin-left:') expect(result).not.toContain('margin-right:') }) + + it('merges repeated identical style values on every element', () => { + const style = 'padding-top: 4px; padding-right: 8px; padding-bottom: 4px; padding-left: 8px' + const html = `

A

B` + const result = shorthandCss(html) + expect(result.match(/padding: 4px 8px/g)).toHaveLength(3) + expect(result).not.toContain('padding-top:') + }) + + it('runs postcss once per distinct style value', () => { + const a = 'padding-top: 4px; padding-right: 8px; padding-bottom: 4px; padding-left: 8px' + const b = 'margin-top: 2px; margin-right: 2px; margin-bottom: 2px; margin-left: 2px' + const html = `

A

B
C
` + + mergeLonghandCalls.count = 0 + shorthandCss(html) + expect(mergeLonghandCalls.count).toBe(2) + }) + + it('does not reuse cached style values across calls', () => { + const html = '

A

' + + mergeLonghandCalls.count = 0 + shorthandCss(html) + shorthandCss(html) + expect(mergeLonghandCalls.count).toBe(2) + }) }) describe('margin shorthand', () => { diff --git a/src/transformers/shorthandCss.ts b/src/transformers/shorthandCss.ts index e8e39612..8f44dae2 100644 --- a/src/transformers/shorthandCss.ts +++ b/src/transformers/shorthandCss.ts @@ -99,25 +99,34 @@ export function shorthandCssDom(dom: ChildNode[], options: ShorthandCssOptions = const allowedTags = options.tags ?? [] const hasTagFilter = allowedTags.length > 0 + /** + * Utility-generated inline styles repeat heavily across elements, so + * each distinct value only goes through postcss once per call. + */ + const cache = new Map() + /** * Merge longhand within a single inline-style value. Returns the merged * string when shorter, otherwise the original. Wraps the value in a * dummy selector since postcss-merge-longhand operates on rules. */ const mergeStyleValue = (styleValue: string): string => { + let result = cache.get(styleValue) + if (result !== undefined) return result + + result = styleValue try { const { css } = postcss() .use(mergeLonghand) .use(mergeBorder) .process(`div { ${styleValue} }`, { parser: safeParser }) const match = css.match(/div\s*\{\s*([^}]+)\s*\}/) - if (match && match[1]) { - const merged = match[1].trim() - if (merged !== styleValue) return merged - } + if (match && match[1]) result = match[1].trim() } catch {} - return styleValue + + cache.set(styleValue, result) + return result } walk(dom, (node) => {