- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
Features
42761fafeat: implement suggestions for no-empty-function (#20057) (jaymarvelz)102f444feat: implement suggestions for no-empty-static-block (#20056) (jaymarvelz)e51fffffeat: addpreserve-caught-errorrule (#19913) (Amnish Singh Arora)Bug Fixes
10e7ae2fix: update uncloneable options error message (#20059) (soda-sorcery)bfa4601fix: ignore empty switch statements with comments in no-empty rule (#20045) (jaymarvelz)dfd11defix: addbeforeandafterto test case types (#20049) (Francesco Trotta)dabbe95fix: correct types forno-restricted-importsrule (#20034) (Milos Djermanovic)ea789c7fix: no-loss-of-precision false positive with uppercase exponent (#20032) (sethamus)Documentation
d265515docs: improve phrasing - "if" → "even if" from getting-started section (#20074) (jjangga0214)a355a0edocs: invert comparison logic for example inno-vardoc page (#20064) (OTonGitHub)5082fc2docs: Update README (GitHub Actions Bot)99cfd7edocs: add missing "the" in rule deprecation docs (#20050) (Josh Goldberg ✨)6ad8973docs: update--no-ignoreand--ignore-patterndocumentation (#20036) (Francesco Trotta)8033b19docs: add documentation for--no-config-lookup(#20033) (Francesco Trotta)Chores
da87f2fchore: upgrade @eslint/js@9.35.0 (#20077) (Milos Djermanovic)af2a087chore: package.json update for @eslint/js release (Jenkins)7055764test: removetests/lib/eslint/eslint.config.js(#20065) (Milos Djermanovic)84ffb96chore: update@eslint-community/eslint-utils(#20069) (Francesco Trotta)d5ef939refactor: remove deprecatedcontext.parserOptionsusage across rules (#20060) (sethamus)1b3881dchore: remove redundant word (#20058) (pxwanglu)
Features
0bb777afeat: multithread linting (#19794) (Francesco Trotta)43a5f9efeat: add eslint-plugin-regexp to eslint-config-eslint base config (#19951) (Pixel998)Bug Fixes
9b89903fix: default value of accessor-pairs option in rule.d.ts file (#20024) (Tanuj Kanti)6c07420fix: fix spurious failure in neostandard integration test (#20023) (Kirk Waiblinger)676f4acfix: allow scientific notation with trailing zeros matching exponent (#20002) (Sweta Tanwar)Documentation
0b4a590docs: make rulesdir deprecation clearer (#20018) (Domenico Gemoli)327c672docs: Update README (GitHub Actions Bot)bf26229docs: Fix typo in core-concepts/index.md (#20009) (Tobias Hernstig)2309327docs: fix typo in the "Configuring Rules" section (#20001) (ghazi-git)2b87e21docs: [no-else-return] clarify sample code. (#19991) (Yuki Takada (Yukinosuke Takada))c36570cdocs: Update README (GitHub Actions Bot)Chores
f19ad94chore: upgrade to@eslint/js@9.34.0(#20030) (Francesco Trotta)b48fa20chore: package.json update for @eslint/js release (Jenkins)4bce8a2chore: package.json update for eslint-config-eslint release (Jenkins)0c9999crefactor: prefer default options ingrouped-accessor-pairs(#20028) (루밀LuMir)d503f19ci: fixstale.yml(#20010) (루밀LuMir)e2dc67dci: centralizestale.yml(#19994) (루밀LuMir)7093cb8ci: bump actions/checkout from 4 to 5 (#20005) (dependabot[bot])
Does any of this look wrong? Please let us know.
See the full diff on Github. The new version differs by 42 commits:
9.35.0Build:
changelog update for 9.35.0chore:
upgrade @eslint/js@9.35.0 (#20077)chore:
package.json update for @eslint/js releasedocs:
improve phrasing - "if" → "even if" from
getting-started section (#20074)test:
remove `tests/lib/eslint/eslint.config.js` (#20065)fix:
update uncloneable options error message (#20059)feat:
implement suggestions for no-empty-function (#20057)feat:
implement suggestions for no-empty-static-block (#20056)chore:
update `@eslint-community/eslint-utils` (#20069)docs:
invert comparison logic for example in `no-var` doc page
(#20064)feat:
add `preserve-caught-error` rule (#19913)docs:
Update READMErefactor:
remove deprecated `context.parserOptions` usage across rules
(#20060)chore:
remove redundant word (#20058)docs:
add missing "the" in rule deprecation docs
(#20050)fix:
ignore empty switch statements with comments in no-empty rule
(#20045)fix:
add `before` and `after` to test case types (#20049)docs:
update `--no-ignore` and `--ignore-pattern` documentation
(#20036)fix:
correct types for `no-restricted-imports` rule (#20034)docs:
add documentation for `--no-config-lookup` (#20033)fix:
no-loss-of-precision false positive with uppercase exponent
(#20032)9.34.0Build:
changelog update for 9.34.0chore:
upgrade to `@eslint/js@9.34.0` (#20030)chore:
package.json update for @eslint/js releasechore:
package.json update for eslint-config-eslint releaserefactor:
prefer default options in `grouped-accessor-pairs`
(#20028)docs:
make rulesdir deprecation clearer (#20018)fix:
default value of accessor-pairs option in rule.d.ts file
(#20024)fix:
fix spurious failure in neostandard integration test
(#20023)fix:
allow scientific notation with trailing zeros matching exponent
(#20002)docs:
Update READMEci:
fix `stale.yml` (#20010)feat:
multithread linting (#19794)docs:
Fix typo in core-concepts/index.md (#20009)feat:
add eslint-plugin-regexp to eslint-config-eslint base config
(#19951)ci:
centralize `stale.yml` (#19994)ci:
bump actions/checkout from 4 to 5 (#20005)docs:
fix typo in the "Configuring Rules" section
(#20001)docs:
[no-else-return] clarify sample code. (#19991)docs:
Update README
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
What's Changed
- docs: cleaned up README.md badges by @JoshuaKGoldberg in #100
- feat: add alignValues option by @PaperStrike in #102
- 1.7.0 by @JoshuaKGoldberg in #103
New Contributors
- @PaperStrike made their first contribution in #102
Full Changelog: v1.6.0...v1.7.0
Does any of this look wrong? Please let us know.
See the full diff on Github. The new version differs by 3 commits:
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
What's Changed
- feat: allow configuration of
organizeImportsTypeOrder(#152) - thanks @goege64 for your first contribution 🎉Full Changelog: v4.2.0...v4.3.0
Does any of this look wrong? Please let us know.
See the full diff on Github. The new version differs by 4 commits:
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
Fixed
- Fixed broken color styles (#405)
Fixed
- Fixed undefined variable error (#403)
Added
Fixed
Changed
- Remove lodash dependencies (#402)
Does any of this look wrong? Please let us know.
See the full diff on Github. The new version differs by 22 commits:
0.5.19Fix
bad RGB syntax (#405)0.5.18Fix
variable declaration in opacity function (#403)0.5.17chore(ci):
update actions for release insidersAdd
modifiers for description list elements (#357)Add
`prose-picture` modifiers (#367)Fix
`kbd` shadow colors not being calculated on oklch colors
(#387)Add
Tailwind v4 custom color theme example to README (#396)Remove
lodash dependencies (#402)Clarify
'not-prose' usage when using prefixes (#399)Fix
syntax highlighting in readmeInclude
v3 installation instructions alongside v4 (#388)Readme:
Remove unused `{theme}` (#385)Fix
typo in comments (#378)Change
the borderTopWidth value for hr from integer to a pixel value
(#379)Rectify
variant order Closes #376Fix
plugin import path in README.md (#382)Fix
syntax errors in Readme.md (#381)Update
README for Tailwind CSS v4 (#380)Update
README.md
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
🌟 What is new?
This release fixes minor issues, migrates to Rspack for dist, and lazily imports the Babel transformer only when needed, which should noticeably improve startup times.
- Install size reduced from
2.03MBto1.67MB- Loading times improved
150ms=>22ms(full transform:180ms=>115ms)🔥 Performance
- Lazy load transformer (#405)
🩹 Fixes
- cjs-interop: Handle function default exports (#396)
- Always use native require/import for
node:specifiers (#392)📦 Build
✅ Tests
- Update deno and bun native test coverage (df844f8)
❤️ Contributors
- Pooya Parsa (@pi0)
- Volodymyr Kolesnykov (@sjinks)
- Jungwoo LEE (@jungwoo3490)
Does any of this look wrong? Please let us know.
See the full diff on Github. The new version differs by 11 commits:
chore(release):
v2.6.0perf:
lazy load babel transform (#405)fix:
always use native for `node:` specifiers (#392)fix(cjs-interop):
handle function default exports (#396)build:
migrate to rspack (#404)test:
new bench scripttest:
update deno and bun native test ignoreschore:
update depschore(deps):
update all non-major dependencies (#393)chore(deps):
update actions/checkout action to v5 (#394)chore(deps):
update actions/setup-node action to v5 (#399)
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
Highlights
#37479 - [Bug]: Upgrade Chromium to 140.0.7339.186.
#37147 - [Regression]: Internal error: step id not found.
#37146 - [Regression]: HTML reporter displays a broken chip link when there are no projects.
#37137 - Revert "fix(a11y): track inert elements as hidden".Browser Versions
- Chromium 140.0.7339.186
- Mozilla Firefox 141.0
- WebKit 26.0
This version was also tested against the following stable channels:
- Google Chrome 139
- Microsoft Edge 139
Does any of this look wrong? Please let us know.
See the full diff on Github. The new version differs by 7 commits:
chore:
mark v1.55.1 (#37530)feat(chromium):
roll to r1193 (#37529)chore:
do not use -k option (#37532)cherry-pick(#37214):
docs: fix method names in release notescherry-pick(#37153):
fix(html): don't display a chip with empty content with no
projectscherry-pick(#37149):
fix(test): attaching in boxed fixturecherry-pick(#37137):
Revert "fix(a11y): track inert elements as hidden
(#36947)"
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
See the full diff on Github. The new version differs by 21 commits:
7.5.1fix:
consistent TOCTOU behavior in sync t.list7.5.0changelog
7.5feat:
add initial zstd supportci:
don't bother testing on node 18update
workflows, tshy configs7.4.4Fix
some typosdocs(changelog):
add missing v7 breaking changedocs:
clean up duplicate onwarn optionsupdate
all depsminizlib@3.1.0doc:
fix lowlevel tar.Parser typoremove
yallist from parse, but NOT from packUse
built-in mkdir instead of mkdirpformat
readmelimit
normalize-unicode cache sizeremove
dirCache from mkdir, unpackremove
extraneous 'for example'docs
on how to use onWriteEntry
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
Features
Bug Fixes
75b74d8fix: add missing rule option types (#20127) (ntnyq)1c0d850fix: updateeslint-all.jsto useObject.freezeforrulesobject (#20116) (루밀LuMir)7d61b7ffix: add missing scope types toScope.type(#20110) (Pixel998)7a670c3fix: correct rule option typings inrules.d.ts(#20084) (Pixel998)Documentation
b73ab12docs: update examples to usedefineConfig(#20131) (sethamus)31d9392docs: fix typos (#20118) (Pixel998)c7f861bdocs: Update README (GitHub Actions Bot)6b0c08bdocs: Update README (GitHub Actions Bot)91f97c5docs: Update README (GitHub Actions Bot)Chores
12411e8chore: upgrade @eslint/js@9.36.0 (#20139) (Milos Djermanovic)488cba6chore: package.json update for @eslint/js release (Jenkins)bac82a2ci: simplify renovate configuration (#19907) (唯然)c00bb37ci: bump actions/labeler from 5 to 6 (#20090) (dependabot[bot])fee751drefactor: usedefaultOptionsin rules (#20121) (Pixel998)1ace67dchore: update example to usedefineConfig(#20111) (루밀LuMir)4821963test: add missing loc information to error objects in rule tests (#20112) (루밀LuMir)b42c42echore: disallow use of deprecatedtypeproperty in core rule tests (#20094) (Milos Djermanovic)7bb498dtest: remove deprecatedtypeproperty from core rule tests (#20093) (Pixel998)e10cf2aci: bump actions/setup-node from 4 to 5 (#20089) (dependabot[bot])5cb0ce4refactor: usemeta.defaultOptionsinpreserve-caught-error(#20080) (Pixel998)f9f7cb5chore: package.json update for eslint-config-eslint release (Jenkins)81764b2chore: updateeslintpeer dependency ineslint-config-eslint(#20079) (Milos Djermanovic)
Does any of this look wrong? Please let us know.
See the full diff on Github. The new version differs by 25 commits:
9.36.0Build:
changelog update for 9.36.0chore:
upgrade @eslint/js@9.36.0 (#20139)chore:
package.json update for @eslint/js releasedocs:
update examples to use `defineConfig` (#20131)feat:
correct `preserve-caught-error` edge cases (#20109)fix:
add missing rule option types (#20127)ci:
simplify renovate configuration (#19907)fix:
update `eslint-all.js` to use `Object.freeze` for `rules` object
(#20116)ci:
bump actions/labeler from 5 to 6 (#20090)refactor:
use `defaultOptions` in rules (#20121)docs:
fix typos (#20118)fix:
add missing scope types to `Scope.type` (#20110)chore:
update example to use `defineConfig` (#20111)test:
add missing loc information to error objects in rule tests
(#20112)docs:
Update READMEdocs:
Update READMEfix:
correct rule option typings in `rules.d.ts` (#20084)chore:
disallow use of deprecated `type` property in core rule tests
(#20094)test:
remove deprecated `type` property from core rule tests
(#20093)docs:
Update READMEci:
bump actions/setup-node from 4 to 5 (#20089)refactor:
use `meta.defaultOptions` in `preserve-caught-error`
(#20080)chore:
package.json update for eslint-config-eslint releasechore:
update `eslint` peer dependency in `eslint-config-eslint`
(#20079)
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
Breaking
- Require Node.js 20 4ae42c8
Fixes
- Fix
expandDirectoriesnot working with globstar patterns 98d691a- Fix relative paths with gitignore option b4d78d8
- Fix gitignore patterns in subdirectories not applying recursively 2cb6088
- Fix TypeScript types for
globbyStreamto correctly yield strings 1c7f3ed
Does any of this look wrong? Please let us know.
See the full diff on Github. The new version differs by 7 commits:
15.0.0Require
Node.js 20Fix
`expandDirectories` not working with globstar patternsFix
relative paths with gitignore optionFix
gitignore patterns in subdirectories not applying
recursivelyDocument
performance implications of `gitignore` optionFix
TypeScript types for `globbyStream` to correctly yield
strings
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
['"])?\s*(?.*?)\4\s*(? [is])?\s*)?\]/ + +describe('parsing', () => { + bench('AttributeSelectorParser.parse', () => { + for (let example of examples) { + AttributeSelectorParser.parse(example) + } + }) + + bench('REGEX.test(…)', () => { + for (let example of examples) { + ATTRIBUTE_REGEX.exec(example) + } + }) + + bench('….match(REGEX)', () => { + for (let example of examples) { + example.match(ATTRIBUTE_REGEX) + } + }) +}) diff --git a/packages/tailwindcss/src/attribute-selector-parser.test.ts b/packages/tailwindcss/src/attribute-selector-parser.test.ts new file mode 100644 index 000000000..140336ab0 --- /dev/null +++ b/packages/tailwindcss/src/attribute-selector-parser.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { parse } from './attribute-selector-parser' + +describe('parse', () => { + it.each([ + [''], + [']'], + ['[]'], + ['['], + ['="value"'], + ['data-foo]'], + ['[data-foo'], + ['[data-foo="foo]'], + ['[data-foo * = foo]'], + ['[data-foo*=]'], + ['[data-foo=value x]'], + ['[data-foo=value ix]'], + ])('should parse an invalid attribute selector (%s) as `null`', (input) => { + expect(parse(input)).toBeNull() + }) + + it.each([ + [ + '[data-foo]', + { attribute: 'data-foo', operator: null, quote: null, value: null, sensitivity: null }, + ], + [ + '[ data-foo ]', + { attribute: 'data-foo', operator: null, quote: null, value: null, sensitivity: null }, + ], + [ + '[data-state=expanded]', + { attribute: 'data-state', operator: '=', quote: null, value: 'expanded', sensitivity: null }, + ], + [ + '[data-state = expanded ]', + { attribute: 'data-state', operator: '=', quote: null, value: 'expanded', sensitivity: null }, + ], + [ + '[data-state*="expanded"]', + { attribute: 'data-state', operator: '*=', quote: '"', value: 'expanded', sensitivity: null }, + ], + [ + '[data-state*="expanded"i]', + { attribute: 'data-state', operator: '*=', quote: '"', value: 'expanded', sensitivity: 'i' }, + ], + [ + '[data-state*=expanded i]', + { attribute: 'data-state', operator: '*=', quote: null, value: 'expanded', sensitivity: 'i' }, + ], + ])('should parse correctly: %s', (selector, expected) => { + expect(parse(selector)).toEqual(expected) + }) + + it('should work with a real-world example', () => { + expect(parse('[data-url$=".com"i]')).toEqual({ + attribute: 'data-url', + operator: '$=', + quote: '"', + value: '.com', + sensitivity: 'i', + }) + }) +}) diff --git a/packages/tailwindcss/src/attribute-selector-parser.ts b/packages/tailwindcss/src/attribute-selector-parser.ts new file mode 100644 index 000000000..e732b2979 --- /dev/null +++ b/packages/tailwindcss/src/attribute-selector-parser.ts @@ -0,0 +1,229 @@ +const TAB = 9 +const LINE_BREAK = 10 +const CARRIAGE_RETURN = 13 +const SPACE = 32 +const DOUBLE_QUOTE = 34 +const DOLLAR = 36 +const SINGLE_QUOTE = 39 +const ASTERISK = 42 +const EQUALS = 61 +const UPPER_I = 73 +const UPPER_S = 83 +const BACKSLASH = 92 +const CARET = 94 +const LOWER_I = 105 +const LOWER_S = 115 +const PIPE = 124 +const TILDE = 126 +const LOWER_A = 97 +const LOWER_Z = 122 +const UPPER_A = 65 +const UPPER_Z = 90 +const ZERO = 48 +const NINE = 57 +const DASH = 45 +const UNDERSCORE = 95 + +interface AttributeSelector { + attribute: string + operator: '=' | '~=' | '|=' | '^=' | '$=' | '*=' | null + quote: '"' | "'" | null + value: string | null + sensitivity: 'i' | 's' | null +} + +export function parse(input: string): AttributeSelector | null { + // Must start with `[` and end with `]` + if (input[0] !== '[' || input[input.length - 1] !== ']') { + return null + } + + let i = 1 + let start = i + let end = input.length - 1 + + // Skip whitespace, e.g.: [ data-foo] + // ^^^ + while (isAsciiWhitespace(input.charCodeAt(i))) i++ + + // Attribute name, e.g.: [data-foo] + // ^^^^^^^^ + { + start = i + for (; i < end; i++) { + let currentChar = input.charCodeAt(i) + // Skip escaped character + if (currentChar === BACKSLASH) { + i++ + continue + } + if (currentChar >= UPPER_A && currentChar <= UPPER_Z) continue + if (currentChar >= LOWER_A && currentChar <= LOWER_Z) continue + if (currentChar >= ZERO && currentChar <= NINE) continue + if (currentChar === DASH || currentChar === UNDERSCORE) continue + break + } + + // Must have at least one character in the attribute name + if (start === i) { + return null + } + } + let attribute = input.slice(start, i) + + // Skip whitespace, e.g.: [data-foo =value] + // ^^^ + while (isAsciiWhitespace(input.charCodeAt(i))) i++ + + // At the end, e.g.: `[data-foo]` + if (i === end) { + return { + attribute, + operator: null, + quote: null, + value: null, + sensitivity: null, + } + } + + // Operator, e.g.: [data-foo*=value] + // ^^ + let operator = null + let currentChar = input.charCodeAt(i) + if (currentChar === EQUALS) { + operator = '=' + i++ + } else if ( + (currentChar === TILDE || + currentChar === PIPE || + currentChar === CARET || + currentChar === DOLLAR || + currentChar === ASTERISK) && + input.charCodeAt(i + 1) === EQUALS + ) { + operator = input[i] + '=' + i += 2 + } else { + return null // Invalid operator + } + + // Skip whitespace, e.g.: [data-foo*= value] + // ^^^ + while (isAsciiWhitespace(input.charCodeAt(i))) i++ + + // At the end, that means that we have an operator but no valid, which is + // invalid, e.g.: `[data-foo*=]` + if (i === end) { + return null + } + + // Value, e.g.: [data-foo*=value] + // ^^^^^ + let value = '' + + // Quoted value, e.g.: [data-foo*="value"] + // ^^^^^^^ + let quote = null + currentChar = input.charCodeAt(i) + if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) { + quote = input[i] as '"' | "'" + i++ + + start = i + for (let j = i; j < end; j++) { + let current = input.charCodeAt(j) + // Found ending quote + if (current === currentChar) { + i = j + 1 + } + + // Skip escaped character + else if (current === BACKSLASH) { + j++ + } + } + + value = input.slice(start, i - 1) + } + + // Unquoted value, e.g.: [data-foo*=value] + // ^^^^^ + else { + start = i + // Keep going until we find whitespace or the end + while (i < end && !isAsciiWhitespace(input.charCodeAt(i))) i++ + value = input.slice(start, i) + } + + // Skip whitespace, e.g.: [data-foo*=value ] + // ^^^ + while (isAsciiWhitespace(input.charCodeAt(i))) i++ + + // At the end, e.g.: `[data-foo=value]` + if (i === end) { + return { + attribute, + operator: operator as '=' | '~=' | '|=' | '^=' | '$=' | '*=', + quote: quote as '"' | "'" | null, + value, + sensitivity: null, + } + } + + // Sensitivity, e.g.: [data-foo=value i] + // ^ + let sensitivity = null + { + switch (input.charCodeAt(i)) { + case LOWER_I: + case UPPER_I: { + sensitivity = 'i' + i++ + break + } + + case LOWER_S: + case UPPER_S: { + sensitivity = 's' + i++ + break + } + + default: + return null // Invalid sensitivity + } + } + + // Skip whitespace, e.g.: [data-foo=value i ] + // ^^^ + while (isAsciiWhitespace(input.charCodeAt(i))) i++ + + // We must be at the end now, if not, then there is an additional character + // after the sensitivity which is invalid, e.g.: [data-foo=value iX] + // ^ + if (i !== end) { + return null + } + + // Fully done + return { + attribute, + operator: operator as '=' | '~=' | '|=' | '^=' | '$=' | '*=', + quote: quote as '"' | "'" | null, + value, + sensitivity: sensitivity as 'i' | 's' | null, + } +} + +function isAsciiWhitespace(code: number): boolean { + switch (code) { + case SPACE: + case TAB: + case LINE_BREAK: + case CARRIAGE_RETURN: + return true + + default: + return false + } +} diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts index 4355071f2..dfc65ac73 100644 --- a/packages/tailwindcss/src/candidate.test.ts +++ b/packages/tailwindcss/src/candidate.test.ts @@ -1,4 +1,5 @@ -import { expect, it } from 'vitest' +import { describe, expect, it, test } from 'vitest' +import { __unstable__loadDesignSystem } from '.' import { buildDesignSystem } from './design-system' import { Theme } from './theme' import { Utilities } from './utilities' @@ -1999,3 +2000,126 @@ it.each([ expect(run(rawCandidate, { utilities, variants })).toEqual([]) }) + +const candidates = [ + // Arbitrary candidates + ['[color:red]', '[color:red]'], + ['[color:red]/50', '[color:red]/50'], + ['[color:red]/[0.5]', '[color:red]/[0.5]'], + ['[color:red]/50!', '[color:red]/50!'], + ['![color:red]/50', '[color:red]/50!'], + ['[color:red]/[0.5]!', '[color:red]/[0.5]!'], + + // Static candidates + ['box-border', 'box-border'], + ['underline!', 'underline!'], + ['!underline', 'underline!'], + ['-inset-full', '-inset-full'], + + // Functional candidates + ['bg-red-500', 'bg-red-500'], + ['bg-red-500/50', 'bg-red-500/50'], + ['bg-red-500/[0.5]', 'bg-red-500/[0.5]'], + ['bg-red-500!', 'bg-red-500!'], + ['!bg-red-500', 'bg-red-500!'], + ['bg-[#0088cc]/50', 'bg-[#0088cc]/50'], + ['bg-[#0088cc]/[0.5]', 'bg-[#0088cc]/[0.5]'], + ['bg-[#0088cc]!', 'bg-[#0088cc]!'], + ['!bg-[#0088cc]', 'bg-[#0088cc]!'], + ['bg-[var(--spacing)-1px]', 'bg-[var(--spacing)-1px]'], + ['bg-[var(--spacing)_-_1px]', 'bg-[var(--spacing)-1px]'], + ['bg-[var(--_spacing)]', 'bg-(--_spacing)'], + ['bg-(--_spacing)', 'bg-(--_spacing)'], + ['bg-[var(--\_spacing)]', 'bg-(--_spacing)'], + ['bg-(--\_spacing)', 'bg-(--_spacing)'], + ['bg-[-1px_-1px]', 'bg-[-1px_-1px]'], + ['p-[round(to-zero,1px)]', 'p-[round(to-zero,1px)]'], + ['w-1/2', 'w-1/2'], + ['p-[calc((100vw-theme(maxWidth.2xl))_/_2)]', 'p-[calc((100vw-theme(maxWidth.2xl))/2)]'], + + // Keep spaces in strings + ['content-["hello_world"]', 'content-["hello_world"]'], + ['content-[____"hello_world"___]', 'content-["hello_world"]'], + + // Do not escape underscores for url() and CSS variable in var() + ['bg-[no-repeat_url(/image_13.png)]', 'bg-[no-repeat_url(/image_13.png)]'], + [ + 'bg-[var(--spacing-0_5,_var(--spacing-1_5,_3rem))]', + 'bg-(--spacing-0_5,var(--spacing-1_5,3rem))', + ], + + // Normalize whitespace in arbitrary properties + ['[display:flex]', '[display:flex]'], + ['[display:_flex]', '[display:flex]'], + ['[display:flex_]', '[display:flex]'], + ['[display:_flex_]', '[display:flex]'], + + // Normalize whitespace in `calc` expressions + ['w-[calc(100%-2rem)]', 'w-[calc(100%-2rem)]'], + ['w-[calc(100%_-_2rem)]', 'w-[calc(100%-2rem)]'], + + // Normalize the important modifier + ['!flex', 'flex!'], + ['flex!', 'flex!'], +] + +const variants = [ + ['', ''], // no variant + ['*:', '*:'], + ['focus:', 'focus:'], + ['group-focus:', 'group-focus:'], + + ['hover:focus:', 'hover:focus:'], + ['hover:group-focus:', 'hover:group-focus:'], + ['group-hover:focus:', 'group-hover:focus:'], + ['group-hover:group-focus:', 'group-hover:group-focus:'], + + ['min-[10px]:', 'min-[10px]:'], + + // Normalize spaces + ['min-[calc(1000px_+_12em)]:', 'min-[calc(1000px+12em)]:'], + ['min-[calc(1000px_+12em)]:', 'min-[calc(1000px+12em)]:'], + ['min-[calc(1000px+_12em)]:', 'min-[calc(1000px+12em)]:'], + ['min-[calc(1000px___+___12em)]:', 'min-[calc(1000px+12em)]:'], + + ['peer-[&_p]:', 'peer-[&_p]:'], + ['peer-[&_p]:hover:', 'peer-[&_p]:hover:'], + ['hover:peer-[&_p]:', 'hover:peer-[&_p]:'], + ['hover:peer-[&_p]:focus:', 'hover:peer-[&_p]:focus:'], + ['peer-[&:hover]:peer-[&_p]:', 'peer-[&:hover]:peer-[&_p]:'], + + ['[p]:', '[p]:'], + ['[_p_]:', '[p]:'], + ['has-[p]:', 'has-[p]:'], + ['has-[_p_]:', 'has-[p]:'], + + // Simplify `&:is(p)` to `p` + ['[&:is(p)]:', '[p]:'], + ['[&:is(_p_)]:', '[p]:'], + ['has-[&:is(p)]:', 'has-[p]:'], + ['has-[&:is(_p_)]:', 'has-[p]:'], + + // Handle special `@` variants. These shouldn't be printed as `@-` + ['@xl:', '@xl:'], + ['@[123px]:', '@[123px]:'], +] + +let combinations: [string, string][] = [] + +for (let [inputVariant, outputVariant] of variants) { + for (let [inputCandidate, outputCandidate] of candidates) { + combinations.push([`${inputVariant}${inputCandidate}`, `${outputVariant}${outputCandidate}`]) + } +} + +describe('normalize candidates', () => { + test.each(combinations)('`%s` -> `%s`', async (candidate: string, result: string) => { + let designSystem = await __unstable__loadDesignSystem('@tailwind utilities', { + base: __dirname, + }) + + let [parsed] = designSystem.parseCandidate(candidate) + + expect(designSystem.printCandidate(parsed)).toEqual(result) + }) +}) diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts new file mode 100644 index 000000000..811de77f5 --- /dev/null +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -0,0 +1,911 @@ +import fs from 'node:fs' +import path from 'node:path' +import { describe, expect, test } from 'vitest' +import { __unstable__loadDesignSystem } from '.' +import { DefaultMap } from './utils/default-map' + +const css = String.raw +const defaultTheme = fs.readFileSync(path.resolve(__dirname, '../theme.css'), 'utf8') + +const designSystems = new DefaultMap((base: string) => { + return new DefaultMap((input: string) => { + return __unstable__loadDesignSystem(input, { + base, + async loadStylesheet() { + return { + path: '', + base: '', + content: css` + @tailwind utilities; + + ${defaultTheme} + `, + } + }, + }) + }) +}) + +describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', (strategy) => { + let testName = '`%s` → `%s` (%#)' + if (strategy === 'with-variant') { + testName = testName.replaceAll('%s', 'focus:%s') + } else if (strategy === 'important') { + testName = testName.replaceAll('%s', '%s!') + } else if (strategy === 'prefix') { + testName = testName.replaceAll('%s', 'tw:%s') + } + + function prepare(candidate: string) { + if (strategy === 'with-variant') { + candidate = `focus:${candidate}` + } else if (strategy === 'important') { + candidate = `${candidate}!` + } else if (strategy === 'prefix') { + candidate = `tw:${candidate}` + + // Prefix all known CSS variables with `--tw-`, except when used inside of `--theme(…)`. + if (candidate.includes('--')) { + candidate = candidate + .replace( + // Replace the variable, as long as it is preceded by a `(`, e.g.: + // `bg-(--foo)` or an `:` in case of `bg-(color:--foo)`. + // + // It also has to end in a `,` or `)` to prevent replacing functions + // that look like variables, e.g.: `--spacing(…)` + /([(:])--([\w-]+)([,)])/g, + (_, start, variable, end) => `${start}--tw-${variable}${end}`, + ) + .replaceAll('--theme(--tw-', '--theme(--') + } + } + + return candidate + } + + async function expectCanonicalization(input: string, candidate: string, expected: string) { + candidate = prepare(candidate) + expected = prepare(expected) + + if (strategy === 'prefix') { + input = input.replace("@import 'tailwindcss';", "@import 'tailwindcss' prefix(tw);") + } + + let designSystem = await designSystems.get(__dirname).get(input) + let [actual] = designSystem.canonicalizeCandidates([candidate]) + + try { + expect(actual).toBe(expected) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, expectCanonicalization) + throw err + } + } + + /// ---------------------------------- + + test.each([ + /// Legacy bg-gradient-* → bg-linear-* + ['bg-gradient-to-t', 'bg-linear-to-t'], + ['bg-gradient-to-tr', 'bg-linear-to-tr'], + ['bg-gradient-to-r', 'bg-linear-to-r'], + ['bg-gradient-to-br', 'bg-linear-to-br'], + ['bg-gradient-to-b', 'bg-linear-to-b'], + ['bg-gradient-to-bl', 'bg-linear-to-bl'], + ['bg-gradient-to-l', 'bg-linear-to-l'], + ['bg-gradient-to-tl', 'bg-linear-to-tl'], + + /// theme(…) to `var(…)` + // Keep candidates that don't contain `theme(…)` or `theme(…, …)` + ['[color:red]', 'text-[red]'], + + // Handle special cases around `.1` in the `theme(…)` + ['[--value:theme(spacing.1)]', '[--value:--spacing(1)]'], + ['[--value:theme(fontSize.xs.1.lineHeight)]', '[--value:var(--text-xs--line-height)]'], + ['[--value:theme(spacing[1.25])]', '[--value:--spacing(1.25)]'], + + // Should not convert invalid spacing values to calc + ['[--value:theme(spacing[1.1])]', '[--value:theme(spacing[1.1])]'], + + // Convert to `var(…)` if we can resolve the path + ['[color:theme(colors.red.500)]', 'text-red-500'], // Arbitrary property + ['[color:theme(colors.red.500)]/50', 'text-red-500/50'], // Arbitrary property + modifier + ['bg-[theme(colors.red.500)]', 'bg-red-500'], // Arbitrary value + ['bg-[size:theme(spacing.4)]', 'bg-size-[--spacing(4)]'], // Arbitrary value + data type hint + + // Pretty print CSS functions preceded by an operator to prevent consecutive + // operator characters. + ['w-[calc(100dvh-theme(spacing.2))]', 'w-[calc(100dvh-(--spacing(2)))]'], + ['w-[calc(100dvh+theme(spacing.2))]', 'w-[calc(100dvh+(--spacing(2)))]'], + ['w-[calc(100dvh/theme(spacing.2))]', 'w-[calc(100dvh/(--spacing(2)))]'], + ['w-[calc(100dvh*theme(spacing.2))]', 'w-[calc(100dvh*(--spacing(2)))]'], + + // Convert to `var(…)` if we can resolve the path, but keep fallback values + ['bg-[theme(colors.red.500,red)]', 'bg-(--color-red-500,red)'], + + // Keep `theme(…)` if we can't resolve the path + ['bg-[theme(colors.foo.1000)]', 'bg-[theme(colors.foo.1000)]'], + + // Keep `theme(…)` if we can't resolve the path, but still try to convert the + // fallback value. + ['bg-[theme(colors.foo.1000,theme(colors.red.500))]', 'bg-red-500'], + + // Use `theme(…)` (deeply nested) inside of a `calc(…)` function + ['text-[calc(theme(fontSize.xs)*2)]', 'text-[calc(var(--text-xs)*2)]'], + + // Multiple `theme(… / …)` calls should result in modern syntax of `theme(…)` + // - Can't convert to `var(…)` because that would lose the modifier. + // - Can't convert to a candidate modifier because there are multiple + // `theme(…)` calls. + // + // If we really want to, we can make a fancy migration that tries to move it + // to a candidate modifier _if_ all `theme(…)` calls use the same modifier. + [ + '[color:theme(colors.red.500/50,theme(colors.blue.500/50))]', + 'text-[--theme(--color-red-500/50,--theme(--color-blue-500/50))]', + ], + [ + '[color:theme(colors.red.500/50,theme(colors.blue.500/50))]/50', + 'text-[--theme(--color-red-500/50,--theme(--color-blue-500/50))]/50', + ], + + // Convert the `theme(…)`, but try to move the inline modifier (e.g. `50%`), + // to a candidate modifier. + // Arbitrary property, with simple percentage modifier + ['[color:theme(colors.red.500/75%)]', 'text-red-500/75'], + + // Arbitrary property, with numbers (0-1) without a unit + ['[color:theme(colors.red.500/.12)]', 'text-red-500/12'], + ['[color:theme(colors.red.500/0.12)]', 'text-red-500/12'], + + // Arbitrary property, with more complex modifier (we only allow whole numbers + // as bare modifiers). Convert the complex numbers to arbitrary values instead. + ['[color:theme(colors.red.500/12.34%)]', 'text-red-500/[12.34%]'], + ['[color:theme(colors.red.500/var(--opacity))]', 'text-red-500/(--opacity)'], + ['[color:theme(colors.red.500/.12345)]', 'text-red-500/1234.5'], + ['[color:theme(colors.red.500/50.25%)]', 'text-red-500/50.25'], + + // Arbitrary value + ['bg-[theme(colors.red.500/75%)]', 'bg-red-500/75'], + ['bg-[theme(colors.red.500/12.34%)]', 'bg-red-500/[12.34%]'], + + // Arbitrary property that already contains a modifier + ['[color:theme(colors.red.500/50%)]/50', 'text-[--theme(--color-red-500/50%)]/50'], + + // Values that don't contain only `theme(…)` calls should not be converted to + // use a modifier since the color is not the whole value. + [ + 'shadow-[shadow:inset_0px_1px_theme(colors.white/15%)]', + 'shadow-[inset_0px_1px_--theme(--color-white/15%)]', + ], + + // Arbitrary value, where the candidate already contains a modifier + // This should still migrate the `theme(…)` syntax to the modern syntax. + ['bg-[theme(colors.red.500/50%)]/50', 'bg-[--theme(--color-red-500/50%)]/50'], + + // Variants, we can't use `var(…)` especially inside of `@media(…)`. We can + // still upgrade the `theme(…)` to the modern syntax. + ['max-[theme(screens.lg)]:flex', 'max-[--theme(--breakpoint-lg)]:flex'], + // There are no variables for `--spacing` multiples, so we can't convert this + ['max-[theme(spacing.4)]:flex', 'max-[theme(spacing.4)]:flex'], + + // This test in itself doesn't make much sense. But we need to make sure + // that this doesn't end up as the modifier in the candidate itself. + ['max-[theme(spacing.4/50)]:flex', 'max-[theme(spacing.4/50)]:flex'], + + // `theme(…)` calls in another CSS function is replaced correctly. + // Additionally we remove unnecessary whitespace. + ['grid-cols-[min(50%_,_theme(spacing.80))_auto]', 'grid-cols-[min(50%,--spacing(80))_auto]'], + + // `theme(…)` calls valid in v3, but not in v4 should still be converted. + ['[--foo:theme(transitionDuration.500)]', '[--foo:theme(transitionDuration.500)]'], + + // Renamed theme keys + ['max-w-[theme(screens.md)]', 'max-w-(--breakpoint-md)'], + ['w-[theme(maxWidth.md)]', 'w-md'], + + // Invalid cases + ['[--foo:theme(colors.red.500/50/50)]', '[--foo:theme(colors.red.500/50/50)]'], + ['[--foo:theme(colors.red.500/50/50)]/50', '[--foo:theme(colors.red.500/50/50)]/50'], + + // Partially invalid cases + [ + '[--foo:theme(colors.red.500/50/50)_theme(colors.blue.200)]', + '[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]', + ], + [ + '[--foo:theme(colors.red.500/50/50)_theme(colors.blue.200)]/50', + '[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]/50', + ], + ])(testName, async (candidate, expected) => { + await expectCanonicalization( + css` + @import 'tailwindcss'; + `, + candidate, + expected, + ) + }) + + describe('arbitrary utilities', () => { + test.each([ + // Arbitrary property to static utility + ['[text-wrap:balance]', 'text-balance'], + + // Arbitrary property to static utility with slight differences in + // whitespace. This will require some canonicalization. + ['[display:_flex_]', 'flex'], + ['[display:_flex]', 'flex'], + ['[display:flex_]', 'flex'], + + // Arbitrary property to static utility + // Map number to keyword-like value + ['leading-[1]', 'leading-none'], + + // Arbitrary property to named functional utility + ['[color:var(--color-red-500)]', 'text-red-500'], + ['[background-color:var(--color-red-500)]', 'bg-red-500'], + + // Arbitrary property with modifier to named functional utility with modifier + ['[color:var(--color-red-500)]/25', 'text-red-500/25'], + + // Arbitrary property with arbitrary modifier to named functional utility with + // arbitrary modifier + ['[color:var(--color-red-500)]/[25%]', 'text-red-500/25'], + ['[color:var(--color-red-500)]/[100%]', 'text-red-500'], + ['[color:var(--color-red-500)]/100', 'text-red-500'], + // No need for `/50` because that's already encoded in the `--color-primary` + // value + ['[color:oklch(62.3%_0.214_259.815)]/50', 'text-primary'], + + // Arbitrary property to arbitrary value + ['[max-height:20px]', 'max-h-[20px]'], + + // Arbitrary property to bare value + ['[grid-column:2]', 'col-2'], + ['[grid-column:1234]', 'col-1234'], + + // Arbitrary value to bare value + ['border-[2px]', 'border-2'], + ['border-[1234px]', 'border-1234'], + + // Arbitrary value with data type, to more specific arbitrary value + ['bg-[position:123px]', 'bg-position-[123px]'], + ['bg-[size:123px]', 'bg-size-[123px]'], + + // Arbitrary value with inferred data type, to more specific arbitrary value + ['bg-[123px]', 'bg-position-[123px]'], + + // Arbitrary value with spacing mul + ['w-[64rem]', 'w-256'], + + // Complex arbitrary property to arbitrary value + [ + '[grid-template-columns:repeat(2,minmax(100px,1fr))]', + 'grid-cols-[repeat(2,minmax(100px,1fr))]', + ], + // Complex arbitrary property to bare value + ['[grid-template-columns:repeat(2,minmax(0,1fr))]', 'grid-cols-2'], + + // Arbitrary value to bare value with percentage + ['from-[25%]', 'from-25%'], + + // Arbitrary percentage value must be a whole number. Should not migrate to + // a bare value. + ['from-[2.5%]', 'from-[2.5%]'], + ])(testName, async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + + @theme { + --*: initial; + --spacing: 0.25rem; + --color-red-500: red; + + /* Equivalent of blue-500/50 */ + --color-primary: color-mix(in oklab, oklch(62.3% 0.214 259.815) 50%, transparent); + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('migrate with custom static utility `@utility custom {…}`', async () => { + let candidate = '[--key:value]' + let expected = 'custom' + + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @utility custom { + --key: value; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('migrate with custom functional utility `@utility custom-* {…}`', async () => { + let candidate = '[--key:value]' + let expected = 'custom-value' + + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @utility custom-* { + --key: --value('value'); + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('migrate with custom functional utility `@utility custom-* {…}` that supports bare values', async () => { + let candidate = '[tab-size:4]' + let expected = 'tab-4' + + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @utility tab-* { + tab-size: --value(integer); + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test.each([ + ['[tab-size:0]', 'tab-0'], + ['[tab-size:4]', 'tab-4'], + ['[tab-size:8]', 'tab-github'], + ['tab-[0]', 'tab-0'], + ['tab-[4]', 'tab-4'], + ['tab-[8]', 'tab-github'], + ])( + 'migrate custom @utility from arbitrary values to bare values and named values (based on theme)', + async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + --tab-size-github: 8; + } + + @utility tab-* { + tab-size: --value(--tab-size, integer, [integer]); + } + ` + + await expectCanonicalization(input, candidate, expected) + }, + ) + + describe.each([['@theme'], ['@theme inline']])('%s', (theme) => { + test.each([ + ['[color:CanvasText]', 'text-canvas'], + ['text-[CanvasText]', 'text-canvas'], + ])(`migrate arbitrary value to theme value ${testName}`, async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + ${theme} { + --*: initial; + --color-canvas: CanvasText; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + // Some utilities read from specific namespaces, in this case we do not want + // to migrate to a value in that namespace if we reference a variable that + // results in the same value, but comes from a different namespace. + // + // E.g.: `max-w` reads from: ['--max-width', '--spacing', '--container'] + test.each([ + // `max-w` does not read from `--breakpoint-md`, but `--breakpoint-md` and + // `--container-3xl` happen to result in the same value. The difference is + // the semantics of the value. + ['max-w-(--breakpoint-md)', 'max-w-(--breakpoint-md)'], + ['max-w-(--container-3xl)', 'max-w-3xl'], + ])(`migrate arbitrary value to theme value ${testName}`, async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + ${theme} { + --*: initial; + --breakpoint-md: 48rem; + --container-3xl: 48rem; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + }) + + test('migrate an arbitrary property without spaces, to a theme value with spaces (canonicalization)', async () => { + let candidate = 'font-[foo,bar,baz]' + let expected = 'font-example' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + --font-example: foo, bar, baz; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test.each([ + // Default spacing scale + ['w-[64rem]', 'w-256', '0.25rem'], + + // Keep arbitrary value if units are different + ['w-[124px]', 'w-[124px]', '0.25rem'], + + // Keep arbitrary value if bare value doesn't fit in steps of .25 + ['w-[0.123rem]', 'w-[0.123rem]', '0.25rem'], + + // Custom pixel based spacing scale + ['w-[123px]', 'w-123', '1px'], + ['w-[256px]', 'w-128', '2px'], + ])(`${testName} (spacing = \`%s\`)`, async (candidate, expected, spacing) => { + let input = css` + @import 'tailwindcss'; + + @theme { + --*: initial; + --spacing: ${spacing}; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('bare values', () => { + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + --spacing: 0.25rem; + --aspect-video: 16 / 9; + --tab-size-github: 8; + } + + @utility tab-* { + tab-size: --value(--tab-size, integer); + } + ` + + test.each([ + // Built-in utility with bare value fraction + ['aspect-16/9', 'aspect-video'], + + // Custom utility with bare value integer + ['tab-8', 'tab-github'], + ])(testName, async (candidate, expected) => { + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('deprecated utilities', () => { + test('`order-none` → `order-0`', async () => { + let candidate = 'order-none' + let expected = 'order-0' + + let input = css` + @import 'tailwindcss'; + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('`order-none` → `order-none` with custom implementation', async () => { + let candidate = 'order-none' + let expected = 'order-none' + + let input = css` + @import 'tailwindcss'; + + @utility order-none { + order: none; /* imagine this exists */ + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('arbitrary variants', () => { + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + ` + + test.each([ + // Arbitrary variant to static variant + ['[&:focus]:flex', 'focus:flex'], + + // Arbitrary variant to static variant with at-rules + ['[@media(scripting:_none)]:flex', 'noscript:flex'], + + // Arbitrary variant to static utility at-rules and with slight differences + // in whitespace. This will require some canonicalization. + ['[@media(scripting:none)]:flex', 'noscript:flex'], + ['[@media(scripting:_none)]:flex', 'noscript:flex'], + ['[@media_(scripting:_none)]:flex', 'noscript:flex'], + + // With compound variants + ['has-[&:focus]:flex', 'has-focus:flex'], + ['not-[&:focus]:flex', 'not-focus:flex'], + ['group-[&:focus]:flex', 'group-focus:flex'], + ['peer-[&:focus]:flex', 'peer-focus:flex'], + ['in-[&:focus]:flex', 'in-focus:flex'], + ])(testName, async (candidate, expected) => { + await expectCanonicalization(input, candidate, expected) + }) + + test('unsafe migrations keep the candidate as-is', async () => { + // `hover:` also includes an `@media` query in addition to the `&:hover` + // state. Migration is not safe because the functionality would be different. + let candidate = '[&:hover]:flex' + let expected = '[&:hover]:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('make unsafe migration safe (1)', async () => { + // Overriding the `hover:` variant to only use a selector will make the + // migration safe. + let candidate = '[&:hover]:flex' + let expected = 'hover:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant hover (&:hover); + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('make unsafe migration safe (2)', async () => { + // Overriding the `hover:` variant to only use a selector will make the + // migration safe. This time with the long-hand `@variant` syntax. + let candidate = '[&:hover]:flex' + let expected = 'hover:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant hover { + &:hover { + @slot; + } + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('custom selector-based variants', async () => { + let candidate = '[&.macos]:flex' + let expected = 'is-macos:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant is-macos (&.macos); + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test('custom @media-based variants', async () => { + let candidate = '[@media(prefers-reduced-transparency:reduce)]:flex' + let expected = 'transparency-safe:flex' + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + } + @variant transparency-safe { + @media (prefers-reduced-transparency: reduce) { + @slot; + } + } + ` + + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('drop unnecessary data types', () => { + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + --color-red-500: red; + } + ` + + test.each([ + // A color value can be inferred from the value + ['bg-[color:#008cc]', 'bg-[#008cc]'], + + // A color is the default for `bg-*` + ['bg-(color:--my-value)', 'bg-(--my-value)'], + + // A color with a known theme variable migrates to the full utility + ['bg-(color:--color-red-500)', 'bg-red-500'], + ])(testName, async (candidate, expected) => { + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('arbitrary value to bare value', () => { + test.each([ + ['aspect-[12/34]', 'aspect-12/34'], + ['aspect-[1.2/34]', 'aspect-[1.2/34]'], + ['col-start-[7]', 'col-start-7'], + ['flex-[2]', 'flex-2'], // `flex` is implemented as static and functional utilities + + ['grid-cols-[subgrid]', 'grid-cols-subgrid'], + ['grid-rows-[subgrid]', 'grid-rows-subgrid'], + + // Only 50-200% (inclusive) are valid: + // https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch#percentage + ['font-stretch-[50%]', 'font-stretch-50%'], + ['font-stretch-[50.5%]', 'font-stretch-[50.5%]'], + ['font-stretch-[201%]', 'font-stretch-[201%]'], + ['font-stretch-[49%]', 'font-stretch-[49%]'], + // Should stay as-is + ['font-stretch-[1/2]', 'font-stretch-[1/2]'], + + // Bare value with % is valid for these utilities + ['from-[28%]', 'from-28%'], + ['via-[28%]', 'via-28%'], + ['to-[28%]', 'to-28%'], + ['from-[28.5%]', 'from-[28.5%]'], + ['via-[28.5%]', 'via-[28.5%]'], + ['to-[28.5%]', 'to-[28.5%]'], + + // This test in itself is a bit flawed because `text-[1/2]` currently + // generates something. Converting it to `text-1/2` doesn't produce anything. + ['text-[1/2]', 'text-[1/2]'], + + // Leading is special, because `leading-[123]` is the direct value of 123, but + // `leading-123` maps to `calc(--spacing(123))`. + ['leading-[123]', 'leading-[123]'], + + ['data-[selected]:flex', 'data-selected:flex'], + ['data-[foo=bar]:flex', 'data-[foo=bar]:flex'], + + ['supports-[gap]:flex', 'supports-gap:flex'], + ['supports-[display:grid]:flex', 'supports-[display:grid]:flex'], + + ['group-data-[selected]:flex', 'group-data-selected:flex'], + ['group-data-[foo=bar]:flex', 'group-data-[foo=bar]:flex'], + ['group-has-data-[selected]:flex', 'group-has-data-selected:flex'], + + ['aria-[selected]:flex', 'aria-[selected]:flex'], + ['aria-[selected="true"]:flex', 'aria-selected:flex'], + ['aria-[selected*="true"]:flex', 'aria-[selected*="true"]:flex'], + + ['group-aria-[selected]:flex', 'group-aria-[selected]:flex'], + ['group-aria-[selected="true"]:flex', 'group-aria-selected:flex'], + ['group-has-aria-[selected]:flex', 'group-has-aria-[selected]:flex'], + + ['max-lg:hover:data-[selected]:flex', 'max-lg:hover:data-selected:flex'], + [ + 'data-[selected]:aria-[selected="true"]:aspect-[12/34]', + 'data-selected:aria-selected:aspect-12/34', + ], + ])(testName, async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + ` + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('modernize arbitrary variants', () => { + test.each([ + // Arbitrary variants + ['[[data-visible]]:flex', 'data-visible:flex'], + ['[&[data-visible]]:flex', 'data-visible:flex'], + ['[[data-visible]&]:flex', 'data-visible:flex'], + ['[&>[data-visible]]:flex', '*:data-visible:flex'], + ['[&_>_[data-visible]]:flex', '*:data-visible:flex'], + ['[&>*]:flex', '*:flex'], + ['[&_>_*]:flex', '*:flex'], + + ['[&_[data-visible]]:flex', '**:data-visible:flex'], + ['[&_*]:flex', '**:flex'], + + ['[&:first-child]:flex', 'first:flex'], + ['[&:not(:first-child)]:flex', 'not-first:flex'], + + // in-* variants + ['[p_&]:flex', 'in-[p]:flex'], + ['[.foo_&]:flex', 'in-[.foo]:flex'], + ['[[data-visible]_&]:flex', 'in-data-visible:flex'], + // Multiple selectors, should stay as-is + ['[[data-foo][data-bar]_&]:flex', '[[data-foo][data-bar]_&]:flex'], + // Using `>` instead of ` ` should not be transformed: + ['[figure>&]:my-0', '[figure>&]:my-0'], + + // nth-child + ['[&:nth-child(2)]:flex', 'nth-2:flex'], + ['[&:not(:nth-child(2))]:flex', 'not-nth-2:flex'], + + ['[&:nth-child(-n+3)]:flex', 'nth-[-n+3]:flex'], + ['[&:not(:nth-child(-n+3))]:flex', 'not-nth-[-n+3]:flex'], + ['[&:nth-child(-n_+_3)]:flex', 'nth-[-n+3]:flex'], + ['[&:not(:nth-child(-n_+_3))]:flex', 'not-nth-[-n+3]:flex'], + + // nth-last-child + ['[&:nth-last-child(2)]:flex', 'nth-last-2:flex'], + ['[&:not(:nth-last-child(2))]:flex', 'not-nth-last-2:flex'], + + ['[&:nth-last-child(-n+3)]:flex', 'nth-last-[-n+3]:flex'], + ['[&:not(:nth-last-child(-n+3))]:flex', 'not-nth-last-[-n+3]:flex'], + ['[&:nth-last-child(-n_+_3)]:flex', 'nth-last-[-n+3]:flex'], + ['[&:not(:nth-last-child(-n_+_3))]:flex', 'not-nth-last-[-n+3]:flex'], + + // nth-child odd/even + ['[&:nth-child(odd)]:flex', 'odd:flex'], + ['[&:not(:nth-child(odd))]:flex', 'even:flex'], + ['[&:nth-child(even)]:flex', 'even:flex'], + ['[&:not(:nth-child(even))]:flex', 'odd:flex'], + + // Keep multiple attribute selectors as-is + ['[[data-visible][data-dark]]:flex', '[[data-visible][data-dark]]:flex'], + + // Keep `:where(…)` as is + ['[:where([data-visible])]:flex', '[:where([data-visible])]:flex'], + + // Complex attribute selectors with operators, quotes and insensitivity flags + ['[[data-url*="example"]]:flex', 'data-[url*="example"]:flex'], + ['[[data-url$=".com"_i]]:flex', 'data-[url$=".com"_i]:flex'], + ['[[data-url$=.com_i]]:flex', 'data-[url$=.com_i]:flex'], + + // Attribute selector wrapped in `&:is(…)` + ['[&:is([data-visible])]:flex', 'data-visible:flex'], + + // Media queries + ['[@media(pointer:fine)]:flex', 'pointer-fine:flex'], + ['[@media_(pointer_:_fine)]:flex', 'pointer-fine:flex'], + ['[@media_not_(pointer_:_fine)]:flex', 'not-pointer-fine:flex'], + ['[@media_print]:flex', 'print:flex'], + ['[@media_not_print]:flex', 'not-print:flex'], + + // Hoist the `:not` part to a compound variant + ['[@media_not_(prefers-color-scheme:dark)]:flex', 'not-dark:flex'], + [ + '[@media_not_(prefers-color-scheme:unknown)]:flex', + 'not-[@media_(prefers-color-scheme:unknown)]:flex', + ], + + // Compound arbitrary variants + ['has-[[data-visible]]:flex', 'has-data-visible:flex'], + ['has-[&:is([data-visible])]:flex', 'has-data-visible:flex'], + ['has-[&>[data-visible]]:flex', 'has-[&>[data-visible]]:flex'], + + ['has-[[data-slot=description]]:flex', 'has-data-[slot=description]:flex'], + ['has-[&:is([data-slot=description])]:flex', 'has-data-[slot=description]:flex'], + + ['has-[[aria-visible="true"]]:flex', 'has-aria-visible:flex'], + ['has-[[aria-visible]]:flex', 'has-aria-[visible]:flex'], + + ['has-[&:not(:nth-child(even))]:flex', 'has-odd:flex'], + ])(testName, async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + ` + await expectCanonicalization(input, candidate, expected) + }) + }) + + describe('optimize modifier', () => { + let input = css` + @import 'tailwindcss'; + @theme { + --*: initial; + --color-red-500: red; + } + ` + + test.each([ + // Keep the modifier as-is, nothing to optimize + ['bg-red-500/25', 'bg-red-500/25'], + + // Use a bare value modifier + ['bg-red-500/[25%]', 'bg-red-500/25'], + + // Convert 0-1 values to bare values + ['bg-[#f00]/[0.16]', 'bg-[#f00]/16'], + + // Drop unnecessary modifiers + ['bg-red-500/[100%]', 'bg-red-500'], + ['bg-red-500/100', 'bg-red-500'], + + // Keep modifiers on classes that don't _really_ exist + ['group/name', 'group/name'], + ])(testName, async (candidate, expected) => { + await expectCanonicalization(input, candidate, expected) + }) + }) +}) + +describe('theme to var', () => { + test('extended space scale converts to var or calc', async () => { + let designSystem = await __unstable__loadDesignSystem( + css` + @tailwind utilities; + @theme { + --spacing: 0.25rem; + --spacing-2: 2px; + --spacing-miami: 0.875rem; + } + `, + { base: __dirname }, + ) + expect( + designSystem.canonicalizeCandidates([ + '[--value:theme(spacing.1)]', + '[--value:theme(spacing.2)]', + '[--value:theme(spacing.miami)]', + '[--value:theme(spacing.nyc)]', + ]), + ).toEqual([ + '[--value:--spacing(1)]', + '[--value:var(--spacing-2)]', + '[--value:var(--spacing-miami)]', + '[--value:theme(spacing.nyc)]', + ]) + }) + + test('custom space scale converts to var', async () => { + let designSystem = await __unstable__loadDesignSystem( + css` + @tailwind utilities; + @theme { + --spacing-*: initial; + --spacing-1: 0.25rem; + --spacing-2: 0.5rem; + } + `, + { base: __dirname }, + ) + expect( + designSystem.canonicalizeCandidates([ + '[--value:theme(spacing.1)]', + '[--value:theme(spacing.2)]', + '[--value:theme(spacing.3)]', + ]), + ).toEqual([ + '[--value:var(--spacing-1)]', + '[--value:var(--spacing-2)]', + '[--value:theme(spacing.3)]', + ]) + }) +}) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts new file mode 100644 index 000000000..ba6910552 --- /dev/null +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -0,0 +1,1501 @@ +import * as AttributeSelectorParser from './attribute-selector-parser' +import { + printModifier, + type Candidate, + type CandidateModifier, + type NamedUtilityValue, + type Variant, +} from './candidate' +import { keyPathToCssProperty } from './compat/apply-config-to-theme' +import type { DesignSystem } from './design-system' +import * as SelectorParser from './selector-parser' +import { + computeUtilitySignature, + computeVariantSignature, + preComputedUtilities, + preComputedVariants, +} from './signatures' +import type { Writable } from './types' +import { DefaultMap } from './utils/default-map' +import { dimensions } from './utils/dimensions' +import { isPositiveInteger, isValidSpacingMultiplier } from './utils/infer-data-type' +import { replaceObject } from './utils/replace-object' +import { segment } from './utils/segment' +import { toKeyPath } from './utils/to-key-path' +import * as ValueParser from './value-parser' + +export function canonicalizeCandidates(ds: DesignSystem, candidates: string[]): string[] { + let result = new Set () + let cache = canonicalizeCandidateCache.get(ds) + for (let candidate of candidates) { + result.add(cache.get(candidate)) + } + return Array.from(result) +} + +const canonicalizeCandidateCache = new DefaultMap((ds: DesignSystem) => { + let prefix = ds.theme.prefix ? `${ds.theme.prefix}:` : '' + let variantCache = canonicalizeVariantCache.get(ds) + let utilityCache = canonicalizeUtilityCache.get(ds) + + return new DefaultMap ((rawCandidate: string, self) => { + for (let candidate of ds.parseCandidate(rawCandidate)) { + let variants = candidate.variants + .slice() + .reverse() + .flatMap((variant) => variantCache.get(variant)) + let important = candidate.important + + // Canonicalize the base candidate (utility), and re-attach the variants + // and important flag afterwards. This way we can maximize cache hits for + // the base candidate and each individual variant. + if (important || variants.length > 0) { + let canonicalizedUtility = self.get( + ds.printCandidate({ ...candidate, variants: [], important: false }), + ) + + // Rebuild the final candidate + let result = canonicalizedUtility + + // Remove the prefix if there are variants, because the variants exist + // between the prefix and the base candidate. + if (ds.theme.prefix !== null && variants.length > 0) { + result = result.slice(prefix.length) + } + + // Re-attach the variants + if (variants.length > 0) { + result = `${variants.map((v) => ds.printVariant(v)).join(':')}:${result}` + } + + // Re-attach the important flag + if (important) { + result += '!' + } + + // Re-attach the prefix if there were variants + if (ds.theme.prefix !== null && variants.length > 0) { + result = `${prefix}${result}` + } + + return result + } + + // We are guaranteed to have no variants and no important flag, just the + // base candidate left to canonicalize. + let result = utilityCache.get(rawCandidate) + if (result !== rawCandidate) { + return result + } + } + + return rawCandidate + }) +}) + +const VARIANT_CANONICALIZATIONS = [ + themeToVarVariant, + arbitraryValueToBareValueVariant, + modernizeArbitraryValuesVariant, + arbitraryVariants, +] + +const canonicalizeVariantCache = new DefaultMap((ds: DesignSystem) => { + return new DefaultMap((variant: Variant): Variant[] => { + let replacement = [variant] + for (let fn of VARIANT_CANONICALIZATIONS) { + for (let current of replacement.splice(0)) { + // A single variant can result in multiple variants, e.g.: + // `[&>[data-selected]]:flex` → `*:data-selected:flex` + let result = fn(ds, structuredClone(current)) + if (Array.isArray(result)) { + replacement.push(...result) + continue + } else { + replacement.push(result) + } + } + } + return replacement + }) +}) + +const UTILITY_CANONICALIZATIONS = [ + bgGradientToLinear, + themeToVarUtility, + arbitraryUtilities, + bareValueUtilities, + deprecatedUtilities, + dropUnnecessaryDataTypes, + arbitraryValueToBareValueUtility, + optimizeModifier, +] + +const canonicalizeUtilityCache = new DefaultMap((ds: DesignSystem) => { + return new DefaultMap((rawCandidate: string): string => { + for (let readonlyCandidate of ds.parseCandidate(rawCandidate)) { + let replacement = structuredClone(readonlyCandidate) as Writable + + for (let fn of UTILITY_CANONICALIZATIONS) { + replacement = fn(ds, replacement) + } + + let canonicalizedCandidate = ds.printCandidate(replacement) + if (rawCandidate !== canonicalizedCandidate) { + return canonicalizedCandidate + } + } + + return rawCandidate + }) +}) + +// ---- + +const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl'] +function bgGradientToLinear(_: DesignSystem, candidate: Candidate) { + if (candidate.kind === 'static' && candidate.root.startsWith('bg-gradient-to-')) { + let direction = candidate.root.slice(15) + + if (!DIRECTIONS.includes(direction)) { + return candidate + } + + candidate.root = `bg-linear-to-${direction}` + return candidate + } + + return candidate +} + +// ---- + +const enum Convert { + All = 0, + MigrateModifier = 1 << 0, + MigrateThemeOnly = 1 << 1, +} + +function themeToVarUtility(designSystem: DesignSystem, candidate: Candidate): Candidate { + let convert = converterCache.get(designSystem) + + if (candidate.kind === 'arbitrary') { + let [newValue, modifier] = convert( + candidate.value, + candidate.modifier === null ? Convert.MigrateModifier : Convert.All, + ) + if (newValue !== candidate.value) { + candidate.value = newValue + + if (modifier !== null) { + candidate.modifier = modifier + } + } + } else if (candidate.kind === 'functional' && candidate.value?.kind === 'arbitrary') { + let [newValue, modifier] = convert( + candidate.value.value, + candidate.modifier === null ? Convert.MigrateModifier : Convert.All, + ) + if (newValue !== candidate.value.value) { + candidate.value.value = newValue + + if (modifier !== null) { + candidate.modifier = modifier + } + } + } + + return candidate +} + +function themeToVarVariant(designSystem: DesignSystem, variant: Variant): Variant | Variant[] { + let convert = converterCache.get(designSystem) + + let iterator = walkVariants(variant) + for (let [variant] of iterator) { + if (variant.kind === 'arbitrary') { + let [newValue] = convert(variant.selector, Convert.MigrateThemeOnly) + if (newValue !== variant.selector) { + variant.selector = newValue + } + } else if (variant.kind === 'functional' && variant.value?.kind === 'arbitrary') { + let [newValue] = convert(variant.value.value, Convert.MigrateThemeOnly) + if (newValue !== variant.value.value) { + variant.value.value = newValue + } + } + } + + return variant +} + +const converterCache = new DefaultMap((ds: DesignSystem) => { + return createConverter(ds) + + function createConverter(designSystem: DesignSystem) { + function convert(input: string, options = Convert.All): [string, CandidateModifier | null] { + let ast = ValueParser.parse(input) + + // In some scenarios (e.g.: variants), we can't migrate to `var(…)` if it + // ends up in the `@media (…)` part. In this case we only have to migrate to + // the new `theme(…)` notation. + if (options & Convert.MigrateThemeOnly) { + return [substituteFunctionsInValue(ast, toTheme), null] + } + + let themeUsageCount = 0 + let themeModifierCount = 0 + + // Analyze AST + ValueParser.walk(ast, (node) => { + if (node.kind !== 'function') return + if (node.value !== 'theme') return + + // We are only interested in the `theme` function + themeUsageCount += 1 + + // Figure out if a modifier is used + ValueParser.walk(node.nodes, (child) => { + // If we see a `,`, it means that we have a fallback value + if (child.kind === 'separator' && child.value.includes(',')) { + return ValueParser.ValueWalkAction.Stop + } + + // If we see a `/`, we have a modifier + else if (child.kind === 'separator' && child.value.trim() === '/') { + themeModifierCount += 1 + return ValueParser.ValueWalkAction.Stop + } + + return ValueParser.ValueWalkAction.Skip + }) + }) + + // No `theme(…)` calls, nothing to do + if (themeUsageCount === 0) { + return [input, null] + } + + // No `theme(…)` with modifiers, we can migrate to `var(…)` + if (themeModifierCount === 0) { + return [substituteFunctionsInValue(ast, toVar), null] + } + + // Multiple modifiers which means that there are multiple `theme(…/…)` + // values. In this case, we can't convert the modifier to a candidate + // modifier. + // + // We also can't migrate to `var(…)` because that would lose the modifier. + // + // Try to convert each `theme(…)` call to the modern syntax. + if (themeModifierCount > 1) { + return [substituteFunctionsInValue(ast, toTheme), null] + } + + // Only a single `theme(…)` with a modifier left, that modifier will be + // migrated to a candidate modifier. + let modifier: CandidateModifier | null = null + let result = substituteFunctionsInValue(ast, (path, fallback) => { + let parts = segment(path, '/').map((part) => part.trim()) + + // Multiple `/` separators, which makes this an invalid path + if (parts.length > 2) return null + + // The path contains a `/`, which means that there is a modifier such as + // `theme(colors.red.500/50%)`. + // + // Currently, we are assuming that this is only being used for colors, + // which means that we can typically convert them to a modifier on the + // candidate itself. + // + // If there is more than one node in the AST though, `theme(…)` must not + // be the whole value so it's not safe to use a modifier instead. + // + // E.g.: `inset 0px 1px theme(colors.red.500/50%)` is a shadow, not a color. + if (ast.length === 1 && parts.length === 2 && options & Convert.MigrateModifier) { + let [pathPart, modifierPart] = parts + + // 50% -> /50 + if (/^\d+%$/.test(modifierPart)) { + modifier = { kind: 'named', value: modifierPart.slice(0, -1) } + } + + // .12 -> /12 + // .12345 -> /[12.345] + else if (/^0?\.\d+$/.test(modifierPart)) { + let value = Number(modifierPart) * 100 + modifier = { + kind: Number.isInteger(value) ? 'named' : 'arbitrary', + value: value.toString(), + } + } + + // Anything else becomes arbitrary + else { + modifier = { kind: 'arbitrary', value: modifierPart } + } + + // Update path to be the first part + path = pathPart + } + + return toVar(path, fallback) || toTheme(path, fallback) + }) + + return [result, modifier] + } + + function pathToVariableName(path: string, shouldPrefix = true) { + let variable = `--${keyPathToCssProperty(toKeyPath(path))}` as const + if (!designSystem.theme.get([variable])) return null + + if (shouldPrefix && designSystem.theme.prefix) { + return `--${designSystem.theme.prefix}-${variable.slice(2)}` + } + + return variable + } + + function toVar(path: string, fallback?: string) { + let variable = pathToVariableName(path) + if (variable) return fallback ? `var(${variable}, ${fallback})` : `var(${variable})` + + let keyPath = toKeyPath(path) + if (keyPath[0] === 'spacing' && designSystem.theme.get(['--spacing'])) { + let multiplier = keyPath[1] + if (!isValidSpacingMultiplier(multiplier)) return null + + return `--spacing(${multiplier})` + } + + return null + } + + function toTheme(path: string, fallback?: string) { + let parts = segment(path, '/').map((part) => part.trim()) + path = parts.shift()! + + let variable = pathToVariableName(path, false) + if (!variable) return null + + let modifier = parts.length > 0 ? `/${parts.join('/')}` : '' + return fallback + ? `--theme(${variable}${modifier}, ${fallback})` + : `--theme(${variable}${modifier})` + } + + return convert + } +}) + +function substituteFunctionsInValue( + ast: ValueParser.ValueAstNode[], + handle: (value: string, fallback?: string) => string | null, +) { + ValueParser.walk(ast, (node, { parent, replaceWith }) => { + if (node.kind === 'function' && node.value === 'theme') { + if (node.nodes.length < 1) return + + // Ignore whitespace before the first argument + if (node.nodes[0].kind === 'separator' && node.nodes[0].value.trim() === '') { + node.nodes.shift() + } + + let pathNode = node.nodes[0] + if (pathNode.kind !== 'word') return + + let path = pathNode.value + + // For the theme function arguments, we require all separators to contain + // comma (`,`), spaces alone should be merged into the previous word to + // avoid splitting in this case: + // + // theme(--color-red-500 / 75%) theme(--color-red-500 / 75%, foo, bar) + // + // We only need to do this for the first node, as the fallback values are + // passed through as-is. + let skipUntilIndex = 1 + for (let i = skipUntilIndex; i < node.nodes.length; i++) { + if (node.nodes[i].value.includes(',')) { + break + } + path += ValueParser.toCss([node.nodes[i]]) + skipUntilIndex = i + 1 + } + + path = eventuallyUnquote(path) + let fallbackValues = node.nodes.slice(skipUntilIndex + 1) + + let replacement = + fallbackValues.length > 0 ? handle(path, ValueParser.toCss(fallbackValues)) : handle(path) + if (replacement === null) return + + if (parent) { + let idx = parent.nodes.indexOf(node) - 1 + while (idx !== -1) { + let previous = parent.nodes[idx] + // Skip the space separator + if (previous.kind === 'separator' && previous.value.trim() === '') { + idx -= 1 + continue + } + + // If the previous node is a word and contains an operator, we need to + // wrap the replacement in parentheses to make the output less + // ambiguous. + // + // Input: + // - `calc(100dvh-theme(spacing.2))` + // + // Output: + // - `calc(100dvh-(--spacing(2)))` + // + // Not: + // -`calc(100dvh---spacing(2))` + // + if (/^[-+*/]$/.test(previous.value.trim())) { + replacement = `(${replacement})` + } + + break + } + } + + replaceWith(ValueParser.parse(replacement)) + } + }) + + return ValueParser.toCss(ast) +} + +function eventuallyUnquote(value: string) { + if (value[0] !== "'" && value[0] !== '"') return value + + let unquoted = '' + let quoteChar = value[0] + for (let i = 1; i < value.length - 1; i++) { + let currentChar = value[i] + let nextChar = value[i + 1] + + if (currentChar === '\\' && (nextChar === quoteChar || nextChar === '\\')) { + unquoted += nextChar + i++ + } else { + unquoted += currentChar + } + } + + return unquoted +} + +// ---- + +function* walkVariants(variant: Variant) { + function* inner( + variant: Variant, + parent: Extract | null = null, + ): Iterable<[Variant, Extract | null]> { + yield [variant, parent] + + if (variant.kind === 'compound') { + yield* inner(variant.variant, variant) + } + } + + yield* inner(variant, null) +} + +function parseCandidate(designSystem: DesignSystem, input: string) { + return designSystem.parseCandidate( + designSystem.theme.prefix && !input.startsWith(`${designSystem.theme.prefix}:`) + ? `${designSystem.theme.prefix}:${input}` + : input, + ) +} + +function printUnprefixedCandidate(designSystem: DesignSystem, candidate: Candidate) { + let candidateString = designSystem.printCandidate(candidate) + + return designSystem.theme.prefix && candidateString.startsWith(`${designSystem.theme.prefix}:`) + ? candidateString.slice(designSystem.theme.prefix.length + 1) + : candidateString +} + +// ---- + +const spacing = new DefaultMap | null>((ds) => { + let spacingMultiplier = ds.resolveThemeValue('--spacing') + if (spacingMultiplier === undefined) return null + + let parsed = dimensions.get(spacingMultiplier) + if (!parsed) return null + + let [value, unit] = parsed + + return new DefaultMap ((input) => { + let parsed = dimensions.get(input) + if (!parsed) return null + + let [myValue, myUnit] = parsed + if (myUnit !== unit) return null + + return myValue / value + }) +}) + +function arbitraryUtilities(designSystem: DesignSystem, candidate: Candidate): Candidate { + // We are only interested in arbitrary properties and arbitrary values + if ( + // Arbitrary property + candidate.kind !== 'arbitrary' && + // Arbitrary value + !(candidate.kind === 'functional' && candidate.value?.kind === 'arbitrary') + ) { + return candidate + } + + let utilities = preComputedUtilities.get(designSystem) + let signatures = computeUtilitySignature.get(designSystem) + + let targetCandidateString = designSystem.printCandidate(candidate) + + // Compute the signature for the target candidate + let targetSignature = signatures.get(targetCandidateString) + if (typeof targetSignature !== 'string') return candidate + + // Try a few options to find a suitable replacement utility + for (let replacementCandidate of tryReplacements(targetSignature, candidate)) { + let replacementString = designSystem.printCandidate(replacementCandidate) + let replacementSignature = signatures.get(replacementString) + if (replacementSignature !== targetSignature) { + continue + } + + // Ensure that if CSS variables were used, that they are still used + if (!allVariablesAreUsed(designSystem, candidate, replacementCandidate)) { + continue + } + + return replacementCandidate + } + + return candidate + + function* tryReplacements( + targetSignature: string, + candidate: Extract , + ): Generator { + // Find a corresponding utility for the same signature + let replacements = utilities.get(targetSignature) + + // Multiple utilities can map to the same signature. Not sure how to migrate + // this one so let's just skip it for now. + // + // TODO: Do we just migrate to the first one? + if (replacements.length > 1) return + + // If we didn't find any replacement utilities, let's try to strip the + // modifier and find a replacement then. If we do, we can try to re-add the + // modifier later and verify if we have a valid migration. + // + // This is necessary because `text-red-500/50` will not be pre-computed, + // only `text-red-500` will. + if (replacements.length === 0 && candidate.modifier) { + let candidateWithoutModifier = { ...candidate, modifier: null } + let targetSignatureWithoutModifier = signatures.get( + designSystem.printCandidate(candidateWithoutModifier), + ) + if (typeof targetSignatureWithoutModifier === 'string') { + for (let replacementCandidate of tryReplacements( + targetSignatureWithoutModifier, + candidateWithoutModifier, + )) { + yield Object.assign({}, replacementCandidate, { modifier: candidate.modifier }) + } + } + } + + // If only a single utility maps to the signature, we can use that as the + // replacement. + if (replacements.length === 1) { + for (let replacementCandidate of parseCandidate(designSystem, replacements[0])) { + yield replacementCandidate + } + } + + // Find a corresponding functional utility for the same signature + else if (replacements.length === 0) { + // An arbitrary property will only set a single property, we can use that + // to find functional utilities that also set this property. + let value = + candidate.kind === 'arbitrary' ? candidate.value : (candidate.value?.value ?? null) + if (value === null) return + + let spacingMultiplier = spacing.get(designSystem)?.get(value) ?? null + let rootPrefix = '' + if (spacingMultiplier !== null && spacingMultiplier < 0) { + rootPrefix = '-' + spacingMultiplier = Math.abs(spacingMultiplier) + } + + for (let root of Array.from(designSystem.utilities.keys('functional')).sort( + // Sort negative roots after positive roots so that we can try + // `mt-*` before `-mt-*`. This is especially useful in situations where + // `-mt-[0px]` can be translated to `mt-[0px]`. + (a, z) => Number(a[0] === '-') - Number(z[0] === '-'), + )) { + if (rootPrefix) root = `${rootPrefix}${root}` + + // Try as bare value + for (let replacementCandidate of parseCandidate(designSystem, `${root}-${value}`)) { + yield replacementCandidate + } + + // Try as bare value with modifier + if (candidate.modifier) { + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-${value}${candidate.modifier}`, + )) { + yield replacementCandidate + } + } + + // Try bare value based on the `--spacing` value. E.g.: + // + // - `w-[64rem]` → `w-256` + if (spacingMultiplier !== null) { + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-${spacingMultiplier}`, + )) { + yield replacementCandidate + } + + // Try bare value based on the `--spacing` value, but with a modifier + if (candidate.modifier) { + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-${spacingMultiplier}${printModifier(candidate.modifier)}`, + )) { + yield replacementCandidate + } + } + } + + // Try as arbitrary value + for (let replacementCandidate of parseCandidate(designSystem, `${root}-[${value}]`)) { + yield replacementCandidate + } + + // Try as arbitrary value with modifier + if (candidate.modifier) { + for (let replacementCandidate of parseCandidate( + designSystem, + `${root}-[${value}]${printModifier(candidate.modifier)}`, + )) { + yield replacementCandidate + } + } + } + } + } +} + +// Let's make sure that all variables used in the value are also all used in the +// found replacement. If not, then we are dealing with a different namespace or +// we could lose functionality in case the variable was changed higher up in the +// DOM tree. +function allVariablesAreUsed( + designSystem: DesignSystem, + candidate: Candidate, + replacement: Candidate, +) { + let value: string | null = null + + // Functional utility with arbitrary value and variables + if ( + candidate.kind === 'functional' && + candidate.value?.kind === 'arbitrary' && + candidate.value.value.includes('var(--') + ) { + value = candidate.value.value + } + + // Arbitrary property with variables + else if (candidate.kind === 'arbitrary' && candidate.value.includes('var(--')) { + value = candidate.value + } + + // No variables in the value, so this is a safe migration + if (value === null) { + return true + } + + let replacementAsCss = designSystem + .candidatesToCss([designSystem.printCandidate(replacement)]) + .join('\n') + + let isSafeMigration = true + ValueParser.walk(ValueParser.parse(value), (node) => { + if (node.kind === 'function' && node.value === 'var') { + let variable = node.nodes[0].value + let r = new RegExp(`var\\(${variable}[,)]\\s*`, 'g') + if ( + // We need to check if the variable is used in the replacement + !r.test(replacementAsCss) || + // The value cannot be set to a different value in the + // replacement because that would make it an unsafe migration + replacementAsCss.includes(`${variable}:`) + ) { + isSafeMigration = false + return ValueParser.ValueWalkAction.Stop + } + } + }) + + return isSafeMigration +} + +// ---- + +function bareValueUtilities(designSystem: DesignSystem, candidate: Candidate): Candidate { + // We are only interested in bare value utilities + if (candidate.kind !== 'functional' || candidate.value?.kind !== 'named') { + return candidate + } + + let utilities = preComputedUtilities.get(designSystem) + let signatures = computeUtilitySignature.get(designSystem) + + let targetCandidateString = designSystem.printCandidate(candidate) + + // Compute the signature for the target candidate + let targetSignature = signatures.get(targetCandidateString) + if (typeof targetSignature !== 'string') return candidate + + // Try a few options to find a suitable replacement utility + for (let replacementCandidate of tryReplacements(targetSignature, candidate)) { + let replacementString = designSystem.printCandidate(replacementCandidate) + let replacementSignature = signatures.get(replacementString) + if (replacementSignature !== targetSignature) { + continue + } + + return replacementCandidate + } + + return candidate + + function* tryReplacements( + targetSignature: string, + candidate: Extract , + ): Generator { + // Find a corresponding utility for the same signature + let replacements = utilities.get(targetSignature) + + // Multiple utilities can map to the same signature. Not sure how to migrate + // this one so let's just skip it for now. + // + // TODO: Do we just migrate to the first one? + if (replacements.length > 1) return + + // If we didn't find any replacement utilities, let's try to strip the + // modifier and find a replacement then. If we do, we can try to re-add the + // modifier later and verify if we have a valid migration. + // + // This is necessary because `text-red-500/50` will not be pre-computed, + // only `text-red-500` will. + if (replacements.length === 0 && candidate.modifier) { + let candidateWithoutModifier = { ...candidate, modifier: null } + let targetSignatureWithoutModifier = signatures.get( + designSystem.printCandidate(candidateWithoutModifier), + ) + if (typeof targetSignatureWithoutModifier === 'string') { + for (let replacementCandidate of tryReplacements( + targetSignatureWithoutModifier, + candidateWithoutModifier, + )) { + yield Object.assign({}, replacementCandidate, { modifier: candidate.modifier }) + } + } + } + + // If only a single utility maps to the signature, we can use that as the + // replacement. + if (replacements.length === 1) { + for (let replacementCandidate of parseCandidate(designSystem, replacements[0])) { + yield replacementCandidate + } + } + } +} + +// ---- + +const DEPRECATION_MAP = new Map([['order-none', 'order-0']]) + +function deprecatedUtilities(designSystem: DesignSystem, candidate: Candidate): Candidate { + let signatures = computeUtilitySignature.get(designSystem) + + let targetCandidateString = printUnprefixedCandidate(designSystem, candidate) + + let replacementString = DEPRECATION_MAP.get(targetCandidateString) ?? null + if (replacementString === null) return candidate + + let legacySignature = signatures.get(targetCandidateString) + if (typeof legacySignature !== 'string') return candidate + + let replacementSignature = signatures.get(replacementString) + if (typeof replacementSignature !== 'string') return candidate + + // Not the same signature, not safe to migrate + if (legacySignature !== replacementSignature) return candidate + + let [replacement] = parseCandidate(designSystem, replacementString) + return replacement +} + +// ---- + +function arbitraryVariants(designSystem: DesignSystem, variant: Variant): Variant | Variant[] { + let signatures = computeVariantSignature.get(designSystem) + let variants = preComputedVariants.get(designSystem) + + let iterator = walkVariants(variant) + for (let [variant] of iterator) { + if (variant.kind === 'compound') continue + + let targetString = designSystem.printVariant(variant) + let targetSignature = signatures.get(targetString) + if (typeof targetSignature !== 'string') continue + + let foundVariants = variants.get(targetSignature) + if (foundVariants.length !== 1) continue + + let foundVariant = foundVariants[0] + let parsedVariant = designSystem.parseVariant(foundVariant) + if (parsedVariant === null) continue + + replaceObject(variant, parsedVariant) + } + + return variant +} + +// ---- + +function dropUnnecessaryDataTypes(designSystem: DesignSystem, candidate: Candidate): Candidate { + let signatures = computeUtilitySignature.get(designSystem) + + if ( + candidate.kind === 'functional' && + candidate.value?.kind === 'arbitrary' && + candidate.value.dataType !== null + ) { + let replacement = designSystem.printCandidate({ + ...candidate, + value: { ...candidate.value, dataType: null }, + }) + + if (signatures.get(designSystem.printCandidate(candidate)) === signatures.get(replacement)) { + candidate.value.dataType = null + } + } + + return candidate +} + +// ---- + +function arbitraryValueToBareValueUtility( + designSystem: DesignSystem, + candidate: Candidate, +): Candidate { + // We are only interested in functional utilities with arbitrary values + if (candidate.kind !== 'functional' || candidate.value?.kind !== 'arbitrary') { + return candidate + } + + let signatures = computeUtilitySignature.get(designSystem) + + let expectedSignature = signatures.get(designSystem.printCandidate(candidate)) + if (expectedSignature === null) return candidate + + for (let value of tryValueReplacements(candidate)) { + let newSignature = signatures.get(designSystem.printCandidate({ ...candidate, value })) + if (newSignature === expectedSignature) { + candidate.value = value + return candidate + } + } + + return candidate +} + +function arbitraryValueToBareValueVariant(_: DesignSystem, variant: Variant): Variant | Variant[] { + let iterator = walkVariants(variant) + for (let [variant] of iterator) { + // Convert `data-[selected]` to `data-selected` + if ( + variant.kind === 'functional' && + variant.root === 'data' && + variant.value?.kind === 'arbitrary' && + !variant.value.value.includes('=') + ) { + variant.value = { + kind: 'named', + value: variant.value.value, + } + } + + // Convert `aria-[selected="true"]` to `aria-selected` + else if ( + variant.kind === 'functional' && + variant.root === 'aria' && + variant.value?.kind === 'arbitrary' && + (variant.value.value.endsWith('=true') || + variant.value.value.endsWith('="true"') || + variant.value.value.endsWith("='true'")) + ) { + let [key, _value] = segment(variant.value.value, '=') + if ( + // aria-[foo~="true"] + key[key.length - 1] === '~' || + // aria-[foo|="true"] + key[key.length - 1] === '|' || + // aria-[foo^="true"] + key[key.length - 1] === '^' || + // aria-[foo$="true"] + key[key.length - 1] === '$' || + // aria-[foo*="true"] + key[key.length - 1] === '*' + ) { + continue + } + + variant.value = { + kind: 'named', + value: variant.value.value.slice(0, variant.value.value.indexOf('=')), + } + } + + // Convert `supports-[gap]` to `supports-gap` + else if ( + variant.kind === 'functional' && + variant.root === 'supports' && + variant.value?.kind === 'arbitrary' && + /^[a-z-][a-z0-9-]*$/i.test(variant.value.value) + ) { + variant.value = { + kind: 'named', + value: variant.value.value, + } + } + } + + return variant +} + +// Convert functional utilities with arbitrary values to bare values if we can. +// We know that bare values can only be: +// +// 1. A number (with increments of .25) +// 2. A percentage (with increments of .25 followed by a `%`) +// 3. A ratio with whole numbers +// +// Not a bare value per se, but if we are dealing with a keyword, that could +// potentially also look like a bare value (aka no `[` or `]`). E.g.: +// ```diff +// grid-cols-[subgrid] +// grid-cols-subgrid +// ``` +function* tryValueReplacements( + candidate: Extract , + value: string = candidate.value?.value ?? '', + seen: Set = new Set(), +): Generator { + if (seen.has(value)) return + seen.add(value) + + // 0. Just try to drop the square brackets and see if it works + // 1. A number (with increments of .25) + yield { + kind: 'named', + value, + fraction: null, + } + + // 2. A percentage (with increments of .25 followed by a `%`) + // Try to drop the `%` and see if it works + if (value.endsWith('%') && isValidSpacingMultiplier(value.slice(0, -1))) { + yield { + kind: 'named', + value: value.slice(0, -1), + fraction: null, + } + } + + // 3. A ratio with whole numbers + if (value.includes('/')) { + let [numerator, denominator] = value.split('/') + if (isPositiveInteger(numerator) && isPositiveInteger(denominator)) { + yield { + kind: 'named', + value: numerator, + fraction: `${numerator}/${denominator}`, + } + } + } + + // It could also be that we have `20px`, we can try just `20` and see if it + // results in the same signature. + let allNumbersAndFractions = new Set () + + // Figure out all numbers and fractions in the value + for (let match of value.matchAll(/(\d+\/\d+)|(\d+\.?\d+)/g)) { + allNumbersAndFractions.add(match[0].trim()) + } + + // Sort the numbers and fractions where the smallest length comes first. This + // will result in the smallest replacement. + let options = Array.from(allNumbersAndFractions).sort((a, z) => { + return a.length - z.length + }) + + // Try all the options + for (let option of options) { + yield* tryValueReplacements(candidate, option, seen) + } +} + +// ---- + +function isSingleSelector(ast: SelectorParser.SelectorAstNode[]): boolean { + return !ast.some((node) => node.kind === 'separator' && node.value.trim() === ',') +} + +function isAttributeSelector(node: SelectorParser.SelectorAstNode): boolean { + let value = node.value.trim() + return node.kind === 'selector' && value[0] === '[' && value[value.length - 1] === ']' +} + +function modernizeArbitraryValuesVariant( + designSystem: DesignSystem, + variant: Variant, +): Variant | Variant[] { + let result = [variant] + let signatures = computeVariantSignature.get(designSystem) + + let iterator = walkVariants(variant) + for (let [variant, parent] of iterator) { + // Forward modifier from the root to the compound variant + if ( + variant.kind === 'compound' && + (variant.root === 'has' || variant.root === 'not' || variant.root === 'in') + ) { + if (variant.modifier !== null) { + if ('modifier' in variant.variant) { + variant.variant.modifier = variant.modifier + variant.modifier = null + } + } + } + + // Expecting an arbitrary variant + if (variant.kind === 'arbitrary') { + // Expecting a non-relative arbitrary variant + if (variant.relative) continue + + let ast = SelectorParser.parse(variant.selector.trim()) + + // Expecting a single selector node + if (!isSingleSelector(ast)) continue + + // `[&>*]` can be replaced with `*` + if ( + // Only top-level, so `has-[&>*]` is not supported + parent === null && + // [&_>_*]:flex + // ^ ^ ^ + ast.length === 3 && + ast[0].kind === 'selector' && + ast[0].value === '&' && + ast[1].kind === 'combinator' && + ast[1].value.trim() === '>' && + ast[2].kind === 'selector' && + ast[2].value === '*' + ) { + replaceObject(variant, designSystem.parseVariant('*')) + continue + } + + // `[&_*]` can be replaced with `**` + if ( + // Only top-level, so `has-[&_*]` is not supported + parent === null && + // [&_*]:flex + // ^ ^ + ast.length === 3 && + ast[0].kind === 'selector' && + ast[0].value === '&' && + ast[1].kind === 'combinator' && + ast[1].value.trim() === '' && // space, but trimmed because there could be multiple spaces + ast[2].kind === 'selector' && + ast[2].value === '*' + ) { + replaceObject(variant, designSystem.parseVariant('**')) + continue + } + + // `in-*` variant. If the selector ends with ` &`, we can convert it to an + // `in-*` variant. + // + // E.g.: `[[data-visible]_&]` => `in-data-visible` + if ( + // Only top-level, so `in-[&_[data-visible]]` is not supported + parent === null && + // [[data-visible]___&]:flex + // ^^^^^^^^^^^^^^ ^ ^ + ast.length === 3 && + ast[1].kind === 'combinator' && + ast[1].value.trim() === '' && // Space, but trimmed because there could be multiple spaces + ast[2].kind === 'selector' && + ast[2].value === '&' + ) { + ast.pop() // Remove the nesting node + ast.pop() // Remove the combinator + + // When handling a compound like `in-[[data-visible]]`, we will first + // handle `[[data-visible]]`, then the parent `in-*` part. This means + // that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`. + // + // Later this gets converted to `in-data-visible`. + replaceObject(variant, designSystem.parseVariant(`in-[${SelectorParser.toCss(ast)}]`)) + continue + } + + // Hoist `not` modifier for `@media` or `@supports` variants + // + // E.g.: `[@media_not_(scripting:none)]:` -> `not-[@media_(scripting:none)]:` + if ( + // Only top-level, so something like `in-[@media(scripting:none)]` + // (which is not valid anyway) is not supported + parent === null && + // [@media_not(scripting:none)]:flex + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ast[0].kind === 'selector' && + (ast[0].value === '@media' || ast[0].value === '@supports') + ) { + let targetSignature = signatures.get(designSystem.printVariant(variant)) + + let parsed = ValueParser.parse(SelectorParser.toCss(ast)) + let containsNot = false + ValueParser.walk(parsed, (node, { replaceWith }) => { + if (node.kind === 'word' && node.value === 'not') { + containsNot = true + replaceWith([]) + } + }) + + // Remove unnecessary whitespace + parsed = ValueParser.parse(ValueParser.toCss(parsed)) + ValueParser.walk(parsed, (node) => { + if (node.kind === 'separator' && node.value !== ' ' && node.value.trim() === '') { + // node.value contains at least 2 spaces. Normalize it to a single + // space. + node.value = ' ' + } + }) + + if (containsNot) { + let hoistedNot = designSystem.parseVariant(`not-[${ValueParser.toCss(parsed)}]`) + if (hoistedNot === null) continue + let hoistedNotSignature = signatures.get(designSystem.printVariant(hoistedNot)) + if (targetSignature === hoistedNotSignature) { + replaceObject(variant, hoistedNot) + continue + } + } + } + + let prefixedVariant: Variant | null = null + + // Handling a child combinator. E.g.: `[&>[data-visible]]` => `*:data-visible` + if ( + // Only top-level, so `has-[&>[data-visible]]` is not supported + parent === null && + // [&_>_[data-visible]]:flex + // ^ ^ ^^^^^^^^^^^^^^ + ast.length === 3 && + ast[0].kind === 'selector' && + ast[0].value.trim() === '&' && + ast[1].kind === 'combinator' && + ast[1].value.trim() === '>' && + ast[2].kind === 'selector' && + isAttributeSelector(ast[2]) + ) { + ast = [ast[2]] + prefixedVariant = designSystem.parseVariant('*') + } + + // Handling a grand child combinator. E.g.: `[&_[data-visible]]` => `**:data-visible` + if ( + // Only top-level, so `has-[&_[data-visible]]` is not supported + parent === null && + // [&_[data-visible]]:flex + // ^ ^^^^^^^^^^^^^^ + ast.length === 3 && + ast[0].kind === 'selector' && + ast[0].value.trim() === '&' && + ast[1].kind === 'combinator' && + ast[1].value.trim() === '' && // space, but trimmed because there could be multiple spaces + ast[2].kind === 'selector' && + isAttributeSelector(ast[2]) + ) { + ast = [ast[2]] + prefixedVariant = designSystem.parseVariant('**') + } + + // Filter out `&`. E.g.: `&[data-foo]` => `[data-foo]` + let selectorNodes = ast.filter( + (node) => !(node.kind === 'selector' && node.value.trim() === '&'), + ) + + // Expecting a single selector (normal selector or attribute selector) + if (selectorNodes.length !== 1) continue + + let target = selectorNodes[0] + if (target.kind === 'function' && target.value === ':is') { + // Expecting a single selector node + if ( + !isSingleSelector(target.nodes) || + // [foo][bar] is considered a single selector but has multiple nodes + target.nodes.length !== 1 + ) + continue + + // Expecting a single attribute selector + if (!isAttributeSelector(target.nodes[0])) continue + + // Unwrap the selector from inside `&:is(…)` + target = target.nodes[0] + } + + // Expecting a pseudo selector (or function) + if ( + (target.kind === 'function' && target.value[0] === ':') || + (target.kind === 'selector' && target.value[0] === ':') + ) { + let targetNode = target + let compoundNot = false + if (targetNode.kind === 'function' && targetNode.value === ':not') { + compoundNot = true + if (targetNode.nodes.length !== 1) continue + if (targetNode.nodes[0].kind !== 'selector' && targetNode.nodes[0].kind !== 'function') { + continue + } + if (targetNode.nodes[0].value[0] !== ':') continue + + targetNode = targetNode.nodes[0] + } + + let newVariant = ((value) => { + if ( + value === ':nth-child' && + targetNode.kind === 'function' && + targetNode.nodes.length === 1 && + targetNode.nodes[0].kind === 'value' && + targetNode.nodes[0].value === 'odd' + ) { + if (compoundNot) { + compoundNot = false + return 'even' + } + return 'odd' + } + + if ( + value === ':nth-child' && + targetNode.kind === 'function' && + targetNode.nodes.length === 1 && + targetNode.nodes[0].kind === 'value' && + targetNode.nodes[0].value === 'even' + ) { + if (compoundNot) { + compoundNot = false + return 'odd' + } + return 'even' + } + + for (let [selector, variantName] of [ + [':nth-child', 'nth'], + [':nth-last-child', 'nth-last'], + [':nth-of-type', 'nth-of-type'], + [':nth-last-of-type', 'nth-of-last-type'], + ]) { + if ( + value === selector && + targetNode.kind === 'function' && + targetNode.nodes.length === 1 + ) { + if ( + targetNode.nodes.length === 1 && + targetNode.nodes[0].kind === 'value' && + isPositiveInteger(targetNode.nodes[0].value) + ) { + return `${variantName}-${targetNode.nodes[0].value}` + } + + return `${variantName}-[${SelectorParser.toCss(targetNode.nodes)}]` + } + } + + // Hoist `not` modifier + if (compoundNot) { + let targetSignature = signatures.get(designSystem.printVariant(variant)) + let replacementSignature = signatures.get(`not-[${value}]`) + if (targetSignature === replacementSignature) { + return `[&${value}]` + } + } + + return null + })(targetNode.value) + + if (newVariant === null) continue + + // Add `not-` prefix + if (compoundNot) newVariant = `not-${newVariant}` + + let parsed = designSystem.parseVariant(newVariant) + if (parsed === null) continue + + // Update original variant + replaceObject(variant, parsed) + } + + // Expecting an attribute selector + else if (isAttributeSelector(target)) { + let attributeSelector = AttributeSelectorParser.parse(target.value) + if (attributeSelector === null) continue // Invalid attribute selector + + // Migrate `data-*` + if (attributeSelector.attribute.startsWith('data-')) { + let name = attributeSelector.attribute.slice(5) // Remove `data-` + + replaceObject(variant, { + kind: 'functional', + root: 'data', + modifier: null, + value: + attributeSelector.value === null + ? { kind: 'named', value: name } + : { + kind: 'arbitrary', + value: `${name}${attributeSelector.operator}${attributeSelector.quote ?? ''}${attributeSelector.value}${attributeSelector.quote ?? ''}${attributeSelector.sensitivity ? ` ${attributeSelector.sensitivity}` : ''}`, + }, + } satisfies Variant) + } + + // Migrate `aria-*` + else if (attributeSelector.attribute.startsWith('aria-')) { + let name = attributeSelector.attribute.slice(5) // Remove `aria-` + replaceObject(variant, { + kind: 'functional', + root: 'aria', + modifier: null, + value: + attributeSelector.value === null + ? { kind: 'arbitrary', value: name } // aria-[foo] + : attributeSelector.operator === '=' && + attributeSelector.value === 'true' && + attributeSelector.sensitivity === null + ? { kind: 'named', value: name } // aria-[foo="true"] or aria-[foo='true'] or aria-[foo=true] + : { + kind: 'arbitrary', + value: `${attributeSelector.attribute}${attributeSelector.operator}${attributeSelector.quote ?? ''}${attributeSelector.value}${attributeSelector.quote ?? ''}${attributeSelector.sensitivity ? ` ${attributeSelector.sensitivity}` : ''}`, + }, // aria-[foo~="true"], aria-[foo|="true"], … + } satisfies Variant) + } + } + + if (prefixedVariant) { + return [prefixedVariant, variant] + } + } + } + + return result +} + +// ---- + +// Optimize the modifier +// +// E.g.: +// +// - `/[25%]` → `/25` +// - `/[100%]` → `/100` → +// - `/100` → +// +function optimizeModifier(designSystem: DesignSystem, candidate: Candidate): Candidate { + // We are only interested in functional or arbitrary utilities with a modifier + if ( + (candidate.kind !== 'functional' && candidate.kind !== 'arbitrary') || + candidate.modifier === null + ) { + return candidate + } + + let signatures = computeUtilitySignature.get(designSystem) + + let targetSignature = signatures.get(designSystem.printCandidate(candidate)) + let modifier = candidate.modifier + + // 1. Try to drop the modifier entirely + if ( + targetSignature === + signatures.get(designSystem.printCandidate({ ...candidate, modifier: null })) + ) { + candidate.modifier = null + return candidate + } + + // 2. Try to remove the square brackets and the `%` sign + { + let newModifier: NamedUtilityValue = { + kind: 'named', + value: modifier.value.endsWith('%') ? modifier.value.slice(0, -1) : modifier.value, + fraction: null, + } + + if ( + targetSignature === + signatures.get(designSystem.printCandidate({ ...candidate, modifier: newModifier })) + ) { + candidate.modifier = newModifier + return candidate + } + } + + // 3. Try to remove the square brackets, but multiply by 100. E.g.: `[0.16]` -> `16` + { + let newModifier: NamedUtilityValue = { + kind: 'named', + value: `${parseFloat(modifier.value) * 100}`, + fraction: null, + } + + if ( + targetSignature === + signatures.get(designSystem.printCandidate({ ...candidate, modifier: newModifier })) + ) { + candidate.modifier = newModifier + return candidate + } + } + + return candidate +} diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index 3b2f0712c..f5444cf56 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -5,6 +5,7 @@ import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candida import { substituteFunctions } from '../css-functions' import * as CSS from '../css-parser' import type { DesignSystem } from '../design-system' +import * as SelectorParser from '../selector-parser' import type { SourceLocation } from '../source-maps/source' import { withAlpha } from '../utilities' import { DefaultMap } from '../utils/default-map' @@ -15,7 +16,6 @@ import { toKeyPath } from '../utils/to-key-path' import { compoundsForSelectors, IS_VALID_VARIANT_NAME, substituteAtSlot } from '../variants' import type { ResolvedConfig, UserConfig } from './config/types' import { createThemeFn } from './plugin-functions' -import * as SelectorParser from './selector-parser' export type Config = UserConfig export type PluginFn = (api: PluginAPI) => void diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 7efebf1a5..5f4450d50 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -10,7 +10,13 @@ import { } from './candidate' import { compileAstNodes, compileCandidates } from './compile' import { substituteFunctions } from './css-functions' -import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense' +import { + canonicalizeCandidates, + getClassList, + getVariants, + type ClassEntry, + type VariantEntry, +} from './intellisense' import { getClassOrder } from './sort' import { Theme, ThemeOptions, type ThemeKey } from './theme' import { Utilities, createUtilities, withAlpha } from './utilities' @@ -48,6 +54,7 @@ export type DesignSystem = { resolveThemeValue(path: string, forceInline?: boolean): string | undefined trackUsedVariables(raw: string): void + canonicalizeCandidates(candidates: string[]): string[] // Used by IntelliSense candidatesToCss(classes: string[]): (string | null)[] @@ -202,6 +209,10 @@ export function buildDesignSystem(theme: Theme): DesignSystem { trackUsedVariables(raw: string) { trackUsedVariables.get(raw) }, + + canonicalizeCandidates(candidates: string[]) { + return canonicalizeCandidates(this, candidates) + }, } return designSystem diff --git a/packages/tailwindcss/src/intellisense.ts b/packages/tailwindcss/src/intellisense.ts index e6db73b50..b04d8d212 100644 --- a/packages/tailwindcss/src/intellisense.ts +++ b/packages/tailwindcss/src/intellisense.ts @@ -3,6 +3,7 @@ import { applyVariant } from './compile' import type { DesignSystem } from './design-system' import { compare } from './utils/compare' import { DefaultMap } from './utils/default-map' +export { canonicalizeCandidates } from './canonicalize-candidates' interface ClassMetadata { modifiers: string[] diff --git a/packages/tailwindcss/src/compat/selector-parser.test.ts b/packages/tailwindcss/src/selector-parser.test.ts similarity index 83% rename from packages/tailwindcss/src/compat/selector-parser.test.ts rename to packages/tailwindcss/src/selector-parser.test.ts index f995c9de7..2a2283522 100644 --- a/packages/tailwindcss/src/compat/selector-parser.test.ts +++ b/packages/tailwindcss/src/selector-parser.test.ts @@ -131,6 +131,34 @@ describe('parse', () => { }, ]) }) + + it('parses nesting selector before attribute selector', () => { + expect(parse('&[data-foo]')).toEqual([ + { kind: 'selector', value: '&' }, + { kind: 'selector', value: '[data-foo]' }, + ]) + }) + + it('parses nesting selector after an attribute selector', () => { + expect(parse('[data-foo]&')).toEqual([ + { kind: 'selector', value: '[data-foo]' }, + { kind: 'selector', value: '&' }, + ]) + }) + + it('parses universal selector before attribute selector', () => { + expect(parse('*[data-foo]')).toEqual([ + { kind: 'selector', value: '*' }, + { kind: 'selector', value: '[data-foo]' }, + ]) + }) + + it('parses universal selector after an attribute selector', () => { + expect(parse('[data-foo]*')).toEqual([ + { kind: 'selector', value: '[data-foo]' }, + { kind: 'selector', value: '*' }, + ]) + }) }) describe('toCss', () => { diff --git a/packages/tailwindcss/src/compat/selector-parser.ts b/packages/tailwindcss/src/selector-parser.ts similarity index 90% rename from packages/tailwindcss/src/compat/selector-parser.ts rename to packages/tailwindcss/src/selector-parser.ts index 77a1ce4d4..0d28fdc4f 100644 --- a/packages/tailwindcss/src/compat/selector-parser.ts +++ b/packages/tailwindcss/src/selector-parser.ts @@ -181,6 +181,8 @@ const SINGLE_QUOTE = 0x27 const SPACE = 0x20 const TAB = 0x09 const TILDE = 0x7e +const AMPERSAND = 0x26 +const ASTERISK = 0x2a export function parse(input: string) { input = input.replaceAll('\r\n', '\n') @@ -369,11 +371,18 @@ export function parse(input: string) { ast.push(node) } } - buffer = String.fromCharCode(currentChar) + buffer = input[i] break } // Start of an attribute selector. + // + // NOTE: Right now we don't care about the individual parts of the + // attribute selector, we just want to find the matching closing bracket. + // + // If we need more information from inside the attribute selector in the + // future, then we can use the `AttributeSelectorParser` here (and even + // inline it if needed) case OPEN_BRACKET: { // Handle everything before the combinator as a selector if (buffer.length > 0) { @@ -443,17 +452,40 @@ export function parse(input: string) { break } + // Nesting `&` is always a new selector. + // Universal `*` is always a new selector. + case AMPERSAND: + case ASTERISK: { + // 1. Handle everything before the combinator as a selector + if (buffer.length > 0) { + let node = selector(buffer) + if (parent) { + parent.nodes.push(node) + } else { + ast.push(node) + } + buffer = '' + } + + // 2. Handle the `&` or `*` as a selector on its own + if (parent) { + parent.nodes.push(selector(input[i])) + } else { + ast.push(selector(input[i])) + } + break + } + // Escaped characters. case BACKSLASH: { - let nextChar = input.charCodeAt(i + 1) - buffer += String.fromCharCode(currentChar) + String.fromCharCode(nextChar) + buffer += input[i] + input[i + 1] i += 1 break } // Everything else will be collected in the buffer default: { - buffer += String.fromCharCode(currentChar) + buffer += input[i] } } } diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts b/packages/tailwindcss/src/signatures.ts similarity index 94% rename from packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts rename to packages/tailwindcss/src/signatures.ts index 4c857fdf5..89a5edde4 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/signatures.ts +++ b/packages/tailwindcss/src/signatures.ts @@ -1,13 +1,13 @@ -import { substituteAtApply } from '../../../../tailwindcss/src/apply' -import { atRule, styleRule, toCss, walk, type AstNode } from '../../../../tailwindcss/src/ast' -import { printArbitraryValue } from '../../../../tailwindcss/src/candidate' -import * as SelectorParser from '../../../../tailwindcss/src/compat/selector-parser' -import { CompileAstFlags, type DesignSystem } from '../../../../tailwindcss/src/design-system' -import { ThemeOptions } from '../../../../tailwindcss/src/theme' -import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' -import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type' -import * as ValueParser from '../../../../tailwindcss/src/value-parser' -import { dimensions } from '../../utils/dimension' +import { substituteAtApply } from './apply' +import { atRule, styleRule, toCss, walk, type AstNode } from './ast' +import { printArbitraryValue } from './candidate' +import { CompileAstFlags, type DesignSystem } from './design-system' +import * as SelectorParser from './selector-parser' +import { ThemeOptions } from './theme' +import { DefaultMap } from './utils/default-map' +import { dimensions } from './utils/dimensions' +import { isValidSpacingMultiplier } from './utils/infer-data-type' +import * as ValueParser from './value-parser' // Given a utility, compute a signature that represents the utility. The // signature will be a normalised form of the generated CSS for the utility, or @@ -44,17 +44,12 @@ export const computeUtilitySignature = new DefaultMap< // There's separate utility caches for respect important vs not // so we want to compile them both with `@theme inline` disabled for (let candidate of designSystem.parseCandidate(utility)) { - designSystem.compileAstNodes(candidate, CompileAstFlags.None) designSystem.compileAstNodes(candidate, CompileAstFlags.RespectImportant) } substituteAtApply(ast, designSystem) }) - // We will be mutating the AST, so we need to clone it first to not affect - // the original AST - ast = structuredClone(ast) - // Optimize the AST. This is needed such that any internal intermediate // nodes are gone. This will also cleanup declaration nodes with undefined // values or `--tw-sort` declarations. @@ -75,6 +70,11 @@ export const computeUtilitySignature = new DefaultMap< else if (node.kind === 'comment') { replaceWith([]) } + + // Remove at-rules that are not needed for the signature + else if (node.kind === 'at-rule' && node.name === '@property') { + replaceWith([]) + } }) // Resolve theme values to their inlined value. @@ -120,6 +120,7 @@ export const computeUtilitySignature = new DefaultMap< // Handle declarations if (node.kind === 'declaration' && node.value !== undefined) { if (node.value.includes('var(')) { + let changed = false let valueAst = ValueParser.parse(node.value) let seen = new Set () @@ -162,6 +163,7 @@ export const computeUtilitySignature = new DefaultMap< // More than 1 argument means that a fallback is already present if (valueNode.nodes.length === 1) { // Inject the fallback value into the variable lookup + changed = true valueNode.nodes.push(...ValueParser.parse(`,${variableValue}`)) } } @@ -174,6 +176,7 @@ export const computeUtilitySignature = new DefaultMap< let nodeAsString = ValueParser.toCss(valueNode.nodes) // This could include more than just the variable let constructedValue = `${valueNode.nodes[0].value},${variableValue}` if (nodeAsString === constructedValue) { + changed = true replaceWith(ValueParser.parse(variableValue)) } } @@ -181,7 +184,7 @@ export const computeUtilitySignature = new DefaultMap< }) // Replace the value with the new value - node.value = ValueParser.toCss(valueAst) + if (changed) node.value = ValueParser.toCss(valueAst) } // Very basic `calc(…)` constant folding to handle the spacing scale diff --git a/packages/@tailwindcss-upgrade/src/utils/types.ts b/packages/tailwindcss/src/types.ts similarity index 100% rename from packages/@tailwindcss-upgrade/src/utils/types.ts rename to packages/tailwindcss/src/types.ts diff --git a/packages/@tailwindcss-upgrade/src/utils/dimension.ts b/packages/tailwindcss/src/utils/dimensions.ts similarity index 80% rename from packages/@tailwindcss-upgrade/src/utils/dimension.ts rename to packages/tailwindcss/src/utils/dimensions.ts index a1dd4bded..17f05f9bd 100644 --- a/packages/@tailwindcss-upgrade/src/utils/dimension.ts +++ b/packages/tailwindcss/src/utils/dimensions.ts @@ -1,8 +1,10 @@ import { DefaultMap } from '../../../tailwindcss/src/utils/default-map' +const DIMENSION_REGEX = /^(? -?(?:\d*\.)?\d+)(? [a-z]+|%)$/i + // Parse a dimension such as `64rem` into `[64, 'rem']`. export const dimensions = new DefaultMap((input) => { - let match = /^(? -?(?:\d*\.)?\d+)(? [a-z]+|%)$/i.exec(input) + let match = DIMENSION_REGEX.exec(input) if (!match) return null let value = match.groups?.value diff --git a/packages/@tailwindcss-upgrade/src/utils/replace-object.ts b/packages/tailwindcss/src/utils/replace-object.ts similarity index 100% rename from packages/@tailwindcss-upgrade/src/utils/replace-object.ts rename to packages/tailwindcss/src/utils/replace-object.ts From efe084b7e72c5692b4b9faa466746aad91937a7b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Oct 2025 12:46:37 +0200 Subject: [PATCH 030/431] Improve performance of cloning AST nodes (#19067) This PR improves the performance of when we need to clone some AST nodes. We have a few places where we clone `Candidate`, `Variant` and CSS `AST` nodes. Right now we use `structuredClone`, which works, but it is a generic solution. However, we do know the exact structure of these AST nodes, so we can write specialized clone functions that are much faster. ## Test plan 1. All the tests still pass with this change 2. The performance is better: ``` cloneCandidate - src/candidate.bench.ts > Candidate cloning 1.72x faster than cloneCandidate (spread) 74.03x faster than structuredClone cloneAstNode() - src/ast.bench.ts > Cloning AST nodes 1.15x faster than cloneAstNode (with spread) 33.54x faster than structuredClone() ``` Ready for review, but should be merged after #19059 --- .../src/codemods/template/candidates.ts | 4 +- .../template/migrate-arbitrary-variants.ts | 3 +- .../migrate-automatic-var-injection.ts | 5 +- .../migrate-camelcase-in-named-value.ts | 3 +- .../migrate-legacy-arbitrary-values.ts | 4 +- .../template/migrate-legacy-classes.ts | 4 +- .../migrate-modernize-arbitrary-values.ts | 4 +- packages/tailwindcss/src/apply.ts | 4 +- packages/tailwindcss/src/ast.bench.ts | 48 ++++++++-- packages/tailwindcss/src/ast.ts | 62 ++++++++++++ packages/tailwindcss/src/candidate.bench.ts | 88 +++++++++++++++-- packages/tailwindcss/src/candidate.ts | 96 +++++++++++++++++++ .../src/canonicalize-candidates.ts | 6 +- .../src/compat/apply-config-to-theme.ts | 2 +- packages/tailwindcss/src/compat/container.ts | 4 +- packages/tailwindcss/src/compat/plugin-api.ts | 4 +- packages/tailwindcss/src/utilities.ts | 5 +- packages/tailwindcss/src/variants.ts | 3 +- 18 files changed, 312 insertions(+), 37 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts index f873c1e10..88b1e4e03 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/candidates.ts @@ -1,5 +1,5 @@ import { Scanner } from '@tailwindcss/oxide' -import type { Candidate } from '../../../../tailwindcss/src/candidate' +import { cloneCandidate, type Candidate } from '../../../../tailwindcss/src/candidate' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' export async function extractRawCandidates( @@ -18,7 +18,7 @@ export async function extractRawCandidates( // Create a basic stripped candidate without variants or important flag export function baseCandidate (candidate: T) { - let base = structuredClone(candidate) + let base = cloneCandidate(candidate) base.important = false base.variants = [] diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts index fb1e9948e..9d8b176bd 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts @@ -1,3 +1,4 @@ +import { cloneCandidate } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { @@ -22,7 +23,7 @@ export function migrateArbitraryVariants( // The below logic makes use of mutation. Since candidates in the // DesignSystem are cached, we can't mutate them directly. - let candidate = structuredClone(readonlyCandidate) as Writable + let candidate = cloneCandidate(readonlyCandidate) as Writable for (let [variant] of walkVariants(candidate)) { if (variant.kind === 'compound') continue diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts index 586d8091d..13b2c0449 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-automatic-var-injection.ts @@ -1,7 +1,8 @@ import { walk, WalkAction } from '../../../../tailwindcss/src/ast' -import { type Candidate, type Variant } from '../../../../tailwindcss/src/candidate' +import { cloneCandidate, type Candidate, type Variant } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import type { Writable } from '../../../../tailwindcss/src/types' import * as ValueParser from '../../../../tailwindcss/src/value-parser' export function migrateAutomaticVarInjection( @@ -12,7 +13,7 @@ export function migrateAutomaticVarInjection( for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { // The below logic makes extended use of mutation. Since candidates in the // DesignSystem are cached, we can't mutate them directly. - let candidate = structuredClone(readonlyCandidate) as Candidate + let candidate = cloneCandidate(readonlyCandidate) as Writable let didChange = false diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-camelcase-in-named-value.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-camelcase-in-named-value.ts index 2214a8366..9cfbc7de0 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-camelcase-in-named-value.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-camelcase-in-named-value.ts @@ -1,3 +1,4 @@ +import { cloneCandidate } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import * as version from '../../utils/version' @@ -15,7 +16,7 @@ export function migrateCamelcaseInNamedValue( for (let candidate of designSystem.parseCandidate(rawCandidate)) { if (candidate.kind !== 'functional') continue - let clone = structuredClone(candidate) + let clone = cloneCandidate(candidate) let didChange = false if ( diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts index 89afbb21e..2bd7d2fd5 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-arbitrary-values.ts @@ -1,4 +1,4 @@ -import { parseCandidate } from '../../../../tailwindcss/src/candidate' +import { cloneCandidate, parseCandidate } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { segment } from '../../../../tailwindcss/src/utils/segment' @@ -9,7 +9,7 @@ export function migrateLegacyArbitraryValues( rawCandidate: string, ): string { for (let candidate of parseCandidate(rawCandidate, designSystem)) { - let clone = structuredClone(candidate) + let clone = cloneCandidate(candidate) let changed = false // Convert commas to spaces. E.g.: [auto,1fr] to [auto_1fr] diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts index fa425574c..614b4017b 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-legacy-classes.ts @@ -1,7 +1,7 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import path from 'node:path' import url from 'node:url' -import type { Candidate } from '../../../../tailwindcss/src/candidate' +import { cloneCandidate, type Candidate } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' @@ -100,7 +100,7 @@ export async function migrateLegacyClasses( // Re-apply the variants and important flag from the original candidate. // E.g.: `hover:blur!` -> `blur` -> `blur-sm` -> `hover:blur-sm!` - let newCandidate = structuredClone(newBaseCandidate) as Candidate + let newCandidate = cloneCandidate(newBaseCandidate) as Candidate newCandidate.variants = candidate.variants newCandidate.important = candidate.important diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts index cafae7aed..91718b323 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.ts @@ -1,4 +1,4 @@ -import { parseCandidate } from '../../../../tailwindcss/src/candidate' +import { cloneCandidate, parseCandidate } from '../../../../tailwindcss/src/candidate' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { replaceObject } from '../../../../tailwindcss/src/utils/replace-object' @@ -10,7 +10,7 @@ export function migrateModernizeArbitraryValues( rawCandidate: string, ): string { for (let candidate of parseCandidate(rawCandidate, designSystem)) { - let clone = structuredClone(candidate) + let clone = cloneCandidate(candidate) let changed = false for (let [variant] of walkVariants(clone)) { diff --git a/packages/tailwindcss/src/apply.ts b/packages/tailwindcss/src/apply.ts index 526a5e000..1c4223e0f 100644 --- a/packages/tailwindcss/src/apply.ts +++ b/packages/tailwindcss/src/apply.ts @@ -1,5 +1,5 @@ import { Features } from '.' -import { rule, toCss, walk, WalkAction, type AstNode } from './ast' +import { cloneAstNode, rule, toCss, walk, WalkAction, type AstNode } from './ast' import { compileCandidates } from './compile' import type { DesignSystem } from './design-system' import type { SourceLocation } from './source-maps/source' @@ -249,7 +249,7 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) { let candidate = compiled.nodeSorting.get(node)?.candidate let candidateOffset = candidate ? candidateOffsets[candidate] : undefined - node = structuredClone(node) + node = cloneAstNode(node) if (!src || !candidate || candidateOffset === undefined) { // While the original nodes may have come from an `@utility` we still diff --git a/packages/tailwindcss/src/ast.bench.ts b/packages/tailwindcss/src/ast.bench.ts index a2d8b2892..034faee27 100644 --- a/packages/tailwindcss/src/ast.bench.ts +++ b/packages/tailwindcss/src/ast.bench.ts @@ -1,5 +1,5 @@ -import { bench } from 'vitest' -import { toCss } from './ast' +import { bench, describe } from 'vitest' +import { cloneAstNode, toCss, type AstNode } from './ast' import * as CSS from './css-parser' const css = String.raw @@ -19,10 +19,46 @@ const input = css` ` const ast = CSS.parse(input) -bench('toCss', () => { - toCss(ast) +describe('AST to CSS', () => { + bench('toCss', () => { + toCss(ast) + }) + + bench('toCss with source maps', () => { + toCss(ast, true) + }) }) -bench('toCss with source maps', () => { - toCss(ast, true) +describe('Cloning AST nodes', () => { + bench('cloneAstNode()', () => { + ast.map(cloneAstNode) + }) + + bench('cloneAstNode (with spread)', () => { + ast.map(cloneAstNodeSpread) + }) + + bench('structuredClone()', () => { + structuredClone(ast) + }) }) + +function cloneAstNodeSpread (node: T): T { + switch (node.kind) { + case 'rule': + case 'at-rule': + case 'at-root': + return { ...node, nodes: node.nodes.map(cloneAstNodeSpread) } + + case 'context': + return { ...node, context: { ...node.context }, nodes: node.nodes.map(cloneAstNodeSpread) } + + case 'declaration': + case 'comment': + return { ...node } + + default: + node satisfies never + throw new Error(`Unknown node kind: ${(node as any).kind}`) + } +} diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 1ca7b7d62..e57e1c2a3 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -122,6 +122,68 @@ export function atRoot(nodes: AstNode[]): AtRoot { } } +export function cloneAstNode (node: T): T { + switch (node.kind) { + case 'rule': + return { + kind: node.kind, + selector: node.selector, + nodes: node.nodes.map(cloneAstNode), + src: node.src, + dst: node.dst, + } satisfies StyleRule as T + + case 'at-rule': + return { + kind: node.kind, + name: node.name, + params: node.params, + nodes: node.nodes.map(cloneAstNode), + src: node.src, + dst: node.dst, + } satisfies AtRule as T + + case 'at-root': + return { + kind: node.kind, + nodes: node.nodes.map(cloneAstNode), + src: node.src, + dst: node.dst, + } satisfies AtRoot as T + + case 'context': + return { + kind: node.kind, + context: { ...node.context }, + nodes: node.nodes.map(cloneAstNode), + src: node.src, + dst: node.dst, + } satisfies Context as T + + case 'declaration': + return { + kind: node.kind, + property: node.property, + value: node.value, + important: node.important, + src: node.src, + dst: node.dst, + } satisfies Declaration as T + + case 'comment': + return { + kind: node.kind, + value: node.value, + src: node.src, + dst: node.dst, + } satisfies Comment as T + + default: + node satisfies never + throw new Error(`Unknown node kind: ${(node as any).kind}`) + } +} + export const enum WalkAction { /** Continue walking, which is the default */ Continue, diff --git a/packages/tailwindcss/src/candidate.bench.ts b/packages/tailwindcss/src/candidate.bench.ts index 5b7171432..7ac0dbb0a 100644 --- a/packages/tailwindcss/src/candidate.bench.ts +++ b/packages/tailwindcss/src/candidate.bench.ts @@ -1,6 +1,6 @@ import { Scanner } from '@tailwindcss/oxide' -import { bench } from 'vitest' -import { parseCandidate } from './candidate' +import { bench, describe } from 'vitest' +import { cloneCandidate, parseCandidate, type Candidate, type Variant } from './candidate' import { buildDesignSystem } from './design-system' import { Theme } from './theme' @@ -8,13 +8,87 @@ import { Theme } from './theme' const root = process.env.FOLDER || process.cwd() // Auto content detection -const scanner = new Scanner({ sources: [{ base: root, pattern: '**/*' }] }) +const scanner = new Scanner({ sources: [{ base: root, pattern: '**/*', negated: false }] }) const candidates = scanner.scan() const designSystem = buildDesignSystem(new Theme()) -bench('parseCandidate', () => { - for (let candidate of candidates) { - Array.from(parseCandidate(candidate, designSystem)) - } +describe('parsing', () => { + bench('parseCandidate', () => { + for (let candidate of candidates) { + Array.from(parseCandidate(candidate, designSystem)) + } + }) }) + +describe('Candidate cloning', async () => { + let parsedCanddiates = candidates.flatMap((candidate) => + Array.from(parseCandidate(candidate, designSystem)), + ) + + bench('structuredClone', () => { + for (let candidate of parsedCanddiates) { + structuredClone(candidate) + } + }) + + bench('cloneCandidate', () => { + for (let candidate of parsedCanddiates) { + cloneCandidate(candidate) + } + }) + + bench('cloneCandidate (spread)', () => { + for (let candidate of parsedCanddiates) { + cloneCandidateSpread(candidate) + } + }) +}) + +function cloneCandidateSpread(candidate: Candidate): Candidate { + switch (candidate.kind) { + case 'arbitrary': + return { + ...candidate, + modifier: candidate.modifier ? { ...candidate.modifier } : null, + variants: candidate.variants.map(cloneVariantSpread), + } + + case 'static': + return { ...candidate, variants: candidate.variants.map(cloneVariantSpread) } + + case 'functional': + return { + ...candidate, + value: candidate.value ? { ...candidate.value } : null, + modifier: candidate.modifier ? { ...candidate.modifier } : null, + variants: candidate.variants.map(cloneVariantSpread), + } + + default: + candidate satisfies never + throw new Error('Unknown candidate kind') + } +} + +function cloneVariantSpread(variant: Variant): Variant { + switch (variant.kind) { + case 'arbitrary': + case 'static': + return { ...variant } + + case 'functional': + return { ...variant, modifier: variant.modifier ? { ...variant.modifier } : null } + + case 'compound': + return { + ...variant, + variant: cloneVariantSpread(variant.variant), + modifier: variant.modifier ? { ...variant.modifier } : null, + } + + default: + variant satisfies never + throw new Error('Unknown variant kind') + } +} diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index d169ba758..a9ba2d6e2 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -215,6 +215,102 @@ export type Candidate = raw: string } +export function cloneCandidate (candidate: T): T { + switch (candidate.kind) { + case 'arbitrary': + return { + kind: candidate.kind, + property: candidate.property, + value: candidate.value, + modifier: candidate.modifier + ? { kind: candidate.modifier.kind, value: candidate.modifier.value } + : null, + variants: candidate.variants.map(cloneVariant), + important: candidate.important, + raw: candidate.raw, + } satisfies Extract as T + + case 'static': + return { + kind: candidate.kind, + root: candidate.root, + variants: candidate.variants.map(cloneVariant), + important: candidate.important, + raw: candidate.raw, + } satisfies Extract as T + + case 'functional': + return { + kind: candidate.kind, + root: candidate.root, + value: candidate.value + ? candidate.value.kind === 'arbitrary' + ? { + kind: candidate.value.kind, + dataType: candidate.value.dataType, + value: candidate.value.value, + } + : { + kind: candidate.value.kind, + value: candidate.value.value, + fraction: candidate.value.fraction, + } + : null, + modifier: candidate.modifier + ? { kind: candidate.modifier.kind, value: candidate.modifier.value } + : null, + variants: candidate.variants.map(cloneVariant), + important: candidate.important, + raw: candidate.raw, + } satisfies Extract as T + + default: + candidate satisfies never + throw new Error('Unknown candidate kind') + } +} + +export function cloneVariant (variant: T): T { + switch (variant.kind) { + case 'arbitrary': + return { + kind: variant.kind, + selector: variant.selector, + relative: variant.relative, + } satisfies Extract as T + + case 'static': + return { kind: variant.kind, root: variant.root } satisfies Extract< + Variant, + { kind: 'static' } + > as T + + case 'functional': + return { + kind: variant.kind, + root: variant.root, + value: variant.value ? { kind: variant.value.kind, value: variant.value.value } : null, + modifier: variant.modifier + ? { kind: variant.modifier.kind, value: variant.modifier.value } + : null, + } satisfies Extract as T + + case 'compound': + return { + kind: variant.kind, + root: variant.root, + variant: cloneVariant(variant.variant), + modifier: variant.modifier + ? { kind: variant.modifier.kind, value: variant.modifier.value } + : null, + } satisfies Extract as T + + default: + variant satisfies never + throw new Error('Unknown variant kind') + } +} + export function* parseCandidate(input: string, designSystem: DesignSystem): Iterable { // hover:focus:underline // ^^^^^ ^^^^^^ -> Variants diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index ba6910552..ec07b9511 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -1,5 +1,7 @@ import * as AttributeSelectorParser from './attribute-selector-parser' import { + cloneCandidate, + cloneVariant, printModifier, type Candidate, type CandidateModifier, @@ -107,7 +109,7 @@ const canonicalizeVariantCache = new DefaultMap((ds: DesignSystem) => { for (let current of replacement.splice(0)) { // A single variant can result in multiple variants, e.g.: // `[&>[data-selected]]:flex` → `*:data-selected:flex` - let result = fn(ds, structuredClone(current)) + let result = fn(ds, cloneVariant(current)) if (Array.isArray(result)) { replacement.push(...result) continue @@ -134,7 +136,7 @@ const UTILITY_CANONICALIZATIONS = [ const canonicalizeUtilityCache = new DefaultMap((ds: DesignSystem) => { return new DefaultMap((rawCandidate: string): string => { for (let readonlyCandidate of ds.parseCandidate(rawCandidate)) { - let replacement = structuredClone(readonlyCandidate) as Writable + let replacement = cloneCandidate(readonlyCandidate) as Writable for (let fn of UTILITY_CANONICALIZATIONS) { replacement = fn(ds, replacement) diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts index c957d3209..a430f9faa 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts @@ -153,7 +153,7 @@ export function keyPathToCssProperty(path: string[]) { // The legacy container component config should not be included in the Theme if (path[0] === 'container') return null - path = structuredClone(path) + path = path.slice() if (path[0] === 'animation') path[0] = 'animate' if (path[0] === 'aspectRatio') path[0] = 'aspect' diff --git a/packages/tailwindcss/src/compat/container.ts b/packages/tailwindcss/src/compat/container.ts index 266d101b0..98f23ce7a 100644 --- a/packages/tailwindcss/src/compat/container.ts +++ b/packages/tailwindcss/src/compat/container.ts @@ -1,4 +1,4 @@ -import { atRule, decl, type AstNode, type AtRule } from '../ast' +import { atRule, cloneAstNode, decl, type AstNode, type AtRule } from '../ast' import type { DesignSystem } from '../design-system' import { compareBreakpoints } from '../utils/compare-breakpoints' import type { ResolvedConfig } from './config/types' @@ -16,7 +16,7 @@ export function registerContainerCompat(userConfig: ResolvedConfig, designSystem return } - designSystem.utilities.static('container', () => structuredClone(rules)) + designSystem.utilities.static('container', () => rules.map(cloneAstNode)) } export function buildCustomContainerUtilityRules( diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index f5444cf56..4aca5feec 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -1,6 +1,6 @@ import type { Features } from '..' import { substituteAtApply } from '../apply' -import { atRule, decl, rule, walk, type AstNode } from '../ast' +import { atRule, cloneAstNode, decl, rule, walk, type AstNode } from '../ast' import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candidate' import { substituteFunctions } from '../css-functions' import * as CSS from '../css-parser' @@ -329,7 +329,7 @@ export function buildPluginApi({ } designSystem.utilities.static(className, (candidate) => { - let clonedAst = structuredClone(ast) + let clonedAst = ast.map(cloneAstNode) replaceNestedClassNameReferences(clonedAst, className, candidate.raw) featuresRef.current |= substituteAtApply(clonedAst, designSystem) return clonedAst diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index efe7b3dc6..0ef9907bb 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -1,6 +1,7 @@ import { atRoot, atRule, + cloneAstNode, decl, rule, styleRule, @@ -6008,7 +6009,7 @@ export function createCssUtility(node: AtRule) { }) designSystem.utilities.functional(name.slice(0, -2), (candidate) => { - let atRule = structuredClone(node) + let atRule = cloneAstNode(node) let value = candidate.value let modifier = candidate.modifier @@ -6176,7 +6177,7 @@ export function createCssUtility(node: AtRule) { if (IS_VALID_STATIC_UTILITY_NAME.test(name)) { return (designSystem: DesignSystem) => { - designSystem.utilities.static(name, () => structuredClone(node.nodes)) + designSystem.utilities.static(name, () => node.nodes.map(cloneAstNode)) } } diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 4e5c46894..0008e1c77 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -3,6 +3,7 @@ import { WalkAction, atRoot, atRule, + cloneAstNode, decl, rule, styleRule, @@ -100,7 +101,7 @@ export class Variants { this.static( name, (r) => { - let body = structuredClone(ast) + let body = ast.map(cloneAstNode) if (usesAtVariant) substituteAtVariant(body, designSystem) substituteAtSlot(body, r.nodes) r.nodes = body From 0c8d881f0ea2c695d8dace64693a37fd0af0a3d8 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Oct 2025 15:47:22 +0200 Subject: [PATCH 031/431] Improve percentage canonicalization (#19072) This PR improves the canonicalization of percentage values such that `[.1]`, `[.10]`, `[10%]` and `[10.0%]` are all treated as the same value. Right now we're only focusing on percentages. We can likely do this for all numbers, but I'm a little afraid of places where you can have multiple numbers separated by multiple dots (think SVGs). ## Test plan 1. Added more tests to cover the new cases. 2. Tested it in a local test project, where you can see the normalization in action. --- .../tailwindcss/src/canonicalize-candidates.test.ts | 4 ++++ packages/tailwindcss/src/canonicalize-candidates.ts | 6 +++++- packages/tailwindcss/src/signatures.ts | 13 +++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 811de77f5..0ee92a7d1 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -254,6 +254,10 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['[color:var(--color-red-500)]/[25%]', 'text-red-500/25'], ['[color:var(--color-red-500)]/[100%]', 'text-red-500'], ['[color:var(--color-red-500)]/100', 'text-red-500'], + ['[color:var(--color-red-500)]/[10%]', 'text-red-500/10'], + ['[color:var(--color-red-500)]/[10.0%]', 'text-red-500/10'], + ['[color:var(--color-red-500)]/[.1]', 'text-red-500/10'], + ['[color:var(--color-red-500)]/[.10]', 'text-red-500/10'], // No need for `/50` because that's already encoded in the `--color-primary` // value ['[color:oklch(62.3%_0.214_259.815)]/50', 'text-primary'], diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index ec07b9511..8f3312194 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -1469,7 +1469,11 @@ function optimizeModifier(designSystem: DesignSystem, candidate: Candidate): Can { let newModifier: NamedUtilityValue = { kind: 'named', - value: modifier.value.endsWith('%') ? modifier.value.slice(0, -1) : modifier.value, + value: modifier.value.endsWith('%') + ? modifier.value.includes('.') + ? `${Number(modifier.value.slice(0, -1))}` + : modifier.value.slice(0, -1) + : modifier.value, fraction: null, } diff --git a/packages/tailwindcss/src/signatures.ts b/packages/tailwindcss/src/signatures.ts index 89a5edde4..80d8f2948 100644 --- a/packages/tailwindcss/src/signatures.ts +++ b/packages/tailwindcss/src/signatures.ts @@ -9,6 +9,8 @@ import { dimensions } from './utils/dimensions' import { isValidSpacingMultiplier } from './utils/infer-data-type' import * as ValueParser from './value-parser' +const FLOATING_POINT_PERCENTAGE = /\d*\.\d+(?:[eE][+-]?\d+)?%/g + // Given a utility, compute a signature that represents the utility. The // signature will be a normalised form of the generated CSS for the utility, or // a unique symbol if the utility is not valid. The class in the selector will @@ -59,6 +61,17 @@ export const computeUtilitySignature = new DefaultMap< if (node.value === undefined || node.property === '--tw-sort') { replaceWith([]) } + + // Normalize percentages by removing unnecessary dots and zeros. + // + // E.g.: `50.0%` → `50%` + else if (node.value.includes('%')) { + FLOATING_POINT_PERCENTAGE.lastIndex = 0 + node.value = node.value.replaceAll( + FLOATING_POINT_PERCENTAGE, + (match) => `${Number(match.slice(0, -1))}%`, + ) + } } // Replace special nodes with its children From 561983d7e526c58ca5fdbdc5fde12623622fd9e3 Mon Sep 17 00:00:00 2001 From: Robin Malfait
Date: Thu, 9 Oct 2025 11:58:51 +0200 Subject: [PATCH 032/431] Suppress warnings when using `:deep`, `:slotted` and `:global` (#19094) This PR ignores warnings related to `:deep`, `:slotted` and `:global` used by frameworks like Vue (see: https://vuejs.org/api/sfc-css-features#deep-selectors). ## Test plan Used a `:deep()` selector in a test project (Catalyst). ```diff diff --git a/templates/catalyst/src/tailwind.css b/templates/catalyst/src/tailwind.css index 79887e67..2acb749c 100644 --- a/templates/catalyst/src/tailwind.css +++ b/templates/catalyst/src/tailwind.css @@ -9,3 +9,7 @@ --font-sans: Inter, sans-serif; --font-sans--font-feature-settings: 'cv11'; } + +:deep(.foo) { + color: red; +} ``` Before: After:
Fixes: https://github.com/tailwindlabs/tailwindcss/pull/18918#issuecomment-3384928613 --- CHANGELOG.md | 1 + packages/@tailwindcss-node/src/optimize.ts | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9ccfdab..98231ff23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix Safari devtools rendering issue due to `color-mix` fallback ([#19069](https://github.com/tailwindlabs/tailwindcss/pull/19069)) +- Suppress Lightning CSS warnings about `:deep`, `:slotted` and `:global` ([#19094](https://github.com/tailwindlabs/tailwindcss/pull/19094)) ## [4.1.14] - 2025-10-01 diff --git a/packages/@tailwindcss-node/src/optimize.ts b/packages/@tailwindcss-node/src/optimize.ts index 0fc752102..6e60f8b87 100644 --- a/packages/@tailwindcss-node/src/optimize.ts +++ b/packages/@tailwindcss-node/src/optimize.ts @@ -60,6 +60,17 @@ export function optimize( let result = optimize(Buffer.from(input), map) map = result.map?.toString() + result.warnings = result.warnings.filter((warning) => { + // Ignore warnings about unknown pseudo-classes as they are likely caused + // by the use of `:deep()`, `:slotted()`, and `:global()` which are not + // standard CSS but are commonly used in frameworks like Vue. + if (/'(deep|slotted|global)' is not recognized as a valid pseudo-/.test(warning.message)) { + return false + } + + return true + }) + // Because of `errorRecovery: true`, there could be warnings, so let's let the // user know about them. if (process.env.NODE_ENV !== 'test' && result.warnings.length > 0) { From 0c14df1a1522689e94c06e537ee8b7c15fa97666 Mon Sep 17 00:00:00 2001 From: Robin Malfait
Date: Thu, 9 Oct 2025 19:10:32 +0200 Subject: [PATCH 033/431] =?UTF-8?q?Fix=20resolving=20colors=20via=20`theme?= =?UTF-8?q?(=E2=80=A6)`=20in=20compat=20mode=20with=20nested=20objects=20(?= =?UTF-8?q?#19097)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes an issue when loading (nested) colors from a config file and later referencing it via the `theme(…)` function in CSS. Given a config like this: ```js module.exports = { theme: { colors: { foo: 'var(--foo-foo)', 'foo-bar': 'var(--foo-foo-bar)', }, }, } ``` We internally map this into the design system. The issue here is that the `foo` and `foo-bar` are overlapping and it behaves more like this: ```js { foo: { DEFAULT: 'var(--foo-foo)', bar: 'var(--foo-foo-bar)' }, } ``` So while we can easily resolve `colors.foo-bar`, the `colors.foo` would result in the object with a `DEFAULT` key. This PR solves that by using the `DEFAULT` key if we end up with an object that has it. If you end up resolving an object (`theme(colors)`) then the behavior is unchanged. ## Test plan 1. Added a test based on the config in the issue (which failed before this fix). 2. Also simplified the test case after identifying the problem (with the `DEFAULT` key). Fixes: #19091 --- CHANGELOG.md | 1 + .../src/codemods/css/migrate-media-screen.ts | 2 +- .../src/compat/apply-compat-hooks.ts | 26 +++++-- .../tailwindcss/src/compat/config.test.ts | 74 ++++++++++++++++++- 4 files changed, 93 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98231ff23..a84ace364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix Safari devtools rendering issue due to `color-mix` fallback ([#19069](https://github.com/tailwindlabs/tailwindcss/pull/19069)) - Suppress Lightning CSS warnings about `:deep`, `:slotted` and `:global` ([#19094](https://github.com/tailwindlabs/tailwindcss/pull/19094)) +- Fix resolving theme keys when starting with the name of another theme key in JS configs and plugins ([#19097](https://github.com/tailwindlabs/tailwindcss/pull/19097)) ## [4.1.14] - 2025-10-01 diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-media-screen.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-media-screen.ts index f4526209d..c10e9ee99 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-media-screen.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-media-screen.ts @@ -16,7 +16,7 @@ export function migrateMediaScreen({ if (!designSystem || !userConfig) return let { resolvedConfig } = resolveConfig(designSystem, [ - { base: '', config: userConfig, reference: false }, + { base: '', config: userConfig, reference: false, src: undefined }, ]) let screens = resolvedConfig?.theme?.screens || {} diff --git a/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts index 78c7d7078..2f88ed800 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -309,15 +309,29 @@ function upgradeToFullPluginSupport({ let resolvedValue = sharedPluginApi.theme(path, undefined) + // When a tuple is returned, return the first element if (Array.isArray(resolvedValue) && resolvedValue.length === 2) { - // When a tuple is returned, return the first element return resolvedValue[0] - } else if (Array.isArray(resolvedValue)) { - // Arrays get serialized into a comma-separated lists + } + + // Arrays get serialized into a comma-separated lists + else if (Array.isArray(resolvedValue)) { return resolvedValue.join(', ') - } else if (typeof resolvedValue === 'string') { - // Otherwise only allow string values here, objects (and namespace maps) - // are treated as non-resolved values for the CSS `theme()` function. + } + + // If we're dealing with an object that has the `DEFAULT` key, return the + // default value + else if ( + typeof resolvedValue === 'object' && + resolvedValue !== null && + 'DEFAULT' in resolvedValue + ) { + return resolvedValue.DEFAULT + } + + // Otherwise only allow string values here, objects (and namespace maps) + // are treated as non-resolved values for the CSS `theme()` function. + else if (typeof resolvedValue === 'string') { return resolvedValue } } diff --git a/packages/tailwindcss/src/compat/config.test.ts b/packages/tailwindcss/src/compat/config.test.ts index 196115d63..8256f663c 100644 --- a/packages/tailwindcss/src/compat/config.test.ts +++ b/packages/tailwindcss/src/compat/config.test.ts @@ -12,7 +12,7 @@ test('Config files can add content', async () => { ` let compiler = await compile(input, { - loadModule: async () => ({ module: { content: ['./file.txt'] }, base: '/root' }), + loadModule: async () => ({ module: { content: ['./file.txt'] }, base: '/root', path: '' }), }) expect(compiler.sources).toEqual([{ base: '/root', pattern: './file.txt', negated: false }]) @@ -25,7 +25,7 @@ test('Config files can change dark mode (media)', async () => { ` let compiler = await compile(input, { - loadModule: async () => ({ module: { darkMode: 'media' }, base: '/root' }), + loadModule: async () => ({ module: { darkMode: 'media' }, base: '/root', path: '' }), }) expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(` @@ -45,7 +45,7 @@ test('Config files can change dark mode (selector)', async () => { ` let compiler = await compile(input, { - loadModule: async () => ({ module: { darkMode: 'selector' }, base: '/root' }), + loadModule: async () => ({ module: { darkMode: 'selector' }, base: '/root', path: '' }), }) expect(compiler.build(['dark:underline'])).toMatchInlineSnapshot(` @@ -68,6 +68,7 @@ test('Config files can change dark mode (variant)', async () => { loadModule: async () => ({ module: { darkMode: ['variant', '&:where(:not(.light))'] }, base: '/root', + path: '', }), }) @@ -101,6 +102,7 @@ test('Config files can add plugins', async () => { ], }, base: '/root', + path: '', }), }) @@ -128,6 +130,7 @@ test('Plugins loaded from config files can contribute to the config', async () = ], }, base: '/root', + path: '', }), }) @@ -157,6 +160,7 @@ test('Config file presets can contribute to the config', async () => { ], }, base: '/root', + path: '', }), }) @@ -198,6 +202,7 @@ test('Config files can affect the theme', async () => { ], }, base: '/root', + path: '', }), }) @@ -212,6 +217,48 @@ test('Config files can affect the theme', async () => { `) }) +// https://github.com/tailwindlabs/tailwindcss/issues/19091 +test('Accessing a default color if a sub-color exists via CSS should work as expected', async () => { + let input = css` + @tailwind utilities; + @config "./config.js"; + + .example { + color: theme('colors.foo-bar'); + border-color: theme('colors.foo'); + } + ` + + let compiler = await compile(input, { + loadModule: async () => ({ + module: { + theme: { + // Internally this object gets converted to something like: + // ``` + // { + // foo: { DEFAULT: 'var(--foo-foo)', bar: 'var(--foo-foo-bar)' }, + // } + // ``` + colors: { + foo: 'var(--foo-foo)', + 'foo-bar': 'var(--foo-foo-bar)', + }, + }, + }, + base: '/root', + path: '', + }), + }) + + expect(compiler.build([])).toMatchInlineSnapshot(` + ".example { + color: var(--foo-foo-bar); + border-color: var(--foo-foo); + } + " + `) +}) + test('Variants in CSS overwrite variants from plugins', async () => { let input = css` @tailwind utilities; @@ -231,6 +278,7 @@ test('Variants in CSS overwrite variants from plugins', async () => { ], }, base: '/root', + path: '', }), }) @@ -317,6 +365,7 @@ describe('theme callbacks', () => { ], } satisfies Config, base: '/root', + path: '', }), }) @@ -391,6 +440,7 @@ describe('theme overrides order', () => { }, }, base: '/root', + path: '', }), }) @@ -442,6 +492,7 @@ describe('theme overrides order', () => { }, } satisfies Config, base: '/root', + path: '', } } else { return { @@ -460,6 +511,7 @@ describe('theme overrides order', () => { ) }), base: '/root', + path: '', } } }, @@ -562,6 +614,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -596,6 +649,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -631,6 +685,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -669,6 +724,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -708,6 +764,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -745,6 +802,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -779,6 +837,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -806,6 +865,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -840,6 +900,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -875,6 +936,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -913,6 +975,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -952,6 +1015,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -989,6 +1053,7 @@ describe('default font family compatibility', () => { }, }, base: '/root', + path: '', }), }) @@ -1021,6 +1086,7 @@ test('creates variants for `data`, `supports`, and `aria` theme options at the s }, }, base: '/root', + path: '', }), }) @@ -1113,6 +1179,7 @@ test('merges css breakpoints with js config screens', async () => { }, }, base: '/root', + path: '', }), }) @@ -1596,6 +1663,7 @@ test('handles setting theme keys to null', async () => { }, }, base: '/root', + path: '', } }, }, From 3aadba7cc117760182c862aced7c66b90e05a087 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 9 Oct 2025 19:30:01 +0200 Subject: [PATCH 034/431] Move modifier of `not-*`, `has-*`, and `in-*` variant to sub variant (#19100) This PR fixes an issue where a compound variant with a modifier such as `not-group-hover/name:flex` would not generate anything because the `/name` modifier belongs to the `not` variant, and not the compounded `group-hover` variant. This PR is a **workaround** (and definitely not perfect) by special casing the `not`, `has`, and `in` variants such that their modifiers are moved internally to the sub variant as-if the `/name` existed on `group-hover`. We don't do it for other compound variants such as `group` and `peer` because then `group-peer-focus/name:underline` would result in a breaking change: ```diff - .group-peer-focus\\/name\\:flex:is(:where(.group\\/name):is(:where(.peer):focus ~ *) *) + .group-peer-focus\/name\:flex:is(:where(.group):is(:where(.peer\/name):focus ~ *) *) ``` In case the diff is not clear, the name has moved: This is also a limited workaround, because if you need multiple modifiers it won't work. I would've loved to special case this _inside_ the `not`, `has`, and `in` code that handles these variants, but we handle the variants in a depth-first way, so by the time you are handling the `not` variant, the sub variant was already handled... In a perfect world, you can use something like `not-group/name-hover` but then it becomes unambiguous because is `name` the name, is `name-hover`? ## Test plan Added a new test that wouldn't generate anything before this fix. Fixes: #15772 --- CHANGELOG.md | 3 ++- packages/tailwindcss/src/candidate.test.ts | 5 +++++ packages/tailwindcss/src/candidate.ts | 7 +++++++ packages/tailwindcss/src/variants.test.ts | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a84ace364..52f712c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix Safari devtools rendering issue due to `color-mix` fallback ([#19069](https://github.com/tailwindlabs/tailwindcss/pull/19069)) -- Suppress Lightning CSS warnings about `:deep`, `:slotted` and `:global` ([#19094](https://github.com/tailwindlabs/tailwindcss/pull/19094)) +- Suppress Lightning CSS warnings about `:deep`, `:slotted`, and `:global` ([#19094](https://github.com/tailwindlabs/tailwindcss/pull/19094)) - Fix resolving theme keys when starting with the name of another theme key in JS configs and plugins ([#19097](https://github.com/tailwindlabs/tailwindcss/pull/19097)) +- Allow named groups in combination with `not-*`, `has-*`, and `in-*` ([#19100](https://github.com/tailwindlabs/tailwindcss/pull/19100)) ## [4.1.14] - 2025-10-01 diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts index dfc65ac73..3d4aa983b 100644 --- a/packages/tailwindcss/src/candidate.test.ts +++ b/packages/tailwindcss/src/candidate.test.ts @@ -2102,6 +2102,11 @@ const variants = [ // Handle special `@` variants. These shouldn't be printed as `@-` ['@xl:', '@xl:'], ['@[123px]:', '@[123px]:'], + + // Compound variants that forward modifiers + ['not-group-hover/name:', 'not-group-hover/name:'], + ['has-group-peer-hover/name:', 'has-group-peer-hover/name:'], + ['in-group-peer-hover/name:', 'in-group-peer-hover/name:'], ] let combinations: [string, string][] = [] diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index a9ba2d6e2..df5998d9e 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -806,6 +806,13 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia case 'compound': { if (value === null) return null + // Forward the modifier of the compound variants to its subVariant. + // This allows for `not-group-hover/name:flex` to work. + if (modifier && (root === 'not' || root === 'has' || root === 'in')) { + value = `${value}/${modifier}` + modifier = null + } + let subVariant = designSystem.parseVariant(value) if (subVariant === null) return null diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index f9d054a21..a2540e698 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -2586,6 +2586,24 @@ test('matchVariant sorts deterministically', async () => { } }) +test('move modifier of compound variant to sub-variant if its also a compound variant', async () => { + expect( + await run([ + 'not-group-focus/name:flex', + 'has-group-focus/name:flex', + 'in-group-focus/name:flex', + + // Keep the `name` on the `group`, don't move it to the `peer` because + // that would be a breaking change. + 'group-peer-focus/name:flex', + ]), + ).toMatchInlineSnapshot(` + ".not-group-focus\\/name\\:flex:not(:is(:where(.group\\/name):focus *)), .group-peer-focus\\/name\\:flex:is(:where(.group\\/name):is(:where(.peer):focus ~ *) *), :where(:is(:where(.group\\/name):focus *)) .in-group-focus\\/name\\:flex, .has-group-focus\\/name\\:flex:has(:is(:where(.group\\/name):focus *)) { + display: flex; + }" + `) +}) + test.each([ // These are style rules [['.foo'], Compounds.StyleRules], From 01d1e9825982894a293ccbf4d2c5e6a702b3868c Mon Sep 17 00:00:00 2001 From: Robin Malfait
Date: Thu, 9 Oct 2025 20:09:29 +0200 Subject: [PATCH 035/431] Canonicalization constant folding and handling zeros (#19095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main goal of this PR was to support canonicalization of zero like values. We essentially want to canonicalize `-mt-0` as `mt-0`, but also `mt-[0px]`, `mt-[0rem]`, and other length-like units to just `mt-0`. To do this, we had to handle 2 things: 1. We introduced some more constant folding, including making `0px` and `0rem` fold to `0`. We only do this for length units. We also normalize `-0`, `+0`, `-0.0` and so on to `0`. 2. While pre-computing utilities in our lookup table, we make sure that we prefer `mt-0` over `-mt-0` if both result in the same signature. Moved some of the constant folding logic into its own function and added a bunch of separate tests for it. ## Test plan Added more unit tests where we normalize different zero-like values to `0`. Running the canonicalization logic: ```js designSystem.canonicalizeCandidates([ '-m-0', '-m-[-0px]', '-m-[-0rem]', '-m-[0px]', '-m-[0rem]', 'm-0', 'm-[-0px]', 'm-[-0rem]', 'm-[0px]', 'm-[0rem]', 'm-[calc(var(--spacing)*0)]', 'm-[--spacing(0)]', 'm-[--spacing(0.0)]', 'm-[+0]', 'm-[-0]', '-m-[-0]', '-m-[+0]', ]) // → ['m-0'] ``` --------- Co-authored-by: Jordan Pittman --- CHANGELOG.md | 1 + .../codemods/template/migrate-theme-to-var.ts | 2 +- .../src/codemods/template/migrate.test.ts | 6 +- packages/tailwindcss/src/candidate.ts | 7 - .../src/canonicalize-candidates.test.ts | 24 ++++ .../src/canonicalize-candidates.ts | 2 +- .../src/constant-fold-declaration.test.ts | 94 +++++++++++++ .../src/constant-fold-declaration.ts | 126 ++++++++++++++++++ packages/tailwindcss/src/signatures.ts | 47 ++----- packages/tailwindcss/src/utils/dimensions.ts | 8 +- .../tailwindcss/src/utils/infer-data-type.ts | 2 +- packages/tailwindcss/src/value-parser.test.ts | 2 +- packages/tailwindcss/src/value-parser.ts | 96 ++++++++++++- 13 files changed, 362 insertions(+), 55 deletions(-) create mode 100644 packages/tailwindcss/src/constant-fold-declaration.test.ts create mode 100644 packages/tailwindcss/src/constant-fold-declaration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f712c87..69359fb1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Suppress Lightning CSS warnings about `:deep`, `:slotted`, and `:global` ([#19094](https://github.com/tailwindlabs/tailwindcss/pull/19094)) - Fix resolving theme keys when starting with the name of another theme key in JS configs and plugins ([#19097](https://github.com/tailwindlabs/tailwindcss/pull/19097)) - Allow named groups in combination with `not-*`, `has-*`, and `in-*` ([#19100](https://github.com/tailwindlabs/tailwindcss/pull/19100)) +- Upgrade: Canonicalize utilities containing `0` values ([#19095](https://github.com/tailwindlabs/tailwindcss/pull/19095)) ## [4.1.14] - 2025-10-01 diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts index aa3a5bb6f..d7113501f 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-theme-to-var.ts @@ -42,7 +42,7 @@ export function createConverter(designSystem: DesignSystem, { prettyPrint = fals } // If we see a `/`, we have a modifier - else if (child.kind === 'separator' && child.value.trim() === '/') { + else if (child.kind === 'word' && child.value === '/') { themeModifierCount += 1 return ValueParser.ValueWalkAction.Stop } diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts index 1d81f9c6b..24bb3d5ef 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.test.ts @@ -61,9 +61,9 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', // handle the `0px * -1` case which translates to `0px` not `-0px`. // // This translation is actually fine, because now, we will prefer the - // non-negative version first so we can replace `-mt-[0px]` with `mt-[0px]`. - ['mt-[0px]', 'mt-[0px]'], - ['-mt-[0px]', 'mt-[0px]'], + // non-negative version first so we can replace `-mt-[0px]` with `mt-0`. + ['mt-[0px]', 'mt-0'], + ['-mt-[0px]', 'mt-0'], // Shorthand CSS Variables should be converted to the new syntax, even if // the fallback contains functions. The fallback should also be migrated to diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index df5998d9e..23d706c8c 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -1049,13 +1049,6 @@ const printArbitraryValueCache = new DefaultMap ((input) => { drop.add(next) } - // The value parser handles `/` as a separator in some scenarios. E.g.: - // `theme(colors.red/50%)`. Because of this, we have to handle this case - // separately. - else if (node.kind === 'separator' && node.value.trim() === '/') { - node.value = '/' - } - // Leading and trailing whitespace else if (node.kind === 'separator' && node.value.length > 0 && node.value.trim() === '') { if (parentArray[0] === node || parentArray[parentArray.length - 1] === node) { diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 0ee92a7d1..b1ba62677 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -675,6 +675,30 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['grid-cols-[subgrid]', 'grid-cols-subgrid'], ['grid-rows-[subgrid]', 'grid-rows-subgrid'], + // Handle zeroes + ['m-[0]', 'm-0'], + ['m-[0px]', 'm-0'], + ['m-[0rem]', 'm-0'], + + ['-m-[0]', 'm-0'], + ['-m-[0px]', 'm-0'], + ['-m-[0rem]', 'm-0'], + + ['m-[-0]', 'm-0'], + ['m-[-0px]', 'm-0'], + ['m-[-0rem]', 'm-0'], + + ['-m-[-0]', 'm-0'], + ['-m-[-0px]', 'm-0'], + ['-m-[-0rem]', 'm-0'], + + ['[margin:0]', 'm-0'], + ['[margin:-0]', 'm-0'], + ['[margin:0px]', 'm-0'], + + // Not a length-unit, can't safely constant fold + ['[margin:0%]', 'm-[0%]'], + // Only 50-200% (inclusive) are valid: // https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch#percentage ['font-stretch-[50%]', 'font-stretch-50%'], diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 8f3312194..791da7844 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -264,7 +264,7 @@ const converterCache = new DefaultMap((ds: DesignSystem) => { } // If we see a `/`, we have a modifier - else if (child.kind === 'separator' && child.value.trim() === '/') { + else if (child.kind === 'word' && child.value === '/') { themeModifierCount += 1 return ValueParser.ValueWalkAction.Stop } diff --git a/packages/tailwindcss/src/constant-fold-declaration.test.ts b/packages/tailwindcss/src/constant-fold-declaration.test.ts new file mode 100644 index 000000000..1fdbbde81 --- /dev/null +++ b/packages/tailwindcss/src/constant-fold-declaration.test.ts @@ -0,0 +1,94 @@ +import { expect, it } from 'vitest' +import { constantFoldDeclaration } from './constant-fold-declaration' + +it.each([ + // Simple expression + ['calc(1 + 1)', '2'], + ['calc(3 - 2)', '1'], + ['calc(2 * 3)', '6'], + ['calc(8 / 2)', '4'], + + // Nested + ['calc(1 + calc(1 + 1))', '3'], + ['calc(3 - calc(1 + 2))', '0'], + ['calc(2 * calc(1 + 3))', '8'], + ['calc(8 / calc(2 + 2))', '2'], + ['calc(1 + (1 + 1))', '3'], + ['calc(3 - (1 + 2))', '0'], + ['calc(2 * (1 + 3))', '8'], + ['calc(8 / (2 + 2))', '2'], + + // With units + ['calc(1rem * 2)', '2rem'], + ['calc(2rem - 0.5rem)', '1.5rem'], + ['calc(3rem * 6)', '18rem'], + ['calc(5rem / 2)', '2.5rem'], + + // Nested partial evaluation + ['calc(calc(1 + 2) + 2rem)', 'calc(3 + 2rem)'], + + // Evaluation only handles two operands right now, this can change in the future + ['calc(1 + 2 + 3)', 'calc(1 + 2 + 3)'], +])('should constant fold `%s` into `%s`', (input, expected) => { + expect(constantFoldDeclaration(input)).toBe(expected) +}) + +it.each([ + ['calc(1rem * 2%)'], + ['calc(1rem * 2px)'], + ['calc(2rem - 6)'], + ['calc(3rem * 3dvw)'], + ['calc(3rem * 2dvh)'], + ['calc(5rem / 17px)'], +])('should not constant fold different units `%s`', (input) => { + expect(constantFoldDeclaration(input)).toBe(input) +}) + +it.each([ + ['calc(0 * 100vw)'], + ['calc(0 * calc(1 * 2))'], + ['calc(0 * var(--foo))'], + ['calc(0 * calc(var(--spacing) * 32))'], + + ['calc(100vw * 0)'], + ['calc(calc(1 * 2) * 0)'], + ['calc(var(--foo) * 0)'], + ['calc(calc(var(--spacing, 0.25rem) * 32) * 0)'], + ['calc(var(--spacing, 0.25rem) * -0)'], + ['calc(-0px * -1)'], + + // Zeroes + ['0px'], + ['0rem'], + ['0em'], + ['0dvh'], + ['-0'], + ['+0'], + ['-0.0rem'], + ['+0.00rem'], +])('should constant fold `%s` to `0`', (input) => { + expect(constantFoldDeclaration(input)).toBe('0') +}) + +it.each([ + ['0deg', '0deg'], + ['0rad', '0rad'], + ['0%', '0%'], + ['0turn', '0turn'], + ['0fr', '0fr'], + ['0ms', '0ms'], + ['0s', '0s'], + ['-0.0deg', '0deg'], + ['-0.0rad', '0rad'], + ['-0.0%', '0%'], + ['-0.0turn', '0turn'], + ['-0.0fr', '0fr'], + ['-0.0ms', '0ms'], + ['-0.0s', '0s'], +])('should not fold non-foldable units to `0`. Constant fold `%s` into `%s`', (input, expected) => { + expect(constantFoldDeclaration(input)).toBe(expected) +}) + +it('should not constant fold when dividing by `0`', () => { + expect(constantFoldDeclaration('calc(123rem / 0)')).toBe('calc(123rem / 0)') +}) diff --git a/packages/tailwindcss/src/constant-fold-declaration.ts b/packages/tailwindcss/src/constant-fold-declaration.ts new file mode 100644 index 000000000..fc2424ef7 --- /dev/null +++ b/packages/tailwindcss/src/constant-fold-declaration.ts @@ -0,0 +1,126 @@ +import { dimensions } from './utils/dimensions' +import { isLength } from './utils/infer-data-type' +import * as ValueParser from './value-parser' + +// Assumption: We already assume that we receive somewhat valid `calc()` +// expressions. So we will see `calc(1 + 1)` and not `calc(1+1)` +export function constantFoldDeclaration(input: string): string { + let folded = false + let valueAst = ValueParser.parse(input) + + ValueParser.walkDepth(valueAst, (valueNode, { replaceWith }) => { + // Convert `-0`, `+0`, `0.0`, … to `0` + // Convert `-0px`, `+0em`, `0.0rem`, … to `0` + if ( + valueNode.kind === 'word' && + valueNode.value !== '0' && // Already `0`, nothing to do + ((valueNode.value[0] === '-' && valueNode.value[1] === '0') || // `-0…` + (valueNode.value[0] === '+' && valueNode.value[1] === '0') || // `+0…` + valueNode.value[0] === '0') // `0…` + ) { + let dimension = dimensions.get(valueNode.value) + if (dimension === null) return // This shouldn't happen + + if (dimension[0] !== 0) return // Not a zero value, nothing to do + + // Replace length units with just `0` + if (dimension[1] === null || isLength(valueNode.value)) { + folded = true + replaceWith(ValueParser.word('0')) + return + } + + // Replace other units with `0 `, e.g. `0%`, `0fr`, `0s`, … + else if (valueNode.value !== `0${dimension[1]}`) { + folded = true + replaceWith(ValueParser.word(`0${dimension[1]}`)) + return + } + } + + // Constant fold `calc()` expressions with two operands and one operator + else if ( + valueNode.kind === 'function' && + (valueNode.value === 'calc' || valueNode.value === '') + ) { + // [ + // { kind: 'word', value: '0.25rem' }, 0 + // { kind: 'separator', value: ' ' }, 1 + // { kind: 'word', value: '*' }, 2 + // { kind: 'separator', value: ' ' }, 3 + // { kind: 'word', value: '256' } 4 + // ] + if (valueNode.nodes.length !== 5) return + + let lhs = dimensions.get(valueNode.nodes[0].value) + let operator = valueNode.nodes[2].value + let rhs = dimensions.get(valueNode.nodes[4].value) + + // Nullify entire expression when multiplying by `0`, e.g.: `calc(0 * 100vw)` -> `0` + // + // TODO: Ensure it's safe to do so based on the data types? + if ( + operator === '*' && + ((lhs?.[0] === 0 && lhs?.[1] === null) || // 0 * something + (rhs?.[0] === 0 && rhs?.[1] === null)) // something * 0 + ) { + folded = true + replaceWith(ValueParser.word('0')) + return + } + + // We're not dealing with dimensions, so we can't fold this + if (lhs === null || rhs === null) { + return + } + + switch (operator) { + case '*': { + if ( + lhs[1] === rhs[1] || // Same Units, e.g.: `1rem * 2rem`, `8 * 6` + (lhs[1] === null && rhs[1] !== null) || // Unitless * Unit, e.g.: `2 * 1rem` + (lhs[1] !== null && rhs[1] === null) // Unit * Unitless, e.g.: `1rem * 2` + ) { + folded = true + replaceWith(ValueParser.word(`${lhs[0] * rhs[0]}${lhs[1] ?? ''}`)) + } + break + } + + case '+': { + if ( + lhs[1] === rhs[1] // Same unit or unitless, e.g.: `1rem + 2rem`, `8 + 6` + ) { + folded = true + replaceWith(ValueParser.word(`${lhs[0] + rhs[0]}${lhs[1] ?? ''}`)) + } + break + } + + case '-': { + if ( + lhs[1] === rhs[1] // Same unit or unitless, e.g.: `2rem - 1rem`, `8 - 6` + ) { + folded = true + replaceWith(ValueParser.word(`${lhs[0] - rhs[0]}${lhs[1] ?? ''}`)) + } + break + } + + case '/': { + if ( + rhs[0] !== 0 && // Don't divide by zero + ((lhs[1] === null && rhs[1] === null) || // Unitless / Unitless, e.g.: `8 / 2` + (lhs[1] !== null && rhs[1] === null)) // Unit / Unitless, e.g.: `1rem / 2` + ) { + folded = true + replaceWith(ValueParser.word(`${lhs[0] / rhs[0]}${lhs[1] ?? ''}`)) + } + break + } + } + } + }) + + return folded ? ValueParser.toCss(valueAst) : input +} diff --git a/packages/tailwindcss/src/signatures.ts b/packages/tailwindcss/src/signatures.ts index 80d8f2948..54738072b 100644 --- a/packages/tailwindcss/src/signatures.ts +++ b/packages/tailwindcss/src/signatures.ts @@ -1,11 +1,11 @@ import { substituteAtApply } from './apply' import { atRule, styleRule, toCss, walk, type AstNode } from './ast' import { printArbitraryValue } from './candidate' +import { constantFoldDeclaration } from './constant-fold-declaration' import { CompileAstFlags, type DesignSystem } from './design-system' import * as SelectorParser from './selector-parser' import { ThemeOptions } from './theme' import { DefaultMap } from './utils/default-map' -import { dimensions } from './utils/dimensions' import { isValidSpacingMultiplier } from './utils/infer-data-type' import * as ValueParser from './value-parser' @@ -208,39 +208,7 @@ export const computeUtilitySignature = new DefaultMap< // → `calc(0.25rem * 4)` ← this is the case we will see // after inlining the variable // → `1rem` - if (node.value.includes('calc')) { - let folded = false - let valueAst = ValueParser.parse(node.value) - ValueParser.walk(valueAst, (valueNode, { replaceWith }) => { - if (valueNode.kind !== 'function') return - if (valueNode.value !== 'calc') return - - // [ - // { kind: 'word', value: '0.25rem' }, 0 - // { kind: 'separator', value: ' ' }, 1 - // { kind: 'word', value: '*' }, 2 - // { kind: 'separator', value: ' ' }, 3 - // { kind: 'word', value: '256' } 4 - // ] - if (valueNode.nodes.length !== 5) return - if (valueNode.nodes[2].kind !== 'word' && valueNode.nodes[2].value !== '*') return - - let parsed = dimensions.get(valueNode.nodes[0].value) - if (parsed === null) return - - let [value, unit] = parsed - - let multiplier = Number(valueNode.nodes[4].value) - if (Number.isNaN(multiplier)) return - - folded = true - replaceWith(ValueParser.parse(`${value * multiplier}${unit}`)) - }) - - if (folded) { - node.value = ValueParser.toCss(valueAst) - } - } + node.value = constantFoldDeclaration(node.value) // We will normalize the `node.value`, this is the same kind of logic // we use when printing arbitrary values. It will remove unnecessary @@ -277,6 +245,17 @@ export const preComputedUtilities = new DefaultMap -?(?:\d*\.)?\d+)(? [a-z]+|%)$/i +const DIMENSION_REGEX = /^(? [-+]?(?:\d*\.)?\d+)(? [a-z]+|%)?$/i // Parse a dimension such as `64rem` into `[64, 'rem']`. export const dimensions = new DefaultMap((input) => { @@ -10,11 +10,11 @@ export const dimensions = new DefaultMap((input) => { let value = match.groups?.value if (value === undefined) return null - let unit = match.groups?.unit - if (unit === undefined) return null - let valueAsNumber = Number(value) if (Number.isNaN(valueAsNumber)) return null + let unit = match.groups?.unit + if (unit === undefined) return [valueAsNumber, null] as const + return [valueAsNumber, unit] as const }) diff --git a/packages/tailwindcss/src/utils/infer-data-type.ts b/packages/tailwindcss/src/utils/infer-data-type.ts index 85b99c6da..587bae785 100644 --- a/packages/tailwindcss/src/utils/infer-data-type.ts +++ b/packages/tailwindcss/src/utils/infer-data-type.ts @@ -233,7 +233,7 @@ const LENGTH_UNITS = [ const IS_LENGTH = new RegExp(`^${HAS_NUMBER.source}(${LENGTH_UNITS.join('|')})$`) -function isLength(value: string): boolean { +export function isLength(value: string): boolean { return IS_LENGTH.test(value) || hasMathFn(value) } diff --git a/packages/tailwindcss/src/value-parser.test.ts b/packages/tailwindcss/src/value-parser.test.ts index 43110f781..e7ece47c0 100644 --- a/packages/tailwindcss/src/value-parser.test.ts +++ b/packages/tailwindcss/src/value-parser.test.ts @@ -89,7 +89,7 @@ describe('parse', () => { value: 'theme', nodes: [ { kind: 'word', value: 'colors.red.500' }, - { kind: 'separator', value: '/' }, + { kind: 'word', value: '/' }, { kind: 'function', value: 'var', nodes: [{ kind: 'word', value: '--opacity' }] }, ], }, diff --git a/packages/tailwindcss/src/value-parser.ts b/packages/tailwindcss/src/value-parser.ts index 945b2a112..ece63ceba 100644 --- a/packages/tailwindcss/src/value-parser.ts +++ b/packages/tailwindcss/src/value-parser.ts @@ -17,7 +17,7 @@ export type ValueSeparatorNode = { export type ValueAstNode = ValueWordNode | ValueFunctionNode | ValueSeparatorNode type ValueParentNode = ValueFunctionNode | null -function word(value: string): ValueWordNode { +export function word(value: string): ValueWordNode { return { kind: 'word', value, @@ -115,6 +115,71 @@ export function walk( } } +export function walkDepth( + ast: ValueAstNode[], + visit: ( + node: ValueAstNode, + utils: { + parent: ValueParentNode + replaceWith(newNode: ValueAstNode | ValueAstNode[]): void + }, + ) => void | ValueWalkAction, + parent: ValueParentNode = null, +) { + for (let i = 0; i < ast.length; i++) { + let node = ast[i] + if (node.kind === 'function') { + if (walkDepth(node.nodes, visit, node) === ValueWalkAction.Stop) { + return ValueWalkAction.Stop + } + } + + let replacedNode = false + let replacedNodeOffset = 0 + let status = + visit(node, { + parent, + replaceWith(newNode) { + if (replacedNode) return + replacedNode = true + + if (Array.isArray(newNode)) { + if (newNode.length === 0) { + ast.splice(i, 1) + replacedNodeOffset = 0 + } else if (newNode.length === 1) { + ast[i] = newNode[0] + replacedNodeOffset = 1 + } else { + ast.splice(i, 1, ...newNode) + replacedNodeOffset = newNode.length + } + } else { + ast[i] = newNode + } + }, + }) ?? ValueWalkAction.Continue + + // We want to visit or skip the newly replaced node(s), which start at the + // current index (i). By decrementing the index here, the next loop will + // process this position (containing the replaced node) again. + if (replacedNode) { + if (status === ValueWalkAction.Continue) { + i-- + } else { + i += replacedNodeOffset - 1 + } + continue + } + + // Stop the walk entirely + if (status === ValueWalkAction.Stop) return ValueWalkAction.Stop + + // Skip visiting the children of this node + if (status === ValueWalkAction.Skip) continue + } +} + export function toCss(ast: ValueAstNode[]) { let css = '' for (const node of ast) { @@ -172,6 +237,33 @@ export function parse(input: string) { break } + // Typically for math operators, they have to have spaces around them. But + // there are situations in `theme(colors.red.500/10)` where we use `/` + // without spaces. Let's make sure this is a separate word as well. + case SLASH: { + // 1. Handle everything before the separator as a word + // Handle everything before the closing paren as a word + if (buffer.length > 0) { + let node = word(buffer) + if (parent) { + parent.nodes.push(node) + } else { + ast.push(node) + } + buffer = '' + } + + // 2. Track the `/` as a word on its own + let node = word(input[i]) + if (parent) { + parent.nodes.push(node) + } else { + ast.push(node) + } + + break + } + // Space and commas are bundled into separators // // E.g.: @@ -186,7 +278,6 @@ export function parse(input: string) { case GREATER_THAN: case LESS_THAN: case NEWLINE: - case SLASH: case SPACE: case TAB: { // 1. Handle everything before the separator as a word @@ -213,7 +304,6 @@ export function parse(input: string) { peekChar !== GREATER_THAN && peekChar !== LESS_THAN && peekChar !== NEWLINE && - peekChar !== SLASH && peekChar !== SPACE && peekChar !== TAB ) { From 53a8d3bc9095c73adb95e2bcbdeb80154eb02c4b Mon Sep 17 00:00:00 2001 From: "depfu[bot]" <23717796+depfu[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:33:23 -0400 Subject: [PATCH 036/431] =?UTF-8?q?Update=20all=20of=20react=2019.1.1=20?= =?UTF-8?q?=E2=86=92=2019.2.0=20(minor)=20(#19087)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Here is everything you need to know about this update. Please take a good look at what changed and the test results before merging this pull request. ### What changed? #### ✳️ react (19.1.1 → 19.2.0) · [Repo](https://github.com/facebook/react) · [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) Release Notes
19.2.0
Below is a list of all new features, APIs, and bug fixes.
Read the React 19.2 release post for more information.
New React Features
<Activity>: A new API to hide and restore the UI and internal state of its children.useEffectEventis a React Hook that lets you extract non-reactive logic into an Effect Event.cacheSignal(for RSCs) lets your know when thecache()lifetime is over.- React Performance tracks appear on the Performance panel’s timeline in your browser developer tools
New React DOM Features
- Added resume APIs for partial pre-rendering with Web Streams:
resume: to resume a prerender to a stream.resumeAndPrerender: to resume a prerender to HTML.- Added resume APIs for partial pre-rendering with Node Streams:
resumeToPipeableStream: to resume a prerender to a stream.resumeAndPrerenderToNodeStream: to resume a prerender to HTML.- Updated
prerenderAPIs to return apostponedstate that can be passed to theresumeAPIs.Notable changes
- React DOM now batches suspense boundary reveals, matching the behavior of client side rendering. This change is especially noticeable when animating the reveal of Suspense boundaries e.g. with the upcoming
<ViewTransition>Component. React will batch as much reveals as possible before the first paint while trying to hit popular first-contentful paint metrics.- Add Node Web Streams (
prerender,renderToReadableStream) to server-side-rendering APIs for Node.js- Use underscore instead of
:IDs generated by useIdAll Changes
React
<Activity />was developed over many years, starting beforeClassComponent.setState(@acdlite @sebmarkbage and many others)- Stringify context as "SomeContext" instead of "SomeContext.Provider" (@kassens #33507)
- Include stack of cause of React instrumentation errors with
%oplaceholder (@eps1lon #34198)- Fix infinite
useDeferredValueloop in popstate event (@acdlite #32821)- Fix a bug when an initial value was passed to
useDeferredValue(@acdlite #34376)- Fix a crash when submitting forms with Client Actions (@sebmarkbage #33055)
- Hide/unhide the content of dehydrated suspense boundaries if they resuspend (@sebmarkbage #32900)
- Avoid stack overflow on wide trees during Hot Reload (@sophiebits #34145)
- Improve Owner and Component stacks in various places (@sebmarkbage, @eps1lon: #33629, #33724, #32735, #33723)
- Add
cacheSignal(@sebmarkbage #33557)React DOM
- Block on Suspensey Fonts during reveal of server-side-rendered content (@sebmarkbage #33342)
- Use underscore instead of
:for IDs generated byuseId(@sebmarkbage, @eps1lon: #32001, #33342#33099, #33422)- Stop warning when ARIA 1.3 attributes are used (@Abdul-Omira #34264)
- Allow
nonceto be used on hoistable styles (@Andarist #32461)- Warn for using a React owned node as a Container if it also has text content (@sebmarkbage #32774)
- s/HTML/text for for error messages if text hydration mismatches (@rickhanlonii #32763)
- Fix a bug with
React.useinsideReact.lazy-ed Component (@hi-ogawa #33941)- Enable the
progressiveChunkSizeoption for server-side-rendering APIs (@sebmarkbage #33027)- Fix a bug with deeply nested Suspense inside Suspense fallback when server-side-rendering (@gnoff #33467)
- Avoid hanging when suspending after aborting while rendering (@gnoff #34192)
- Add Node Web Streams to server-side-rendering APIs for Node.js (@sebmarkbage #33475)
React Server Components
- Preload
<img>and<link>using hints before they're rendered (@sebmarkbage #34604)- Log error if production elements are rendered during development (@eps1lon #34189)
- Fix a bug when returning a Temporary reference (e.g. a Client Reference) from Server Functions (@sebmarkbage #34084, @denk0403 #33761)
- Pass line/column to
filterStackFrame(@eps1lon #33707)- Support Async Modules in Turbopack Server References (@lubieowoce #34531)
- Add support for .mjs file extension in Webpack (@jennyscript #33028)
- Fix a wrong missing key warning (@unstubbable #34350)
- Make console log resolve in predictable order (@sebmarkbage #33665)
React Reconciler
- createContainer and createHydrationContainer had their parameter order adjusted after
on*handlers to account for upcoming experimental APIseslint-plugin-react-hooks@6.1.0
Note: Version 6.0.0 was mistakenly released and immediately deprecated and untagged on npm. This is the first official 6.x major release and includes breaking changes.
- Breaking: Require Node.js 18 or newer. (@michaelfaith in #32458)
- Breaking: Flat config is now the default
recommendedpreset. Legacy config moved torecommended-legacy. (@michaelfaith in #32457)- New Violations: Disallow calling
usewithin try/catch blocks. (@poteto in #34040)- New Violations: Disallow calling
useEffectEventfunctions in arbitrary closures. (@jbrown215 in #33544)- Handle
React.useEffectin addition touseEffectin rules-of-hooks. (@Ayc0 in #34076)- Added
react-hookssettings config option that to acceptadditionalEffectHooksthat are used across exhaustive-deps and rules-of-hooks rules. (@jbrown215) in #34497Does any of this look wrong? Please let us know.
#### ✳️ react-dom (19.1.1 → 19.2.0) · [Repo](https://github.com/facebook/react) · [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)Commits
See the full diff on Github. The new version differs by more commits than we can show here.
Release Notes
19.2.0
Below is a list of all new features, APIs, and bug fixes.
Read the React 19.2 release post for more information.
New React Features
<Activity>: A new API to hide and restore the UI and internal state of its children.useEffectEventis a React Hook that lets you extract non-reactive logic into an Effect Event.cacheSignal(for RSCs) lets your know when thecache()lifetime is over.- React Performance tracks appear on the Performance panel’s timeline in your browser developer tools
New React DOM Features
- Added resume APIs for partial pre-rendering with Web Streams:
resume: to resume a prerender to a stream.resumeAndPrerender: to resume a prerender to HTML.- Added resume APIs for partial pre-rendering with Node Streams:
resumeToPipeableStream: to resume a prerender to a stream.resumeAndPrerenderToNodeStream: to resume a prerender to HTML.- Updated
prerenderAPIs to return apostponedstate that can be passed to theresumeAPIs.Notable changes
- React DOM now batches suspense boundary reveals, matching the behavior of client side rendering. This change is especially noticeable when animating the reveal of Suspense boundaries e.g. with the upcoming
<ViewTransition>Component. React will batch as much reveals as possible before the first paint while trying to hit popular first-contentful paint metrics.- Add Node Web Streams (
prerender,renderToReadableStream) to server-side-rendering APIs for Node.js- Use underscore instead of
:IDs generated by useIdAll Changes
React
<Activity />was developed over many years, starting beforeClassComponent.setState(@acdlite @sebmarkbage and many others)- Stringify context as "SomeContext" instead of "SomeContext.Provider" (@kassens #33507)
- Include stack of cause of React instrumentation errors with
%oplaceholder (@eps1lon #34198)- Fix infinite
useDeferredValueloop in popstate event (@acdlite #32821)- Fix a bug when an initial value was passed to
useDeferredValue(@acdlite #34376)- Fix a crash when submitting forms with Client Actions (@sebmarkbage #33055)
- Hide/unhide the content of dehydrated suspense boundaries if they resuspend (@sebmarkbage #32900)
- Avoid stack overflow on wide trees during Hot Reload (@sophiebits #34145)
- Improve Owner and Component stacks in various places (@sebmarkbage, @eps1lon: #33629, #33724, #32735, #33723)
- Add
cacheSignal(@sebmarkbage #33557)React DOM
- Block on Suspensey Fonts during reveal of server-side-rendered content (@sebmarkbage #33342)
- Use underscore instead of
:for IDs generated byuseId(@sebmarkbage, @eps1lon: #32001, #33342#33099, #33422)- Stop warning when ARIA 1.3 attributes are used (@Abdul-Omira #34264)
- Allow
nonceto be used on hoistable styles (@Andarist #32461)- Warn for using a React owned node as a Container if it also has text content (@sebmarkbage #32774)
- s/HTML/text for for error messages if text hydration mismatches (@rickhanlonii #32763)
- Fix a bug with
React.useinsideReact.lazy-ed Component (@hi-ogawa #33941)- Enable the
progressiveChunkSizeoption for server-side-rendering APIs (@sebmarkbage #33027)- Fix a bug with deeply nested Suspense inside Suspense fallback when server-side-rendering (@gnoff #33467)
- Avoid hanging when suspending after aborting while rendering (@gnoff #34192)
- Add Node Web Streams to server-side-rendering APIs for Node.js (@sebmarkbage #33475)
React Server Components
- Preload
<img>and<link>using hints before they're rendered (@sebmarkbage #34604)- Log error if production elements are rendered during development (@eps1lon #34189)
- Fix a bug when returning a Temporary reference (e.g. a Client Reference) from Server Functions (@sebmarkbage #34084, @denk0403 #33761)
- Pass line/column to
filterStackFrame(@eps1lon #33707)- Support Async Modules in Turbopack Server References (@lubieowoce #34531)
- Add support for .mjs file extension in Webpack (@jennyscript #33028)
- Fix a wrong missing key warning (@unstubbable #34350)
- Make console log resolve in predictable order (@sebmarkbage #33665)
React Reconciler
- createContainer and createHydrationContainer had their parameter order adjusted after
on*handlers to account for upcoming experimental APIseslint-plugin-react-hooks@6.1.0
Note: Version 6.0.0 was mistakenly released and immediately deprecated and untagged on npm. This is the first official 6.x major release and includes breaking changes.
- Breaking: Require Node.js 18 or newer. (@michaelfaith in #32458)
- Breaking: Flat config is now the default
recommendedpreset. Legacy config moved torecommended-legacy. (@michaelfaith in #32457)- New Violations: Disallow calling
usewithin try/catch blocks. (@poteto in #34040)- New Violations: Disallow calling
useEffectEventfunctions in arbitrary closures. (@jbrown215 in #33544)- Handle
React.useEffectin addition touseEffectin rules-of-hooks. (@Ayc0 in #34076)- Added
react-hookssettings config option that to acceptadditionalEffectHooksthat are used across exhaustive-deps and rules-of-hooks rules. (@jbrown215) in #34497Does any of this look wrong? Please let us know.
---  [Depfu](https://depfu.com) will automatically keep this PR conflict-free, as long as you don't add any commits to this branch yourself. You can also trigger a rebase manually by commenting with `@depfu rebase`.Commits
See the full diff on Github. The new version differs by more commits than we can show here.
Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com> Co-authored-by: Jordan PittmanAll Depfu comment commands
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
--- playgrounds/nextjs/package.json | 4 +-- playgrounds/v3/package.json | 4 +-- playgrounds/vite/package.json | 4 +-- pnpm-lock.yaml | 64 ++++++++++++++++----------------- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/playgrounds/nextjs/package.json b/playgrounds/nextjs/package.json index 8f6678442..3812dba81 100644 --- a/playgrounds/nextjs/package.json +++ b/playgrounds/nextjs/package.json @@ -12,8 +12,8 @@ "@tailwindcss/postcss": "workspace:^", "fast-glob": "^3.3.3", "next": "15.5.4", - "react": "^19.1.1", - "react-dom": "^19.1.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", "tailwindcss": "workspace:^" }, "devDependencies": { diff --git a/playgrounds/v3/package.json b/playgrounds/v3/package.json index 041fee025..f0869b809 100644 --- a/playgrounds/v3/package.json +++ b/playgrounds/v3/package.json @@ -10,8 +10,8 @@ }, "dependencies": { "next": "15.5.4", - "react": "^19.1.1", - "react-dom": "^19.1.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", "tailwindcss": "^3" }, "devDependencies": { diff --git a/playgrounds/vite/package.json b/playgrounds/vite/package.json index 631a83a8b..18f7cccc4 100644 --- a/playgrounds/vite/package.json +++ b/playgrounds/vite/package.json @@ -11,8 +11,8 @@ "dependencies": { "@tailwindcss/vite": "workspace:^", "@vitejs/plugin-react": "^5.0.3", - "react": "^19.1.1", - "react-dom": "^19.1.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", "tailwindcss": "workspace:^" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79efc1a58..cb48bd6de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -488,13 +488,13 @@ importers: version: 3.3.3 next: specifier: 15.5.4 - version: 15.5.4(@playwright/test@1.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 15.5.4(@playwright/test@1.55.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: - specifier: ^19.1.1 - version: 19.1.1 + specifier: ^19.2.0 + version: 19.2.0 react-dom: - specifier: ^19.1.1 - version: 19.1.1(react@19.1.1) + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) tailwindcss: specifier: workspace:^ version: link:../../packages/tailwindcss @@ -522,13 +522,13 @@ importers: dependencies: next: specifier: 15.5.4 - version: 15.5.4(@playwright/test@1.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 15.5.4(@playwright/test@1.55.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: - specifier: ^19.1.1 - version: 19.1.1 + specifier: ^19.2.0 + version: 19.2.0 react-dom: - specifier: ^19.1.1 - version: 19.1.1(react@19.1.1) + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) tailwindcss: specifier: ^3 version: 3.4.14 @@ -564,11 +564,11 @@ importers: specifier: ^5.0.3 version: 5.0.3(vite@7.0.0(@types/node@20.19.1)(jiti@2.6.0)(lightningcss@1.30.1(patch_hash=tzyxy3asfxcqc7ihrooumyi5fm))(terser@5.31.6)(tsx@4.19.1)(yaml@2.6.0)) react: - specifier: ^19.1.1 - version: 19.1.1 + specifier: ^19.2.0 + version: 19.2.0 react-dom: - specifier: ^19.1.1 - version: 19.1.1(react@19.1.1) + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) tailwindcss: specifier: workspace:^ version: link:../../packages/tailwindcss @@ -4058,10 +4058,10 @@ packages: radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} - react-dom@19.1.1: - resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==} + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} peerDependencies: - react: ^19.1.1 + react: ^19.2.0 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4070,8 +4070,8 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react@19.1.1: - resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -4140,8 +4140,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.26.0: - resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} @@ -7732,15 +7732,15 @@ snapshots: natural-compare@1.4.0: {} - next@15.5.4(@playwright/test@1.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + next@15.5.4(@playwright/test@1.55.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 15.5.4 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001705 postcss: 8.4.31 - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - styled-jsx: 5.1.6(react@19.1.1) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + styled-jsx: 5.1.6(react@19.2.0) optionalDependencies: '@next/swc-darwin-arm64': 15.5.4 '@next/swc-darwin-x64': 15.5.4 @@ -8028,16 +8028,16 @@ snapshots: radix3@1.1.2: {} - react-dom@19.1.1(react@19.1.1): + react-dom@19.2.0(react@19.2.0): dependencies: - react: 19.1.1 - scheduler: 0.26.0 + react: 19.2.0 + scheduler: 0.27.0 react-is@16.13.1: {} react-refresh@0.17.0: {} - react@19.1.1: {} + react@19.2.0: {} read-cache@1.0.0: dependencies: @@ -8156,7 +8156,7 @@ snapshots: safer-buffer@2.1.2: {} - scheduler@0.26.0: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -8322,10 +8322,10 @@ snapshots: strip-json-comments@3.1.1: {} - styled-jsx@5.1.6(react@19.1.1): + styled-jsx@5.1.6(react@19.2.0): dependencies: client-only: 0.0.1 - react: 19.1.1 + react: 19.2.0 sucrase@3.35.0: dependencies: From 496a1e93622789b121aeb608e0c6a07e35abeb0c Mon Sep 17 00:00:00 2001 From: "depfu[bot]" <23717796+depfu[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:58:37 -0400 Subject: [PATCH 037/431] =?UTF-8?q?Update=20@types/react-dom=2019.1.9=20?= =?UTF-8?q?=E2=86=92=2019.2.1=20(minor)=20(#19088)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Here is everything you need to know about this update. Please take a good look at what changed and the test results before merging this pull request. ### What changed? #### ✳️ @types/react-dom (19.1.9 → 19.2.1) · [Repo](https://github.com/DefinitelyTyped/DefinitelyTyped) Sorry, we couldn't find anything useful about this release. ---  [Depfu](https://depfu.com) will automatically keep this PR conflict-free, as long as you don't add any commits to this branch yourself. You can also trigger a rebase manually by commenting with `@depfu rebase`. Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com> Co-authored-by: Jordan PittmanAll Depfu comment commands
- @depfu rebase
- Rebases against your default branch and redoes this update
- @depfu recreate
- Recreates this PR, overwriting any edits that you've made to it
- @depfu merge
- Merges this PR once your tests are passing and conflicts are resolved
- @depfu cancel merge
- Cancels automatic merging of this PR
- @depfu close
- Closes this PR and deletes the branch
- @depfu reopen
- Restores the branch and reopens this PR (if it's closed)
- @depfu pause
- Ignores all future updates for this dependency and closes this PR
- @depfu pause [minor|major]
- Ignores all future minor/major updates for this dependency and closes this PR
- @depfu resume
- Future versions of this dependency will create PRs again (leaves this PR as is)
--- playgrounds/nextjs/package.json | 2 +- playgrounds/v3/package.json | 2 +- playgrounds/vite/package.json | 2 +- pnpm-lock.yaml | 20 ++++++++++---------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/playgrounds/nextjs/package.json b/playgrounds/nextjs/package.json index 3812dba81..91e40a23f 100644 --- a/playgrounds/nextjs/package.json +++ b/playgrounds/nextjs/package.json @@ -19,7 +19,7 @@ "devDependencies": { "@types/node": "catalog:", "@types/react": "^19.1.13", - "@types/react-dom": "^19.1.9", + "@types/react-dom": "^19.2.1", "eslint": "^9.36.0", "eslint-config-next": "^15.5.4", "typescript": "^5.5.4" diff --git a/playgrounds/v3/package.json b/playgrounds/v3/package.json index f0869b809..15ac33f34 100644 --- a/playgrounds/v3/package.json +++ b/playgrounds/v3/package.json @@ -17,7 +17,7 @@ "devDependencies": { "@types/node": "^20.14.8", "@types/react": "^19.1.13", - "@types/react-dom": "^19.1.9", + "@types/react-dom": "^19.2.1", "autoprefixer": "^10.4.21", "eslint": "^9.36.0", "eslint-config-next": "^15.5.4", diff --git a/playgrounds/vite/package.json b/playgrounds/vite/package.json index 18f7cccc4..884afd4a9 100644 --- a/playgrounds/vite/package.json +++ b/playgrounds/vite/package.json @@ -17,7 +17,7 @@ }, "devDependencies": { "@types/react": "^19.1.13", - "@types/react-dom": "^19.1.9", + "@types/react-dom": "^19.2.1", "bun": "^1.2.22", "vite": "catalog:" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb48bd6de..4063a9326 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -506,8 +506,8 @@ importers: specifier: ^19.1.13 version: 19.1.13 '@types/react-dom': - specifier: ^19.1.9 - version: 19.1.9(@types/react@19.1.13) + specifier: ^19.2.1 + version: 19.2.1(@types/react@19.1.13) eslint: specifier: ^9.36.0 version: 9.36.0(jiti@2.6.0) @@ -540,8 +540,8 @@ importers: specifier: ^19.1.13 version: 19.1.13 '@types/react-dom': - specifier: ^19.1.9 - version: 19.1.9(@types/react@19.1.13) + specifier: ^19.2.1 + version: 19.2.1(@types/react@19.1.13) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.4.47) @@ -577,8 +577,8 @@ importers: specifier: ^19.1.13 version: 19.1.13 '@types/react-dom': - specifier: ^19.1.9 - version: 19.1.9(@types/react@19.1.13) + specifier: ^19.2.1 + version: 19.2.1(@types/react@19.1.13) bun: specifier: ^1.2.22 version: 1.2.22 @@ -2441,10 +2441,10 @@ packages: '@types/postcss-import@14.0.3': resolution: {integrity: sha512-raZhRVTf6Vw5+QbmQ7LOHSDML71A5rj4+EqDzAbrZPfxfoGzFxMHRCq16VlddGIZpHELw0BG4G0YE2ANkdZiIQ==} - '@types/react-dom@19.1.9': - resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} + '@types/react-dom@19.2.1': + resolution: {integrity: sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==} peerDependencies: - '@types/react': ^19.0.0 + '@types/react': ^19.2.0 '@types/react@19.1.13': resolution: {integrity: sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==} @@ -6087,7 +6087,7 @@ snapshots: dependencies: postcss: 8.4.41 - '@types/react-dom@19.1.9(@types/react@19.1.13)': + '@types/react-dom@19.2.1(@types/react@19.1.13)': dependencies: '@types/react': 19.1.13 From c67c0c54cfdd693eed8af42002298cb4a455db4c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 10 Oct 2025 17:44:53 +0200 Subject: [PATCH 038/431] Canonicalize dimensions (#19101) --- .../template/migrate-arbitrary-variants.ts | 6 +- ...migrate-modernize-arbitrary-values.test.ts | 2 +- .../src/codemods/template/migrate.ts | 12 +- .../src/canonicalize-candidates.test.ts | 29 +++- .../src/canonicalize-candidates.ts | 124 ++++++++++++------ .../src/constant-fold-declaration.test.ts | 12 +- .../src/constant-fold-declaration.ts | 72 ++++++---- packages/tailwindcss/src/design-system.ts | 7 +- packages/tailwindcss/src/intellisense.ts | 2 +- packages/tailwindcss/src/signatures.ts | 124 ++++++++++-------- 10 files changed, 250 insertions(+), 140 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts index 9d8b176bd..c1b9669ac 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts @@ -1,4 +1,5 @@ import { cloneCandidate } from '../../../../tailwindcss/src/candidate' +import { createSignatureOptions } from '../../../../tailwindcss/src/canonicalize-candidates' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { @@ -14,8 +15,9 @@ export function migrateArbitraryVariants( _userConfig: Config | null, rawCandidate: string, ): string { - let signatures = computeVariantSignature.get(designSystem) - let variants = preComputedVariants.get(designSystem) + let signatureOptions = createSignatureOptions(designSystem) + let signatures = computeVariantSignature.get(signatureOptions) + let variants = preComputedVariants.get(signatureOptions) for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { // We are only interested in the variants diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.test.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.test.ts index 9f26f0188..b1887ae0b 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-modernize-arbitrary-values.test.ts @@ -17,7 +17,7 @@ function migrate(designSystem: DesignSystem, userConfig: UserConfig | null, rawC migratePrefix, migrateModernizeArbitraryValues, migrateArbitraryVariants, - (designSystem: DesignSystem, _, rawCandidate: string) => { + (designSystem: DesignSystem, _: UserConfig | null, rawCandidate: string) => { return designSystem.canonicalizeCandidates([rawCandidate]).pop() ?? rawCandidate }, ]) { diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts index d402847fc..6060d8020 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises' import path, { extname } from 'node:path' +import { createSignatureOptions } from '../../../../tailwindcss/src/canonicalize-candidates' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' import { computeUtilitySignature } from '../../../../tailwindcss/src/signatures' @@ -39,11 +40,10 @@ export const DEFAULT_MIGRATIONS: Migration[] = [ migrateModernizeArbitraryValues, ] -let migrateCached = new DefaultMap< - DesignSystem, - DefaultMap >> ->((designSystem) => { - return new DefaultMap((userConfig) => { +let migrateCached = new DefaultMap((designSystem: DesignSystem) => { + let options = createSignatureOptions(designSystem) + + return new DefaultMap((userConfig: Config | null) => { return new DefaultMap(async (rawCandidate) => { let original = rawCandidate @@ -57,7 +57,7 @@ let migrateCached = new DefaultMap< // Verify that the candidate actually makes sense at all. E.g.: `duration` // is not a valid candidate, but it will parse because `duration- ` // exists. - let signature = computeUtilitySignature.get(designSystem).get(rawCandidate) + let signature = computeUtilitySignature.get(options).get(rawCandidate) if (typeof signature !== 'string') return original return rawCandidate diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index b1ba62677..75c0d45d1 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import { describe, expect, test } from 'vitest' import { __unstable__loadDesignSystem } from '.' +import type { CanonicalizeOptions } from './intellisense' import { DefaultMap } from './utils/default-map' const css = String.raw @@ -63,7 +64,12 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', return candidate } - async function expectCanonicalization(input: string, candidate: string, expected: string) { + async function expectCanonicalization( + input: string, + candidate: string, + expected: string, + options?: CanonicalizeOptions, + ) { candidate = prepare(candidate) expected = prepare(expected) @@ -72,7 +78,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', } let designSystem = await designSystems.get(__dirname).get(input) - let [actual] = designSystem.canonicalizeCandidates([candidate]) + let [actual] = designSystem.canonicalizeCandidates([candidate], options) try { expect(actual).toBe(expected) @@ -937,3 +943,22 @@ describe('theme to var', () => { ]) }) }) + +describe('options', () => { + test('normalize `rem` units to `px`', async () => { + let designSystem = await __unstable__loadDesignSystem( + css` + @tailwind utilities; + @theme { + --spacing: 0.25rem; + } + `, + { base: __dirname }, + ) + + expect(designSystem.canonicalizeCandidates(['m-[16px]'])).toEqual(['m-[16px]']) + expect(designSystem.canonicalizeCandidates(['m-[16px]'], { rem: 16 })).toEqual(['m-4']) + expect(designSystem.canonicalizeCandidates(['m-[16px]'], { rem: 64 })).toEqual(['m-1']) + expect(designSystem.canonicalizeCandidates(['m-[16px]'])).toEqual(['m-[16px]']) // Ensure options don't influence shared state + }) +}) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 791da7844..8df31af09 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -16,6 +16,7 @@ import { computeVariantSignature, preComputedUtilities, preComputedVariants, + type SignatureOptions, } from './signatures' import type { Writable } from './types' import { DefaultMap } from './utils/default-map' @@ -26,19 +27,47 @@ import { segment } from './utils/segment' import { toKeyPath } from './utils/to-key-path' import * as ValueParser from './value-parser' -export function canonicalizeCandidates(ds: DesignSystem, candidates: string[]): string[] { +export interface CanonicalizeOptions { + /** + * The root font size in pixels. If provided, `rem` values will be normalized + * to `px` values. + * + * E.g.: `mt-[16px]` with `rem: 16` will become `mt-4` (assuming `--spacing: 0.25rem`). + */ + rem?: number +} + +const optionsCache = new DefaultMap((designSystem: DesignSystem) => { + return new DefaultMap((rem: number | null = null) => { + return { designSystem, rem } satisfies SignatureOptions + }) +}) + +export function createSignatureOptions( + designSystem: DesignSystem, + options?: CanonicalizeOptions, +): SignatureOptions { + return optionsCache.get(designSystem).get(options?.rem ?? null) +} + +export function canonicalizeCandidates( + designSystem: DesignSystem, + candidates: string[], + options?: CanonicalizeOptions, +): string[] { let result = new Set () - let cache = canonicalizeCandidateCache.get(ds) + let cache = canonicalizeCandidateCache.get(createSignatureOptions(designSystem, options)) for (let candidate of candidates) { result.add(cache.get(candidate)) } return Array.from(result) } -const canonicalizeCandidateCache = new DefaultMap((ds: DesignSystem) => { +const canonicalizeCandidateCache = new DefaultMap((options: SignatureOptions) => { + let ds = options.designSystem let prefix = ds.theme.prefix ? `${ds.theme.prefix}:` : '' - let variantCache = canonicalizeVariantCache.get(ds) - let utilityCache = canonicalizeUtilityCache.get(ds) + let variantCache = canonicalizeVariantCache.get(options) + let utilityCache = canonicalizeUtilityCache.get(options) return new DefaultMap ((rawCandidate: string, self) => { for (let candidate of ds.parseCandidate(rawCandidate)) { @@ -95,21 +124,26 @@ const canonicalizeCandidateCache = new DefaultMap((ds: DesignSystem) => { }) }) -const VARIANT_CANONICALIZATIONS = [ +type VariantCanonicalizationFunction = ( + variant: Variant, + options: SignatureOptions, +) => Variant | Variant[] + +const VARIANT_CANONICALIZATIONS: VariantCanonicalizationFunction[] = [ themeToVarVariant, arbitraryValueToBareValueVariant, modernizeArbitraryValuesVariant, arbitraryVariants, ] -const canonicalizeVariantCache = new DefaultMap((ds: DesignSystem) => { +const canonicalizeVariantCache = new DefaultMap((options: SignatureOptions) => { return new DefaultMap((variant: Variant): Variant[] => { let replacement = [variant] for (let fn of VARIANT_CANONICALIZATIONS) { for (let current of replacement.splice(0)) { // A single variant can result in multiple variants, e.g.: // `[&>[data-selected]]:flex` → `*:data-selected:flex` - let result = fn(ds, cloneVariant(current)) + let result = fn(cloneVariant(current), options) if (Array.isArray(result)) { replacement.push(...result) continue @@ -122,7 +156,12 @@ const canonicalizeVariantCache = new DefaultMap((ds: DesignSystem) => { }) }) -const UTILITY_CANONICALIZATIONS = [ +type UtilityCanonicalizationFunction = ( + candidate: Candidate, + options: SignatureOptions, +) => Candidate + +const UTILITY_CANONICALIZATIONS: UtilityCanonicalizationFunction[] = [ bgGradientToLinear, themeToVarUtility, arbitraryUtilities, @@ -133,16 +172,17 @@ const UTILITY_CANONICALIZATIONS = [ optimizeModifier, ] -const canonicalizeUtilityCache = new DefaultMap((ds: DesignSystem) => { +const canonicalizeUtilityCache = new DefaultMap((options: SignatureOptions) => { + let designSystem = options.designSystem return new DefaultMap((rawCandidate: string): string => { - for (let readonlyCandidate of ds.parseCandidate(rawCandidate)) { + for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { let replacement = cloneCandidate(readonlyCandidate) as Writable for (let fn of UTILITY_CANONICALIZATIONS) { - replacement = fn(ds, replacement) + replacement = fn(replacement, options) } - let canonicalizedCandidate = ds.printCandidate(replacement) + let canonicalizedCandidate = designSystem.printCandidate(replacement) if (rawCandidate !== canonicalizedCandidate) { return canonicalizedCandidate } @@ -155,7 +195,7 @@ const canonicalizeUtilityCache = new DefaultMap((ds: DesignSystem) => { // ---- const DIRECTIONS = ['t', 'tr', 'r', 'br', 'b', 'bl', 'l', 'tl'] -function bgGradientToLinear(_: DesignSystem, candidate: Candidate) { +function bgGradientToLinear(candidate: Candidate) { if (candidate.kind === 'static' && candidate.root.startsWith('bg-gradient-to-')) { let direction = candidate.root.slice(15) @@ -178,8 +218,8 @@ const enum Convert { MigrateThemeOnly = 1 << 1, } -function themeToVarUtility(designSystem: DesignSystem, candidate: Candidate): Candidate { - let convert = converterCache.get(designSystem) +function themeToVarUtility(candidate: Candidate, options: SignatureOptions): Candidate { + let convert = converterCache.get(options.designSystem) if (candidate.kind === 'arbitrary') { let [newValue, modifier] = convert( @@ -210,8 +250,8 @@ function themeToVarUtility(designSystem: DesignSystem, candidate: Candidate): Ca return candidate } -function themeToVarVariant(designSystem: DesignSystem, variant: Variant): Variant | Variant[] { - let convert = converterCache.get(designSystem) +function themeToVarVariant(variant: Variant, options: SignatureOptions): Variant | Variant[] { + let convert = converterCache.get(options.designSystem) let iterator = walkVariants(variant) for (let [variant] of iterator) { @@ -545,7 +585,7 @@ const spacing = new DefaultMap | }) }) -function arbitraryUtilities(designSystem: DesignSystem, candidate: Candidate): Candidate { +function arbitraryUtilities(candidate: Candidate, options: SignatureOptions): Candidate { // We are only interested in arbitrary properties and arbitrary values if ( // Arbitrary property @@ -556,8 +596,9 @@ function arbitraryUtilities(designSystem: DesignSystem, candidate: Candidate): C return candidate } - let utilities = preComputedUtilities.get(designSystem) - let signatures = computeUtilitySignature.get(designSystem) + let designSystem = options.designSystem + let utilities = preComputedUtilities.get(options) + let signatures = computeUtilitySignature.get(options) let targetCandidateString = designSystem.printCandidate(candidate) @@ -761,14 +802,15 @@ function allVariablesAreUsed( // ---- -function bareValueUtilities(designSystem: DesignSystem, candidate: Candidate): Candidate { +function bareValueUtilities(candidate: Candidate, options: SignatureOptions): Candidate { // We are only interested in bare value utilities if (candidate.kind !== 'functional' || candidate.value?.kind !== 'named') { return candidate } - let utilities = preComputedUtilities.get(designSystem) - let signatures = computeUtilitySignature.get(designSystem) + let designSystem = options.designSystem + let utilities = preComputedUtilities.get(options) + let signatures = computeUtilitySignature.get(options) let targetCandidateString = designSystem.printCandidate(candidate) @@ -837,8 +879,9 @@ function bareValueUtilities(designSystem: DesignSystem, candidate: Candidate): C const DEPRECATION_MAP = new Map([['order-none', 'order-0']]) -function deprecatedUtilities(designSystem: DesignSystem, candidate: Candidate): Candidate { - let signatures = computeUtilitySignature.get(designSystem) +function deprecatedUtilities(candidate: Candidate, options: SignatureOptions): Candidate { + let designSystem = options.designSystem + let signatures = computeUtilitySignature.get(options) let targetCandidateString = printUnprefixedCandidate(designSystem, candidate) @@ -860,9 +903,10 @@ function deprecatedUtilities(designSystem: DesignSystem, candidate: Candidate): // ---- -function arbitraryVariants(designSystem: DesignSystem, variant: Variant): Variant | Variant[] { - let signatures = computeVariantSignature.get(designSystem) - let variants = preComputedVariants.get(designSystem) +function arbitraryVariants(variant: Variant, options: SignatureOptions): Variant | Variant[] { + let designSystem = options.designSystem + let signatures = computeVariantSignature.get(options) + let variants = preComputedVariants.get(options) let iterator = walkVariants(variant) for (let [variant] of iterator) { @@ -887,8 +931,9 @@ function arbitraryVariants(designSystem: DesignSystem, variant: Variant): Varian // ---- -function dropUnnecessaryDataTypes(designSystem: DesignSystem, candidate: Candidate): Candidate { - let signatures = computeUtilitySignature.get(designSystem) +function dropUnnecessaryDataTypes(candidate: Candidate, options: SignatureOptions): Candidate { + let designSystem = options.designSystem + let signatures = computeUtilitySignature.get(options) if ( candidate.kind === 'functional' && @@ -911,15 +956,16 @@ function dropUnnecessaryDataTypes(designSystem: DesignSystem, candidate: Candida // ---- function arbitraryValueToBareValueUtility( - designSystem: DesignSystem, candidate: Candidate, + options: SignatureOptions, ): Candidate { // We are only interested in functional utilities with arbitrary values if (candidate.kind !== 'functional' || candidate.value?.kind !== 'arbitrary') { return candidate } - let signatures = computeUtilitySignature.get(designSystem) + let designSystem = options.designSystem + let signatures = computeUtilitySignature.get(options) let expectedSignature = signatures.get(designSystem.printCandidate(candidate)) if (expectedSignature === null) return candidate @@ -935,7 +981,7 @@ function arbitraryValueToBareValueUtility( return candidate } -function arbitraryValueToBareValueVariant(_: DesignSystem, variant: Variant): Variant | Variant[] { +function arbitraryValueToBareValueVariant(variant: Variant): Variant | Variant[] { let iterator = walkVariants(variant) for (let [variant] of iterator) { // Convert `data-[selected]` to `data-selected` @@ -1083,11 +1129,12 @@ function isAttributeSelector(node: SelectorParser.SelectorAstNode): boolean { } function modernizeArbitraryValuesVariant( - designSystem: DesignSystem, variant: Variant, + options: SignatureOptions, ): Variant | Variant[] { let result = [variant] - let signatures = computeVariantSignature.get(designSystem) + let designSystem = options.designSystem + let signatures = computeVariantSignature.get(options) let iterator = walkVariants(variant) for (let [variant, parent] of iterator) { @@ -1442,7 +1489,7 @@ function modernizeArbitraryValuesVariant( // - `/[100%]` → `/100` → // - `/100` → // -function optimizeModifier(designSystem: DesignSystem, candidate: Candidate): Candidate { +function optimizeModifier(candidate: Candidate, options: SignatureOptions): Candidate { // We are only interested in functional or arbitrary utilities with a modifier if ( (candidate.kind !== 'functional' && candidate.kind !== 'arbitrary') || @@ -1451,7 +1498,8 @@ function optimizeModifier(designSystem: DesignSystem, candidate: Candidate): Can return candidate } - let signatures = computeUtilitySignature.get(designSystem) + let designSystem = options.designSystem + let signatures = computeUtilitySignature.get(options) let targetSignature = signatures.get(designSystem.printCandidate(candidate)) let modifier = candidate.modifier diff --git a/packages/tailwindcss/src/constant-fold-declaration.test.ts b/packages/tailwindcss/src/constant-fold-declaration.test.ts index 1fdbbde81..b95ed74a3 100644 --- a/packages/tailwindcss/src/constant-fold-declaration.test.ts +++ b/packages/tailwindcss/src/constant-fold-declaration.test.ts @@ -72,18 +72,18 @@ it.each([ it.each([ ['0deg', '0deg'], - ['0rad', '0rad'], + ['0rad', '0deg'], ['0%', '0%'], - ['0turn', '0turn'], + ['0turn', '0deg'], ['0fr', '0fr'], - ['0ms', '0ms'], + ['0ms', '0s'], ['0s', '0s'], ['-0.0deg', '0deg'], - ['-0.0rad', '0rad'], + ['-0.0rad', '0deg'], ['-0.0%', '0%'], - ['-0.0turn', '0turn'], + ['-0.0turn', '0deg'], ['-0.0fr', '0fr'], - ['-0.0ms', '0ms'], + ['-0.0ms', '0s'], ['-0.0s', '0s'], ])('should not fold non-foldable units to `0`. Constant fold `%s` into `%s`', (input, expected) => { expect(constantFoldDeclaration(input)).toBe(expected) diff --git a/packages/tailwindcss/src/constant-fold-declaration.ts b/packages/tailwindcss/src/constant-fold-declaration.ts index fc2424ef7..2ae6b2557 100644 --- a/packages/tailwindcss/src/constant-fold-declaration.ts +++ b/packages/tailwindcss/src/constant-fold-declaration.ts @@ -4,38 +4,26 @@ import * as ValueParser from './value-parser' // Assumption: We already assume that we receive somewhat valid `calc()` // expressions. So we will see `calc(1 + 1)` and not `calc(1+1)` -export function constantFoldDeclaration(input: string): string { +export function constantFoldDeclaration(input: string, rem: number | null): string { let folded = false let valueAst = ValueParser.parse(input) ValueParser.walkDepth(valueAst, (valueNode, { replaceWith }) => { - // Convert `-0`, `+0`, `0.0`, … to `0` - // Convert `-0px`, `+0em`, `0.0rem`, … to `0` + // Canonicalize dimensions to their simplest form. This includes: + // - Convert `-0`, `+0`, `0.0`, … to `0` + // - Convert `-0px`, `+0em`, `0.0rem`, … to `0` + // - Convert units to an equivalent unit if ( valueNode.kind === 'word' && - valueNode.value !== '0' && // Already `0`, nothing to do - ((valueNode.value[0] === '-' && valueNode.value[1] === '0') || // `-0…` - (valueNode.value[0] === '+' && valueNode.value[1] === '0') || // `+0…` - valueNode.value[0] === '0') // `0…` + valueNode.value !== '0' // Already `0`, nothing to do ) { - let dimension = dimensions.get(valueNode.value) - if (dimension === null) return // This shouldn't happen + let canonical = canonicalizeDimension(valueNode.value, rem) + if (canonical === null) return // Couldn't be canonicalized, nothing to do + if (canonical === valueNode.value) return // Already in canonical form, nothing to do - if (dimension[0] !== 0) return // Not a zero value, nothing to do - - // Replace length units with just `0` - if (dimension[1] === null || isLength(valueNode.value)) { - folded = true - replaceWith(ValueParser.word('0')) - return - } - - // Replace other units with `0 `, e.g. `0%`, `0fr`, `0s`, … - else if (valueNode.value !== `0${dimension[1]}`) { - folded = true - replaceWith(ValueParser.word(`0${dimension[1]}`)) - return - } + folded = true + replaceWith(ValueParser.word(canonical)) + return } // Constant fold `calc()` expressions with two operands and one operator @@ -124,3 +112,39 @@ export function constantFoldDeclaration(input: string): string { return folded ? ValueParser.toCss(valueAst) : input } + +function canonicalizeDimension(input: string, rem: number | null = null): string | null { + let dimension = dimensions.get(input) + if (dimension === null) return null // This shouldn't happen + + let [value, unit] = dimension + if (unit === null) return `${value}` // Already unitless, nothing to do + + // Replace `0 ` units with just `0` + if (value === 0 && isLength(input)) return '0' + + // prettier-ignore + switch (unit.toLowerCase()) { + // to px, https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Styling_basics/Values_and_units#lengths + case 'in': return `${value * 96}px` // 1in = 96.000px + case 'cm': return `${value * 96 / 2.54}px` // 1cm = 37.795px + case 'mm': return `${value * 96 / 2.54 / 10}px` // 1mm = 3.779px + case 'q': return `${value * 96 / 2.54 / 10 / 4}px` // 1q = 0.945px + case 'pc': return `${value * 96 / 6}px` // 1pc = 16.000px + case 'pt': return `${value * 96 / 72}px` // 1pt = 1.333px + case 'rem': return rem !== null ? `${value * rem}px` : null // 1rem = 16.000px (Assuming root font-size is 16px) + + // to deg, https://developer.mozilla.org/en-US/docs/Web/CSS/angle + case 'grad': return `${value * 0.9}deg` // 1grad = 0.900deg + case 'rad': return `${value * 180 / Math.PI}deg` // 1rad = 57.296deg + case 'turn': return `${value * 360}deg` // 1turn = 360.000deg + + //