Skip to content

fix(compiler): answer an ancestor condition that names no class - #462

Open
YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/unnamed-ancestor-conditions
Open

YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/unnamed-ancestor-conditions

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A descendant combinator opens a container-query ref, and every later component of that compound writes its condition onto it. Only the class arm attaches the ref to the rule, so an ancestor identified by a condition alone is discarded and the rule applies to every element the class names, in every state. The container queries each rule compiles to on main:

selector main here
[data-state="on"] .x none, so it applies in every state [{"a":[["d","state","=","on"]]}]
:hover .x none [{"p":{"h":1}}]
:disabled .x none [{"a":[["a","disabled"]]}]
.g[data-state="on"] .x [{"a":[…],"n":"g:g"}] unchanged
.x:where([data-state="on"] *) [{"a":[["d","state","=","on"]]}] unchanged

The last two rows are why this is a defect rather than a limit. The class arm attaches a named query and parseIsWhereComponents already emits an unnamed one for the :where(… *) spelling, so two spellings of one selector disagree, and the one an author writes by hand fails open. A descendant combinator asks for an ancestor matching the whole compound to its left (Selectors 4 §14.1), so a condition in that compound is part of the match.

Solution

The ref is attached when a condition is written, not only when a class names it. attachContainerQuery adds a ref once, so a compound carrying both a class and a condition attaches one query and the named path compiles as before.

The condition then resolves against the nearest container, as the :where() spelling does. With no container above, testContainerQuery answers false, so a selector the runtime cannot answer fails closed.

Tests

  • src/__tests__/compiler/ancestor-conditions.test.ts: each row above, a chain of two classless ancestors, a named and a classless ancestor together, a multi-class compound yielding one query, the combinator compiling to what :where() does, a compound on one element staying the subject's, and an unanswerable combinator still dropping the rule.
  • src/__tests__/native/ancestor-conditions.test.tsx, through the runtime:
    • hover and press withheld until the container is hovered or pressed
    • [data-state="on"] and :disabled ancestors following the container's props both ways
    • withheld where no ancestor can answer and from a sibling's subtree
    • a named ancestor still answering from its own element

On main, 14 of the 22 cases in the two files fail; all pass here. Removing the attach from the hover, attribute and disabled arms fails their own cases.

Every ancestor-state test in the suite names a class, because that is what Tailwind emits: group-hover: compiles to .group:hover .x, which was already correct. The classless form is what an author writes in a plain stylesheet.

Verification

On Windows with Node 26:

  • yarn lint clean
  • yarn typecheck clean
  • yarn test --maxWorkers=2 --coverage: 1353 passed, 4 failed. The 4 are the babel cases that also fail on main on this machine.
  • yarn build clean
  • yarn example expo export --platform web exported
  • nothing unstaged after either build

No existing issue tracks this. I searched for ancestor attribute selector, data- descendant, hover descendant, group-hover and parent hover.

Device evidence, before and after: #462 (comment)

Known limits

An unnamed query resolves the nearest ancestor that established a container, not any ancestor. That under-matches, as :where(… *) already does. Going further needs every element to be addressable, and the stylesheet is class-keyed: an element carrying no class the sheet names registers nothing.

Independent of #463, which decides the order of an ancestor chain at runtime. They touch no common file.

Merge order

It shares lines with #448 (compiler/selector-builder.ts), #459 (compiler/selector-builder.ts); whichever lands second rebases.

Base

Re-written on main (a5002c5).

@YevheniiKotyrlo

YevheniiKotyrlo commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — All three bars are red. The classless ancestor’s condition is written onto a ref nothing reads, so the rule compiles with no condition and applies under every ancestor.

FIXED — Only the first bar is red. [data-state="on"] .probe applies under the ancestor that carries the attribute, and nowhere else.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

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