---
status: accepted
---

# Editor-scoped `:has()` is the v5 runtime-perf hazard; reduce it

The v5 CMS UI feels frozen during editing while v4 is smooth, on the same page/DOM/machine. The cause is **`:has()` selector invalidation**: v5's stylesheet authors ~782 distinct `:has()` selectors (993 occurrences) vs v4's 41, and the browser re-tests `:has()` on every DOM mutation and every change to a class/attribute used as a `:has()` argument — which the editor JS does constantly. Each such interaction costs **~250 ms in v5 vs ~0–2 ms in v4**; expanding a Subscriptions item makes it ~16× worse by bringing the `:has()`-dense `ContentForm` subtree live. **Decision: treat the editor-subtree `:has()` as a runtime-perf hazard and reduce the ~160 rules scoped to `.CIG`/`.RCIG`/`.EIG`/`ContentForm`** (rewrite to `:focus-within`, JS/server-toggled classes, `:where()`/sibling selectors), preserving the structural `display:none` hides. Full evidence and rewrite recipe: [`plans/v5-css-has-runtime-perf-investigation.md`](../../plans/v5-css-has-runtime-perf-investigation.md).

## Considered options

All measured via live A/B (v5 `jpencola` vs v4 `joe`, same page), median forced-reflow cost per post-expand interaction:

- **Do nothing.** Stock v5 ≈ 507 ms/interaction (v4 ≈ 1 ms). **Rejected** — this is the reported "frozen" UX.
- **Rewrite only `:has(:focus)` / `:has(.is-*)` (the dynamic patterns, 149 rules).** 507 ms → ~375 ms (**1.4×**). **Rejected** — necessary mechanism but far short; cost is the aggregate `:has()` count in scope, not one category.
- **Remove all `:has()` (802 rules).** ~21 ms (**~25×**). **Rejected as the fix** — strips structural `display:none` hides and component styling system-wide; proves the lever but breaks layout/behavior.
- **Reduce the ~160 editor-scoped `:has()` (`.CIG`/`.RCIG`/`.EIG`/`ContentForm`).** ~22 ms (**~25×**, = full benefit) while leaving the other 768 untouched. **Chosen** — 16 % of the rules recover ~96 % of the speed, on a bounded surface (the editor CSS files).

## Consequences

- The work is a **design-system `:has()` diet** in `src/legacy/tool-ui/src/` editor CSS (`ContentEdit.css`, `ContentInputGroup.css`, `ContentForm.css`, `RepeatableContentInputGroup/Selector.css`, `Widget.css`, …), not a one-line change. Behavior-sensitive: `:has(:focus)`→`:focus-within` is equivalent, but structural `:has(> child)` and `:has(.is-*)` rewrites must keep the same visual result (especially `display:none` hides) or layout regresses.
- Distinct from byte-bloat work (ADR 0002 and the bloat investigation): reducing `:has()` also trims ~185 KB, but the **runtime** win comes specifically from cutting the editor-subtree `:has()` count — size reduction alone (e.g. icon emission) would not fix the jank.
- `:has()` becomes a reviewable cost in the editor surface: new `:has()` in CIG/RCIG/ContentForm CSS should be justified or replaced with a class, since the cost scales with how many `:has()` cover an interacted subtree.
- The lever was first proven via live in-container CSS surgery (the proxy); the shipped fix is the source rewrite below, rebuilt through the real compilers and re-measured on the same page.

## Outcome (implemented 2026-06-02, branch `fix/v5-css-bloat`)

Behavior-preserving rewrite of the 42 editor-scoped `:has()` source rules: `:has(:focus…)` → `:focus-within`; `:has(+ .CIG-row)` / error-border → `:not(:last-child)`; one inert `transition-delay` rule dropped; everything structural (`:has(> child)`, `:has(~ sibling)`, `:has(.is-*)`) → a runtime class set by a new self-contained module `src/legacy/tool-ui/src/editorHasCompat.ts` — an idempotent, debounced sweep (`querySelectorAll` + `classList.toggle`) driven by ready + the editor's `brightspot-content-state-change` event + a 120 ms `MutationObserver`, **not** Blink's per-mutation `:has()` re-test. (FormFilter marks `.is-filterResultAncestor` on result ancestors in JS, replacing `:has(.is-filterResult)`.)

- **Built-Monolith count (PostCSS walk):** total `:has(` **993 → 765**; editor-scoped (`.CIG/.RCIG/.EIG/ContentForm`) **223 → 3**. The 3 residuals are the self-contradictory `RCIG-list:empty:has(ol,ul)` ContentForm placeholders (`:empty` + `:has(children)` → never match), absent from normal content-edit surfaces.
- **Runtime verdict — human A/B, per the [microbenchmark pitfall](../../plans/v5-css-has-runtime-perf-investigation.md):** editor went from janky to "a lot better / acceptable." Synthetic forced-reflow probes do **not** reproduce the jank on Chrome 148 and must not be used to confirm it.
- **Confirmed by Chrome DevTools *Selector stats* under real editor churn (sorted by invalidation count):** **no rewritten editor `:has()` appears** — `has-formContent` et al. are plain classes now, so they don't invalidate. The only editor-scoped `:has()` in the trace are the 3 inert ContentForm `:empty:has` residuals, at ~748 invalidations but **~0.1 ms each** (`:empty` + `:has(children)` bails instantly — confirmed harmless). The top invalidators are all sub-1.1 ms: `:hover`/`:focus-within` recalc (the pattern this rewrite adopts) and tiny-scope out-of-scope page-chrome `:has()` (calendar / preview / page-header). The per-interaction `:has()` invalidation storm is gone; cost is now ordinary recalc.
- **Correctness:** for each runtime class, `querySelectorAll('.has-X')` equals the original `:has()` selector element-for-element — 11 classes confirmed EQUAL across Users / Blog Post / Dashboard, the `has-cig` setter and the live `MutationObserver` path confirmed by injection, no console errors. Found and fixed one gap during verification: `DashboardWidget`'s `form:has(.CIG)` → `form.has-cig` rewrite had no setter (added).
- **Follow-up (out of scope, file separately):** the single most expensive selector in the trace, `.is-imagePreview:not(:has(.ImageEditor-groups.is-focus-active))` (~46 ms, 0 invalidations), is a pre-existing ImageEditor `:has()` outside the `.CIG/.RCIG/.EIG/ContentForm` scope; 7 ImageEditor-related `:has()` remain as a candidate next pass.
