Skip to content

[babel-plugin] only substitute referenced constants in processStylexRules#1700

Open
henryqdineen wants to merge 1 commit into
facebook:mainfrom
henryqdineen:hqd-process-rules-targeted-consts
Open

[babel-plugin] only substitute referenced constants in processStylexRules#1700
henryqdineen wants to merge 1 commit into
facebook:mainfrom
henryqdineen:hqd-process-rules-targeted-consts

Conversation

@henryqdineen

@henryqdineen henryqdineen commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

What changed / motivation ?

This PR makes processStylexRules substitute only the constants each rule actually references, instead of replacing every constant in constsMap into every rule.

That loop was O(rules × constsMap) and dominated the function. On a real ~15.7k-rule, ~3.3k-const HubSpot production app, processStylexRules took ~9.2s — almost all of it in this loop. This PR brings it to ~29ms (~317×); output was byte-identical on our app. The only behavior changes are the narrow edge cases detailed below.

A single targeted pass is correct because the existing resolveConstant step already collapses constant alias chains up front, so each value is already fully resolved — substituting every referenced var once is enough, with no need to re-scan to follow chains.

It also fixes a latent nondeterminism: the old override-key rewrite (#1219) was order-dependent for multi-hop const→const alias chains. That's an edge case — the common pattern (a const aliasing one externally-defined variable) is identical before and after. Details in Additional Context.

Linked PR/Issues

Relates to #1219 (var() overrides — using a const as a property key to override the var it aliases) and #1460 (---prefixed verbatim const keys, which let one const alias another by name). Together they're the source of the const aliasing / override behavior this change has to preserve. No separate tracking issue.

Additional Context

This is an edge-case behavior change. The common pattern — a const aliasing a single externally-defined variable (and how we use it) — is byte-identical before and after. Output only changes for multi-hop const→const chains (one const naming another — which needs the verbatim -- keys from #1460) or rules with multiple interacting override declarations, and only with fallback-form refs (var(--x, fallback)); bare chains are pre-collapsed by resolveConstant, so they're identical on both branches. The full delta is exactly three tests (see Tests).

The mechanism: a defineConsts value can be a var(...); used as a property key it emits a declaration overriding that variable (#1219), and processStylexRules rewrites the declared key to the variable the const aliases. The old loop applied that rewrite cumulatively across all of constsMap, so for a chain it followed a variable distance depending on order:

--blue500:      #3b82f6
--brandPrimary: var(--blue500, #3b82f6)
--primary:      var(--brandPrimary, #3b82f6)

Overriding --primary emitted --brandPrimary: one way and --blue500: the other — same source, different CSS. Now the override-key rewrite is a single pass resolving each declaration one independent step, so it targets the variable the value actually reads and can't cascade or depend on order.

Key grammar. The per-rule scan matches any custom-property key, including non-ASCII (e.g. --café), so terminal non-ASCII keys substitute correctly. resolveConstant is unchanged (byte-identical to main).

⚠️ Limitations we found (left as-is — flagging for you): the old loop substituted by exact var(--key) string, matching a key of any shape, so vs that:

  • non-ASCII const→const alias chains resolve only one step here (terminal non-ASCII keys are fine) — resolveConstant only pre-collapses ASCII chains, and we left it untouched;
  • names with escaped delimiters (--foo\:bar) aren't matched.

Both are likely rare and predate this change. We're happy to go either way: make the scan ASCII-only to match resolveConstant (consistent and simpler — drops non-ASCII const support entirely), or widen resolveConstant to handle non-ASCII chains fully. (An escape-aware grammar would cover the escaped-delimiter case.)

Benchmark. We captured the exact Rule[] one production HubSpot app hands processStylexRules (15,690 rules — 3,281 constant + 12,409 CSS) and timed main vs this PR on it (median of 7, byte-identical output, 326,173 bytes): 9,152ms → 28.8ms (~317×). The change trades O(rules × constsMap.size) (a replaceAll per constant per rule) for O(var/declaration tokens) (a scan + an O(1) lookup per var(--…) / --…: token), so cost no longer grows with constant count.

Cost model, sweeps, and the one slower case

New cost tracks how many var() references the rules contain, not how many constants exist. Atomic rules are a single declaration each, so they reference only a handful of vars (our dump averaged ~0.4 per rule). Each row below varies one axis and feeds both builds identical input (output byte-identical in every case); rules = total rules, consts = number of constants, refs/rule = var() references per rule:

varying old new
constants 10 → 3,000 (20k rules, 1 ref each) 43ms → 5,308ms ~31–34ms (flat)
refs per rule 0 → 10 (20k rules, 100 consts) 181ms → 428ms 25ms → 84ms
rules 5k → 100k (200 consts, 1 ref each) 93ms → 1,858ms 7.5ms → 165ms

Old time grows with the number of constants; new time stays flat and only grows with how many var() refs the rules actually contain.

The new code is only slower when there are very few constants and a rule is packed with many non-constant var() refs (e.g. 3 constants and 30 refs in one rule → ~1.7×, 41→70ms). It takes both at once, atomic CSS produces neither (one declaration per rule), and even then it's only tens of ms.

We considered closing even that case: instead of scanning for every var(--…) and checking each against the map, build one regex from the actual constant names — var\((--brandPrimary|--accent|…)\) — so the scan only matches real constants. Measured, it's about even on our dump and ~10× faster in the packed case (and stays fast even with thousands of constants). We kept the simpler constsMap.get(match) here for readability.

Tests: all existing processStylexRules tests pass unchanged. Added coverage: value substitution, single-alias overrides, bare/fallback chain resolution, non-ASCII keys (+ the unresolved-chain limitation), no-reference / no-constant paths, and an end-to-end cross-file case (a defineConsts @media breakpoint consumed via a real import) through transform(), snapshotting metadata + CSS.

Exactly three of the added tests fail on main — the complete behavior delta:

  • order-independence of a chained override rewrite
  • one-step (non-cascading) multi-declaration rewrite
  • non-ASCII alias chain (main over-resolves it)

Questions for maintainers

  1. Semantics: is single-step override resolution the intended behavior — an override targets the variable the value resolves to read (one alias link), not the chain's terminal variable?
  2. Opt-in? The old order-dependent behavior reads more like a quirk than a contract, and StyleX is pre-1.0 — so our instinct is to just ship the new behavior. If you'd rather preserve the current output by default, should it be gated behind an option to processStylexRules (new behavior opt-in)?
  3. Const-key opacity ([babel-plugin] Allow -- prefixed keys in defineConsts to preserve user-authored names #1460): every output-changing case here requires referencing a verbatim ---prefixed const key directly — one const aliasing another by name, used as an override key, via a fallback-form chain. If verbatim const keys are meant to be opaque (an implementation detail, not a stable name to alias/override by), these scenarios are arguably out of contract and the behavior change is moot. Is relying on verbatim const-key names intended, or something to discourage? It would let us drop the most esoteric tests here.

Pre-flight checklist

  • I have read the contributing guidelines
  • Performed a self-review of my code

🤖 Generated with Claude Code

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 5, 2026
@vercel

vercel Bot commented Jun 5, 2026

Copy link
Copy Markdown

@henryqdineen is attempting to deploy a commit to the Meta Open Source Team on Vercel.

A member of the Team first needs to authorize it.

@henryqdineen henryqdineen marked this pull request as ready for review June 5, 2026 02:42
…ules

processStylexRules looped over every constant in constsMap and replaced it in
every non-constant rule -- O(rules x constsMap). On a real ~15.7k-rule,
~3.3k-const build that was ~7.5s, almost entirely this loop. Substituting only
the references actually present in each rule drops it to ~28ms (~27% of rules
reference a constant, ~1.1 distinct refs each).

It also makes override resolution deterministic. A defineConsts value can be a
var(...); used as a property key it overrides that variable in scope (facebook#1219),
and processStylexRules rewrites the declared key to the variable the const
aliases. The previous loop applied that rewrite cumulatively across all of
constsMap, so for chained aliases it followed the chain a variable distance
depending on order. Now:
  - value substitution runs once per referenced var (values are pre-resolved by
    resolveConstant; only var(--x, fallback), which is overridable, is left); and
  - the override-key rewrite is a single pass that resolves each declaration one
    independent step, so it cannot cascade across declarations.

The per-rule scan matches any custom-property key, including non-ASCII (e.g.
--café), so those substitute correctly. resolveConstant is left untouched, so
it still only pre-collapses ASCII alias chains -- a non-ASCII const->const chain
resolves a single step here rather than through to its terminal. That's a
pre-existing limitation we noticed but did not change (terminal non-ASCII keys
resolve fine; escaped-delimiter names like --foo\:bar are also out of scope).

Tests: bare vs fallback chain resolution; single- and multi-declaration override
rewrites resolve one step and are order-independent; non-ASCII key (terminal,
plus the unresolved-chain limitation); no-reference / no-constant paths; one
end-to-end cross-file case (a @media breakpoint const consumed from a
separate module via a real import) through transform().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@henryqdineen henryqdineen force-pushed the hqd-process-rules-targeted-consts branch from 3e83317 to 1326301 Compare June 5, 2026 03:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant