Skip to content

fix(native): answer an ancestor chain as a path, not as a set of names - #463

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/ancestor-chain-order
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/ancestor-chain-order

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Problem

A container is registered by NAME and every ancestor compound is resolved in the subject's own scope, so a chain asks "are both names somewhere above me" rather than Selectors 4 §14.1's narrower question — and .outer .inner .x therefore matches the reversed nesting too.

// src/native/reactivity.ts
export type ContainerContextValue = Record<string, WeakKey>;
// src/native/conditions/container-query.ts
return queries.every((query) => {
  return testContainerQuery(query, inheritedContainers, guards, get);
});

Measured on main with .outer .inner .subject { color: red }:

nesting should does
<outer><inner><subject> match {"color":"#f00"}
<inner><outer><subject> not match {"color":"#f00"}
<inner/><outer><subject> not match undefined

The third row is why this is a defect rather than a missing feature: the set is answered correctly and only the ORDER is lost. And it is the shipped group-* path — stacked ancestor variants are what Tailwind emits.

Solution

The scope a container registers in is already in hand at registration time; nothing was keeping it. Carry it, and the chain has something to walk:

export interface ContainerRegistration {
  readonly key: WeakKey;
  readonly scope: ContainerContextValue;
}

testContainerQueries then walks innermost first, resolving each compound in the scope of the one inside it. A single-compound selector resolves exactly as before — every group-hover: / group-data-*: utility in use today.

Worth stating, because the obvious guard shape does not work: a guard recorded against the scope THAT link resolved in makes testGuards — which re-checks container guards against the ELEMENT's scope — disagree every pass, re-rendering until React refuses. Guards stay on the element's own scope, the only one it can re-read and where every chain name is inherited anyway.

Tests

src/__tests__/native/ancestor-chain-order.test.tsx, 13 cases: two pin the compiled shape the walk depends on, eight drive the nesting (ordered, reversed, siblings, intervening elements, a three-deep chain with one pair swapped, both class names on one element), three pin what must not move (a single ancestor, an interaction condition on each compound, an idempotent re-render). The reversed case is the mutation proof — red on main for the reason above.

Every ancestor test in the suite uses ONE ancestor, where a name lookup and a path walk cannot disagree. Nothing rendered two ancestors in both nestings.

Mutation-proved: reverting the src/ diff and re-running these files alone turns 3 of 13 red.

Verification

yarn test 1061 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

Residual, stated: an outer .a shadowed by a nearer .a that is not an ancestor of .b can leave a guard stale — narrower than today's behaviour, which is wrong for every reversed nesting.

Independent of #462, which answers a classless ancestor's condition in the compiler; this answers the ORDER of any chain in the runtime. They touch no common file, so whichever lands last needs no rebase.

No existing issue tracks this — searched the tracker for descendant combinator, container query order, group- and ancestor nesting, zero hits.

Base

Branched off f70c402. main has since taken #451 (a5002c5). 3 of the 5 files this changes also moved there, and 3 genuinely conflictsrc/native/conditions/container-query.ts, src/native/react/rules.ts, src/native/reactivity.ts. Every measurement above was taken on f70c402. Say the word and I will re-apply it onto current main.

`ContainerContextValue` mapped a container name to the nearest element that
registered it, and `testContainerQueries` resolved every compound of a
selector in the element's own scope. So `.outer .inner .x` asked whether
both names were somewhere above rather than whether the `.inner` ancestor
itself had an `.outer` ancestor, and the rule matched under reversed
nesting.

A registration now carries the scope it was made in, and the walk goes
innermost first, resolving each compound in the scope of the one inside it.
A single-compound selector resolves exactly as before.

Guards stay keyed on the element's own scope: it is the only one this
element can re-read, and every name the walk reaches is inherited by it, so
a change to any of them is observable there. Recording a guard against the
scope a link was resolved in instead never agrees on re-check and re-renders
the tree until React refuses.
@YevheniiKotyrlo

YevheniiKotyrlo commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The first two bars are red. A chain asks only whether both container names are somewhere above the subject, so .outer .inner .probe matches the reversed nesting too.

FIXED — Only the first bar is red. The chain resolves innermost-first, each compound in the scope of the one inside it — Selectors 4 §14.1's actual question.

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

Read the ancestor-chain row. One rule governs all three bars — .chain-outer .chain-inner .chain-probe over a blue base — and they differ only in how their ancestors nest: outer > inner, inner > outer, and a sibling subtree holding neither.

The first and third bars are the anti-vacuity pair, and they do not move. The first is red in both frames, so the chain compiled and matched at all; the third is blue in both, so a subject with no such ancestors was never matching. Only the second — the reversed nesting — flips.

That third bar is also why this is a defect rather than a missing feature: the ancestor set is answered correctly today and only the order is lost.

The other five 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.

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