Skip to content

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

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

YevheniiKotyrlo wants to merge 2 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 in that compound writes its condition onto it — but only the class arm ever attaches that ref to the rule, so an ancestor identified by a condition alone is discarded and the rule applies unconditionally.

// src/compiler/selector-builder.ts
      } else {
        let containerQueries = containerQueryMap.get(root);
        
        if (!ref.n) {
          containerQueries.unshift(ref);
        }

Measured, the cq each rule compiles to:

selector before after
[data-state="on"] .x nothing — applies in every state [{"a":[["d","state","=","on"]]}]
:hover .x nothing [{"p":{"h":1}}]
:disabled .x nothing [{"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 what make it a defect rather than a limit. parseIsWhereComponents already emits an unnamed container query for the :where(… *) spelling, and the class arm already attaches a named one — so two spellings of one selector disagree, and the one an author hand-writes is the one that fails open.

Normatively the ancestor half is Selectors 4 §14.1 — a descendant combinator asks for an ancestor matching the whole compound to its left, so a condition in that compound is part of what must match rather than decoration on it.

Solution

Attach when a condition is written, not only when a class names it. attachContainerQuery is idempotent, so a compound carrying both attaches once and the named path stays byte-identical.

function attachContainerQuery(root: PartialSelector, ref: PartialSelector | ContainerQuery): void {
  if (!isContainerQuery(ref)) { return; }
  let containerQueries = containerQueryMap.get(root);
  
  if (!containerQueries.includes(ref)) { containerQueries.unshift(ref); }
}

The condition then resolves against DEFAULT_CONTAINER_NAME exactly as the :where() spelling does; with no container above, testContainerQuery answers false, so a selector this engine cannot answer fails closed instead of open.

Tests

src/__tests__/compiler/ancestor-conditions.test.ts — 13 cases: each row above, a chain of two classless ancestors, a named and a classless one together, a multi-class compound still yielding one query, that the combinator spelling now compiles to what :where() already did, that a compound on ONE element stays the subject's, and that an unanswerable combinator still drops the rule.

src/__tests__/native/ancestor-conditions.test.tsx — 7 cases through the real runtime: withheld under a container that is not hovered and applied when it is, withheld where no ancestor can answer it, withheld from a sibling's subtree, and a named ancestor still answering from its own element.

Mutation-proved: restricting the attach to a named ref — the pre-fix behaviour — turns 13 of the 20 red.

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 always correct. The classless form is what an author hand-writes in a plain stylesheet, and nothing exercised it.

Verification

yarn test 1068 passed · yarn typecheck 0 · yarn lint 0. The three failures are two babel suites that fail identically on an untouched main worktree (Windows-only module-specifier rewrites) — this touches no babel file.

Known limits

An unnamed query resolves the nearest ancestor that established a container, not any ancestor — under-matching, and the same approximation :where(… *) already makes. It cannot go further because the stylesheet is class-keyed: .a .x emits ["a", [{c:["g:a"]}]] and makes the ancestor addressable, while [data-p] .x emits no such rule, so an element carrying no class the sheet names can register nothing. Making every element addressable is a wire-format decision.

testContainerQuery's attribute arm is commented out on main, so an emitted a query is not yet evaluated — #442 is that half. The p arm is evaluated today, so :hover / :active / :focus ancestors are fully fixed here.

Independent of #463, which answers the ORDER of an ancestor chain in the runtime. They touch no common file.

Base

Branched off f70c402. main has since taken #451 (a5002c5). 1 of the 3 files this changes also moved there (src/compiler/selector-builder.ts), and it still merges cleanly onto current main. Every measurement above was taken on f70c402. Say the word and I will re-apply it onto current main.

`parseComponents` opens a container-query ref at a descendant combinator
and every later component in that compound writes its condition onto it,
but only the `class` arm attached that ref to the rule. A compound
identified by a condition alone — `[data-state="on"] .x`, `:hover .x`,
`:disabled .x` — therefore wrote onto an object nothing read, and the
rule shipped with no condition: it applied to every element its class
named, in every state.

Attach the ref when a condition is written onto it, idempotently, so a
compound carrying both a condition and a class attaches once. The
condition then resolves against the default container, which is what the
`:is()` / `:where()` spelling of the same selector already compiled to,
and an absent container answers false rather than true.
@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 carries no cq at all and applies under every ancestor rather than under the one the selector names.

FIXED — The first bar is red and the other two are blue. [data-state="on"] .probe applies only under an ancestor that carries the attribute.

before — this build, minus this PR after — with this PR

Read the ancestor-condition row. All three bars carry the identical class list over a blue base, and differ only in their own ancestor: one carries data-state="on", one carries data-state="off", and one has no ancestor container at all.

The third bar is what makes the pair mean something. Two bars that had both lost the rule would agree perfectly, so the second flipping proves nothing alone. The third says the withheld case looks like nothing — no ancestor can answer the condition, and the rule fails closed rather than open.

The other four rows are byte-identical between the frames: before is this same build with only this PR's hunk reverted, so exactly one variable differs.

Both frames: pooled Android 36 emulator, 1140×2400 @ 480dpi, dark scheme, same run.

The attribute travels on dataSet rather than as a raw data-*, because a raw one reaches nothing on either platform: this library compiles the selector to a dataSet query on native, and react-native-web filters host props through an allowlist carrying dataSet and no raw data-*.

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