Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

149. A state invalidates what it can reach

Date: 2026-08-19

Status

Accepted. Narrows ADR-0070’s invalidation, which was conservative by an amount nobody had measured until ADR-0146 put the number on screen.

Context

Reported as “if I just click on empty space the paint and styling keep adding a lot of ms each click”. Measured through the real launcher, on the showcase’s own tree: 74 of 78 elements re-resolved on every click, and style sat at 12 ms a frame.

:hover and :active apply to the whole ancestor chain — .card:hover .title has to work — so a click on empty space marks every node from the deepest one to the root. Each of those called Element.invalidateStyle, which throws away the whole subtree’s cached styles on the grounds that a descendant combinator can make a node’s match depend on an ancestor’s state. For a node near the root, that subtree is the window.

ADR-0070 said so plainly: “Conservative on purpose. Working out which descendants a rule could reach is real machinery, and this walk is pointer-chasing against a cascade pass that costs hundreds of times more.” The walk is cheap; what it triggers is not, and it triggered all of it.

Decision

A state change invalidates the subtree only when some rule can read that state through a descendant combinator, on a node of this type.

StyleResolver walks every selector once, at construction, and records which pseudo-classes appear to the left of a combinator and on what type: checkbox:hover check-indicator puts checkbox under :hover. Then

if (resolver.reachesDescendants(pseudoClass, type())) { … invalidate the subtree }

Two answers are deliberately conservative. An ancestor compound naming no type — .section:affixed > affix-content, in the showcase’s own sheet — cannot be narrowed, so its pseudo-class reaches everything. And a resolver that has not been seen yet reaches everything, which is the first frame.

A node with no CSS type reaches nothing, and getting this wrong is what made the first attempt worthless. The hover chain is full of composition nodes; a compound that could match one names no type either, and every such compound is already in the untyped set. Returning “unknown, be conservative” for them left the whole tree re-resolving with the narrowing in place and the measurement unchanged.

The resolver comes from the tree, not from the node. A node’s own resolver is half of its cache key and is cleared when the entry is thrown away — so the question could not be asked at the moment it matters, which is a press whose release arrives before the next frame. ElementTree carries the resolver the renderer is using; that is a fact about the tree, and invalidating a style does not change it.

Measured, clicking empty space on the showcase’s Controls screen: 74 elements re-resolved per click → 3, and style from 12.4 ms → 2.5 ms.

Consequences

The remaining 2.5 ms is transitions, not the cascade. controls.css puts transition on most controls, and a click that changes :hover and :active genuinely starts and settles them. That is work the frame asked for.

A stylesheet can make this expensive again, and honestly so: writing column:hover x puts column back in the set for :hover. That is the rule being paid for by the rule that needs it, which is the right way round.

Only the state path is narrowed. Element.update — a rebuilt widget with possibly different classes — still invalidates the subtree wholesale, because what changed there is the node’s identity to the cascade rather than one bit of its state.

The test asserts by identity, not by time. StyleIdentityTest marks :hover on a parent no rule reads through and asserts the child was handed the same ComputedStyle instance — the thing the cache is keyed on — and asserts the opposite with poisoner:hover recorder in the sheet.