fix(themes): emit each theme once so custom themes aren't overridden - #4641
fix(themes): emit each theme once so custom themes aren't overridden#4641JamBalaya56562 wants to merge 1 commit into
Conversation
saadeghi
left a comment
There was a problem hiding this comment.
Thanks.
This PR is not fixing #4488.
I tested the example repo https://github.com/joefefs/daisyui-vite-8-test with this PR applied. there was no difference and the issue still exists.
|
Thanks for testing. I re-verified against current Two things that hide it:
Result — computed
📸 Screenshots (headless Chromium,
|
| initial load | 1st toggle (dark) |
2nd toggle (light) |
|
|---|---|---|---|
master |
![]() |
![]() |
❌ falls back to the built-in light theme |
| this PR | ![]() |
![]() |
✅ stays on the custom theme |
Procedure (~1 min):
# build this branch and pack it
bun install && bun run build
cd packages/daisyui && npm pack --pack-destination /tmp && cd ../..
# install it into the repro
git clone https://github.com/joefefs/daisyui-vite-8-test && cd daisyui-vite-8-test
npm install
npm run build # <-- BEFORE
grep -o ':is(:root:has(input.theme-controller\[value=light\]' dist/assets/*.css | wc -l # => 1
npm install /tmp/daisyui-5.7.4.tgz
rm -rf dist && npm run build # <-- AFTER
grep -o ':is(:root:has(input.theme-controller\[value=light\]' dist/assets/*.css | wc -l # => 0
npx vite preview # click Toggle TWICE -> heading stays redThe rule that disappears, and regression checks
The duplicate emission is promoted to specificity (0,4,1) by lightningcss, which outranks the user's (0,1,0) [data-theme=light] rule:
master: :is(:root:has(input.theme-controller[value=light]:checked),[data-theme=light]){--color-primary:oklch(45% .24 277.023)}
:where(:root),[data-theme=light]{--color-primary:red}
this PR: :where(:root),[data-theme=light]{--color-primary:red} (the :is(...) rule is gone)
No regression on the plain path: with @plugin "daisyui" { themes: all; } all 35 [data-theme=*] rules are still emitted with their built-in colors. bun run build && bun test → 180 pass / 0 fail.
Side note on @pdanpdan's comment
The data-theme="light" on the doctype is indeed invalid, but it only affects the initial paint — and the initial paint is already correct on master. The reported failure comes from toggle.js, which sets the attribute on documentElement correctly, so the doctype isn't the cause here.
Their other observation (that it works when the defaults are set via themes: light --default, dark --prefersdark) is consistent with this mechanism: without default: true, the user's rule also gets :is()-promoted to (0,4,1), ties with the duplicate, and wins on source order.
A theme listed with `--default` was emitted twice: once by the default loop as `:where(:root),:root:has(...),[data-theme=x]`, and again by the "other themes" loop as the plain `:root:has(...),[data-theme=x]` with identical declarations. The `themes: "all"` branch had the same overlap, since `themeOrder` contains `light`/`dark`. That duplicate is what breaks theme customization on Vite 8. Vite derives lightningcss `targets` from `build.target`, and its default `baseline-widely-available` set includes Firefox 104, which has no `:has()` support -- so lightningcss rewrites the selector list to the forgiving `:is(...)`. `:is()` takes the highest specificity of its arguments, promoting `[data-theme=x]` from (0,1,0) to (0,4,1). The default rule's `[data-theme=x]` compound is correctly dropped in favour of the user's later rule, but the duplicate is nobody's subset, survives, and outranks a theme defined with `@plugin "daisyui/theme"`. Hence the reported symptom: right on first paint, wrong after switching `data-theme`. Without `targets` (Vite <=7, plain Tailwind CLI) the rewrite never happens, which is why this looked unreproducible. Track applied themes in a per-invocation Set inside `applyTheme` so each theme is emitted once, keeping the first (`--default`) emission. The `--prefersdark` `@media` rules bypass `applyTheme`, so a `dark --prefersdark` theme still gets its regular `[data-theme=dark]` rule from the "other themes" loop. Verified in Chromium through lightningcss with Vite's default targets: with `data-theme="light"`, `--color-primary` was the built-in color before and is the custom `red` after, while no-attribute and `data-theme="dark"` are unchanged. closes saadeghi#4488 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0afbc4b to
37606d5
Compare






Problem
Closes #4488
Customizing a built-in theme with
@plugin "daisyui/theme"works on first paint, then loses to the built-in colors oncedata-themeis set — but only in a Vite 8 production build.1. daisyUI emits the same theme twice. In
pluginOptionsHandler.js, a theme listed with--defaultis emitted by the default loop as:where(:root),:root:has(...),[data-theme=light], and then again by the "other themes" loop as the plain:root:has(...),[data-theme=light]— identical declarations, subset selector list. Thethemes: "all"branch has the same overlap becausethemeOrdercontainslight/dark.2.
targetsforces an:is()rewrite. Vite derives lightningcsstargetsfrombuild.target, and the defaultbaseline-widely-availableset includes Firefox 104, which has no:has()support. lightningcss therefore wraps such selector lists in the forgiving:is(...)::is()takes the highest specificity of its arguments, so[data-theme=light]jumps from (0,1,0) to (0,4,1).3. Only the duplicate survives. lightningcss drops compounds that a later equal-specificity rule fully overrides. The default rule's
[data-theme=light]is correctly dropped in favour of the user's rule — which is why no-attribute rendering is fine. The duplicate is nobody's subset, survives, gets:is()-promoted, and outranks the user's (0,1,0) rule:BEFORE AFTER :root:has(…[value=light]:checked){--color-primary:blue} (same) @media (prefers-color-scheme:dark){…} (same) - :is(:root:has(…[value=light]:checked),[data-theme=light]){…:blue} ← (0,4,1), wins :is(:root:has(…[value=dark]:checked),[data-theme=dark]){…:black} (same) :where(:root),[data-theme=light]{--color-primary:red} ← (0,1,0), user (same, now wins)Without
targets(Vite ≤7, plain Tailwind CLI) the rewrite never happens — which is why this originally looked unreproducible. It also only bites when the user's theme is declareddefault: true, since that is what makes lightningcss split the user rule down to (0,1,0) for the[data-theme]branch.Fix
Track applied themes in a per-invocation
SetinsideapplyTheme, so each theme is emitted once and the first (--default) emission — the one carrying:where(:root)— is the one kept. One hunk covers both the array path and thethemes: "all"path.The
--prefersdark@mediarules are emitted by rawaddBase, not throughapplyTheme, so adark --prefersdarktheme still gets its regular[data-theme=dark]rule from the "other themes" loop. There's a test pinning that.Before / After (Tailwind Play)
https://play.tailwindcss.com/tUMYJ3OnTw
Play doesn't run lightningcss with
targets, so the demo writes the post-lightningcss output verbatim to show the mechanism: the:is(…)-wrapped duplicate beats the custom rule (Before, blue) and removing it lets the custom rule win (After, red).Verification
Compiled the reproduction through Tailwind, then through lightningcss with Vite's default targets (
chrome107, edge107, firefox104, safari16,minify: true), and read the computed value in Chromium:data-themeunsetdata-theme="light"data-theme="dark"redredred✅:is(:root:has(…[value=light]…)occurrences in the built output: 1 → 0. Uncompressed CSS shrinks ~1.3 KB (one duplicated theme block).Tests:
pluginOptionsHandler.test.jsgoes 7 → 10 passing. Two existing assertions encoded the duplicate emission as expected behavior and are now inverted with a comment; three regression tests are added, including one that pins the emitted selector order so an over-eager dedupe can't silently swallow the--prefersdarktheme's regular rule. Full suite passes (one pre-existing, unrelated docs-translation timeout fails with and without this change).packages/bundle/*holds a compiled copy of this function and is regenerated bybun run bundleat release, so the CDN build picks the fix up at the next version bump.