Skip to content

[babel-plugin] Fix corrupted class names from null coercion in dynamic styles - #1731

Open
henryqdineen wants to merge 5 commits into
facebook:mainfrom
henryqdineen:hqd-fix-null-coercion-dynamic-styles
Open

henryqdineen wants to merge 5 commits into
facebook:mainfrom
henryqdineen:hqd-fix-null-coercion-dynamic-styles

Conversation

@henryqdineen

Copy link
Copy Markdown
Collaborator

What changed / motivation ?

When a dynamic style has multiple conditional class names for a single CSS property (e.g. multiple media queries or pseudo-classes), the generated code joins them with + string concatenation. If any condition evaluates to null or undefined at runtime, JavaScript coerces it to the string "null" or "undefined", producing corrupted class names like "nullxafpxz5".

The fix changes the else branch of the null-check ternary from expr to t.stringLiteral(''), so a non-matching condition contributes nothing to the concatenation instead of contributing "null".

This is a regression from 7a1ed95 which refactored the single-class and multi-class code paths into one. The original code before that refactor already used "" for the multi-class case — the refactor replaced it with expr, likely reasoning that expr would be null anyway in the else branch. That's true, but null + "string" produces "nullstring" in JavaScript.

As a side benefit, this also produces smaller output since "" replaces what was previously a duplicated copy of the original expression in the else branch (unless the expression is a single-character variable).

Note that "" and null are not identical in styleq's merge logic — null is the intentional "unset" semantic while "" is treated as an empty string value. In practice they behave the same (both claim the property slot, neither adds a real class name), and the all-null case was already broken before this fix (producing "nullnull"), so this is strictly an improvement.

Linked PR/Issues

Fixes #1702

Additional Context

Existing snapshot tests updated to reflect the new "" fallback. All tests pass.

Pre-flight checklist

…c styles

When a dynamic style has multiple conditional class names for a single
CSS property (e.g. multiple media queries or pseudo-classes), the
generated code joins them with `+`. If any condition evaluates to `null`
or `undefined` at runtime, JavaScript coerces it to the string "null" or
"undefined", producing corrupted class names like "nullxafpxz5".

The fix changes the else branch of the null-check ternary from `expr` to
`t.stringLiteral('')`. This is a regression from 7a1ed95 which replaced
the original `""` with `expr` during a refactor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 22, 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.

@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 22, 2026
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
const styles = {
color: color => [{
"--__hashed_var__1jqb1tb": color != null ? "__hashed_var__1w8wjxo" : color,
"--__hashed_var__1jqb1tb": color != null ? "__hashed_var__1w8wjxo" : "",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a small regression because the reason the fallback value was color is to account for the way undefined vs null is handled by stylex. null means intentionally unsetting a value. undefined is to ignored and the previously set value wins. Here all nullish values are represented as "" which behaves like null.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps what we need here is:

color !== undefined && {"--__hashed_var__1jqb1tb": color != null ? "__hashed_var__1w8wjxo" : "", $$css: true}

This way, we can skip the object for undefined entirely.

@henryqdineen henryqdineen Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, thanks for catching that! I worked through it with Claude to implement your suggestion.

From Claude 🤖 :

Implemented as suggested: a property that resolves to a single conditional class is now emitted as expr !== undefined && { … }. When the value is undefined the object is skipped (styleq drops false styles) so the previously set value wins; when it's null the class still resolves to "" to unset the slot. That restores the undefined vs null distinction the ""-only fallback had flattened. So (color) => ({ color }) now compiles to:

color => [color !== undefined && { kMwMTN: color != null ? "…" : "", $$css: true }, { "--x-color":  }]

Two things shaped the approach:

Why not just restore the original expr fallback? With color != null ? cls : color, passing undefined leaves a literal undefined as a value in the $$css object — and styleq errors on that (console.error(… is not "string" or "null")), since it only accepts a string or null there. Guarding the object with !== undefined means undefined never ends up inside it.

Why one guarded object per property, not one around the whole style? When two variables drive the same style, a single shared guard is wrong — it would drop a set value whenever the other is undefined. So each property is guarded on its own value:

// (bg, oc) => ({ backgroundColor: bg, outlineColor: oc })
[bg !== undefined && { kWkggS:  }, oc !== undefined && { kjBf7l:  }, {}]

(Multi-class concatenations — e.g. several media queries on one property — keep the "" fallback instead: the slot is already claimed by the sibling classes, and undefined vs null can't be expressed inside a joined string anyway.)

Possible followup: properties driven by the same value could be grouped into one guarded object rather than one-per-property. Left out to keep the diff focused.

— Claude

@nmn nmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either way can change the way the object is generated in the way I suggested, or we can change the runtime to fix how we merge.

henryqdineen and others added 2 commits July 16, 2026 12:39
The prior null-coercion fix changed the single-class fallback from `expr`
to `""`. That stopped the "nullxafpxz5" corruption, but it collapsed the
distinction styleq draws between `undefined` and `null`: `undefined` means
"ignore this, let the previously set value win" while `null` means
"intentionally unset". With `""` both behave like `null`, so passing
`undefined` for a dynamic value wrongly unset the base style instead of
deferring to it (raised in review by @nmn).

Reverting to the `expr` fallback is not an option either: a bare `undefined`
value inside a compiled ($$css) object trips styleq's `console.error(... not
"string" or "null")`, since styleq only accepts `string | null` there.

Instead, per @nmn's suggestion, a property that resolves to a single
conditional class is emitted as its own object guarded by
`expr !== undefined && { ... }`. styleq skips a `false` style entirely, so
`undefined` defers to the previous value with no error, while `null` still
claims the slot with `""` to unset it. Each such property becomes its own
guarded object so the semantics hold independently when multiple variables
drive one style. Multi-class concatenations keep the `""` fallback: the slot
is already claimed by the other classes, so the distinction is both moot and
unrepresentable in a joined string there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@henryqdineen
henryqdineen requested a review from nmn July 16, 2026 19:52
Resolved conflicts in three babel-plugin test files by taking main's
snapshots and regenerating them against this branch's transform, so the
only remaining diff is the intended `expr !== undefined && {...}` shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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

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.

Corrupted class names including string "null" generated from dynamic style with media query conditions

2 participants