Skip to content

fix(themes): emit each theme once so custom themes aren't overridden - #4641

Open
JamBalaya56562 wants to merge 1 commit into
saadeghi:masterfrom
JamBalaya56562:fix-duplicate-theme-rules
Open

fix(themes): emit each theme once so custom themes aren't overridden#4641
JamBalaya56562 wants to merge 1 commit into
saadeghi:masterfrom
JamBalaya56562:fix-duplicate-theme-rules

Conversation

@JamBalaya56562

Copy link
Copy Markdown
Contributor

Problem

Closes #4488

Customizing a built-in theme with @plugin "daisyui/theme" works on first paint, then loses to the built-in colors once data-theme is set — but only in a Vite 8 production build.

1. daisyUI emits the same theme twice. In pluginOptionsHandler.js, a theme listed with --default is 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. The themes: "all" branch has the same overlap because themeOrder contains light/dark.

2. targets forces an :is() rewrite. Vite derives lightningcss targets from build.target, and the default baseline-widely-available set includes Firefox 104, which has no :has() support. lightningcss therefore wraps such selector lists in the forgiving :is(...):

no targets           :root:has(input.theme-controller[value=light]:checked),[data-theme=light]{…}
chrome107…firefox104 :is(:root:has(input.theme-controller[value=light]:checked),[data-theme=light]){…}

: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 declared default: 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 Set inside applyTheme, 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 the themes: "all" path.

The --prefersdark @media rules are emitted by raw addBase, not through applyTheme, so a dark --prefersdark theme still gets its regular [data-theme=dark] rule from the "other themes" loop. There's a test pinning that.

The Set is deliberately inside the returned function rather than next to firstRun in the IIFE closure — module-level state would make every build after the first emit no theme rules at all.

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-theme unset data-theme="light" data-theme="dark"
before red built-in color built-in dark
after red red built-in dark (unchanged)

: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.js goes 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 --prefersdark theme'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 by bun run bundle at release, so the CDN build picks the fix up at the next version bump.

@saadeghi saadeghi self-assigned this Jul 28, 2026

@saadeghi saadeghi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@JamBalaya56562

JamBalaya56562 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for testing. I re-verified against current master (5.7.4) and the fix does change the outcome — I think the difference was hidden by how the repro was run.

Two things that hide it:

  1. It has to be a production build. The bug only exists after lightningcss runs with Vite's targets. In npm run dev the served CSS contains zero :is( rewrites, so before/after are identical there. Use npm run build + npx vite preview.
  2. The toggle has to be clicked twice. Initial load (red) and the first click (dark, also red) look correct even without the fix. Only the second click — back to data-theme="light" — exposes it.

Result — computed color of the h1, production build, daisyUI 5.7.4 packed from master vs. this branch:

initial load 1st toggle (dark) 2nd toggle (light)
master rgb(255,0,0) rgb(255,0,0) oklch(0.45 0.24 277.023)
this PR rgb(255,0,0) rgb(255,0,0) rgb(255,0,0)
📸 Screenshots (headless Chromium, vite preview)
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 red
The 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>
@JamBalaya56562
JamBalaya56562 force-pushed the fix-duplicate-theme-rules branch from 0afbc4b to 37606d5 Compare August 6, 2026 11:37
@JamBalaya56562
JamBalaya56562 requested a review from saadeghi August 6, 2026 23:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Theme customization colors overridden by built-in theme when using Vite 8 in production builds

2 participants