Skip to content

fix(publisher): read :not() and :is() correctly when tree-shaking class rules - #572

Open
tommy230 wants to merge 1 commit into
CoreBunch:mainfrom
tommy230:fix/tree-shake-selector-semantics
Open

tommy230 wants to merge 1 commit into
CoreBunch:mainfrom
tommy230:fix/tree-shake-selector-semantics

Conversation

@tommy230

@tommy230 tommy230 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A rule that mentions a class only inside :not(), or as one alternative of :is()/:where(), is dropped at publish when that class is not in use on the site. The stylesheet reads back correctly and the canvas renders it; only the live site loses the styling.

A theme pads every nav link with

.nav > li > a:not(.cart-button) { padding: 0 20px }

No node on the site carries .cart-button, so publish drops the rule and every nav link sits flush against the next. Likewise :is(.a, .b) p is read as .a.b p and is dropped when only .b is in use.

The cause is in the tree shake of the class registry, which drops an ambient rule when its selector names a registry class that no node uses. selectorPartCanMatch in src/core/publisher/styleRuleTreeShake.ts scans the whole selector part with extractCssSelectorClasses and fails on any known-but-unused class, so it reads a negated class as a requirement and :is() alternatives as all required at once. CssSelectorClassToken.functionalDepth is not enough to fix it: depth cannot tell :not(.x) (never required) from :has(.x) or :nth-child(n of .x) (required), so the pseudo name has to be read.

The fix:

  • Split each selector-list part into the text whose classes are all required and the :is()/:where() groups where one alternative is enough. :not(...) is removed outright, at any nesting. Quoted strings, attribute selectors and escapes are respected.
  • :has() and :nth-child(n of ...) arguments stay required. Malformed pseudos (unbalanced parens, empty :is()) fall to "keep".

For any selector the shared class lexer reads correctly, this only keeps more CSS than before, never less. A part is dead only if a known-unused class remains in the required text or every alternative of some :is() group is dead, and both of those were already grounds for dropping under the old whole-part scan.

The one exception is an escaped quote in an unquoted attribute value inside a removed pseudo, such as .nav a:not([title=Don\'t]) .gone. The old scan read the \' as an opening quote, went blind for the rest of the selector and kept the rule by accident; the new scan reads past it and drops the rule, which is correct.

Verification

  • bun run build
  • bun run lint
  • bun test: 7060 pass, 0 fail
  • Docker/deployment check, if relevant: not relevant

New tests in src/__tests__/publisher/styleRuleTreeShake.test.ts: 3 of 7 fail on main and pass with this change.

Checklist

  • Tests cover behavior changes.
  • Docs were updated when behavior, config, deployment, or public surfaces changed. (docs/features/publisher.md.)
  • No compatibility shim was added for old pre-release behavior.
  • No secrets, local databases, uploads, or generated artifacts are included.

🤖 Generated with Claude Code

@tommy230
tommy230 force-pushed the fix/tree-shake-selector-semantics branch 2 times, most recently from 9ff93c2 to d1a9947 Compare September 29, 2026 00:02
…ss rules

The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.

selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tommy230
tommy230 force-pushed the fix/tree-shake-selector-semantics branch from d1a9947 to 7dc8292 Compare September 29, 2026 04:45
@tommy230 tommy230 changed the title fix(publisher): read :not() and :is()/:where() by their semantics in the style tree shake fix(publisher): read :not() and :is() correctly when tree-shaking class rules Sep 29, 2026
@tommy230
tommy230 marked this pull request as ready for review September 29, 2026 04:47

This branch has not been deployed

No deployments
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.

1 participant