Skip to content

Commit c6a90a1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/partial-file-transforms-7NcMb
2 parents ed24ec7 + 5eacafa commit c6a90a1

32 files changed

Lines changed: 1782 additions & 95 deletions

scripts/verify-storybook-rendering.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const FLAKINESS_EXPECTED = new Set<string>([
5858
"keyframes-interpolatedDurationWithDelay",
5959
"keyframes-multiAnimationInterpolatedDuration",
6060
"keyframes-unionComplexity",
61+
"cssVariable-animationShorthand",
6162
]);
6263

6364
// Case-specific pixelmatch threshold overrides for known anti-aliasing noise.

src/__tests__/transform.test.ts

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2659,9 +2659,9 @@ export const App = () => <Box>Hello</Box>;
26592659
});
26602660

26612661
describe("conditional value handling", () => {
2662-
it("should bail when a boolean literal is used as a CSS value in conditional expression", () => {
2662+
it("emits a positive-only variant when alternate is `false` (omit-declaration sentinel)", () => {
26632663
// In styled-components, falsy interpolations like `false` mean "omit this declaration".
2664-
// We should bail rather than producing invalid CSS like `cursor: "false"`.
2664+
// We model this as a single positive variant bucket — equivalent to `$disabled && "not-allowed"`.
26652665
const source = `
26662666
import styled from "styled-components";
26672667
@@ -2678,7 +2678,38 @@ export const App = () => <Button $disabled>Click</Button>;
26782678
{ adapter: fixtureAdapter },
26792679
);
26802680

2681-
expect(result.code).toBeNull();
2681+
expect(result.code).not.toBeNull();
2682+
expect(result.code).toContain('cursor: "not-allowed"');
2683+
expect(result.code).not.toContain('cursor: "false"');
2684+
expect(result.code).not.toContain("cursor: false");
2685+
});
2686+
2687+
it("does not unconditionally apply the alternate when consequent is `undefined`", () => {
2688+
// `prop ? undefined : value` must NOT be lowered to a single `!prop`
2689+
// variant via splitVariantsResolved* — that path treats `!`-prefixed
2690+
// `when` strings as the unconditional default and would silently drop
2691+
// the gate. Falling back to a dynamic style function is the safe choice.
2692+
const source = `
2693+
import styled from "styled-components";
2694+
2695+
const Button = styled.button<{ $disabled?: boolean }>\`
2696+
cursor: \${(p) => (p.$disabled ? undefined : "pointer")};
2697+
\`;
2698+
2699+
export const App = () => <Button>Click</Button>;
2700+
`;
2701+
2702+
const result = transformWithWarnings(
2703+
{ source, path: "negative-only-variant.tsx" },
2704+
{ jscodeshift: j, j, stats: () => {}, report: () => {} },
2705+
{ adapter: fixtureAdapter },
2706+
);
2707+
2708+
expect(result.code).not.toBeNull();
2709+
// Guard against the regression where the value bleeds into an unconditional
2710+
// base style rule (which would set cursor: "pointer" for ALL <Button> uses,
2711+
// including when $disabled is true).
2712+
expect(result.code).not.toMatch(/cursor:\s*"pointer"\s*[,}]/);
26822713
});
26832714

26842715
it("should bail when true is used as a CSS value in conditional expression", () => {
@@ -6187,3 +6218,101 @@ export const App = () => (
61876218
expect(result.code).toContain("stylex.when.descendant");
61886219
});
61896220
});
6221+
6222+
describe("var() rewriter — adapter contract", () => {
6223+
it("should not pass placeholder sentinels to adapter when var() default contains a dynamic interpolation", () => {
6224+
const source = `
6225+
import styled from "styled-components";
6226+
6227+
const Box = styled.div<{ $tone: string }>\`
6228+
width: \${(props) => \`var(--known-var, \${props.$tone})\`};
6229+
\`;
6230+
6231+
export const App = () => <Box $tone="red">x</Box>;
6232+
`;
6233+
6234+
const fallbacksSeen: Array<string | undefined> = [];
6235+
const recordingAdapter = {
6236+
...fixtureAdapter,
6237+
resolveValue(ctx: ResolveValueContext) {
6238+
if (ctx.kind === "cssVariable" && ctx.name === "--known-var") {
6239+
fallbacksSeen.push(ctx.fallback);
6240+
return {
6241+
expr: "vars.knownVar",
6242+
imports: [
6243+
{
6244+
from: { kind: "specifier" as const, value: "./vars.stylex" },
6245+
names: [{ imported: "vars" }],
6246+
},
6247+
],
6248+
};
6249+
}
6250+
return fixtureAdapter.resolveValue(ctx);
6251+
},
6252+
} satisfies Adapter;
6253+
6254+
const result = transformWithWarnings(
6255+
{ source, path: "test.tsx" },
6256+
{ jscodeshift: j, j, stats: () => {}, report: () => {} },
6257+
{ adapter: recordingAdapter },
6258+
);
6259+
6260+
expect(result.code).not.toBeNull();
6261+
expect(fallbacksSeen.length).toBeGreaterThan(0);
6262+
for (const fallback of fallbacksSeen) {
6263+
// Adapter must never receive synthetic interpolation markers as fallback text;
6264+
// it would mis-parse them as part of the user's CSS fallback content.
6265+
expect(fallback ?? "").not.toMatch(/__SC_TPL_EXPR_/);
6266+
expect(fallback ?? "").not.toContain("\u0000");
6267+
}
6268+
});
6269+
6270+
it("should drop --name definition from variant buckets when adapter returns dropDefinition: true", () => {
6271+
const source = `
6272+
import styled from "styled-components";
6273+
6274+
const Box = styled.div<{ $active?: boolean }>\`
6275+
color: var(--variant-color);
6276+
\${(props) => (props.$active ? "--variant-color: red;" : "")}
6277+
\`;
6278+
6279+
export const App = () => (
6280+
<div>
6281+
<Box>off</Box>
6282+
<Box $active>on</Box>
6283+
</div>
6284+
);
6285+
`;
6286+
6287+
const droppingAdapter = {
6288+
...fixtureAdapter,
6289+
resolveValue(ctx: ResolveValueContext) {
6290+
if (ctx.kind === "cssVariable" && ctx.name === "--variant-color") {
6291+
return {
6292+
expr: "vars.variantColor",
6293+
imports: [
6294+
{
6295+
from: { kind: "specifier" as const, value: "./vars.stylex" },
6296+
names: [{ imported: "vars" }],
6297+
},
6298+
],
6299+
dropDefinition: true,
6300+
};
6301+
}
6302+
return fixtureAdapter.resolveValue(ctx);
6303+
},
6304+
} satisfies Adapter;
6305+
6306+
const result = transformWithWarnings(
6307+
{ source, path: "test.tsx" },
6308+
{ jscodeshift: j, j, stats: () => {}, report: () => {} },
6309+
{ adapter: droppingAdapter },
6310+
);
6311+
6312+
expect(result.code).not.toBeNull();
6313+
// The original `--variant-color: red` definition lived inside the active-variant
6314+
// bucket. With dropDefinition: true the codemod must remove it from every bucket
6315+
// that holds the local definition, not just the base styleObj.
6316+
expect(result.code).not.toContain("--variant-color");
6317+
});
6318+
});

src/internal/builtin-handlers/conditionals.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,37 @@ export function tryResolveConditionalValue(
452452
return branchToExpr(value);
453453
};
454454

455+
// Convert `prop ? value : undefined/null/false/""` into a positive-only
456+
// `splitVariantsResolved*` result (one variant bucket gated on `prop`).
457+
// Returns null when both branches resolve, or when the alternate isn't an
458+
// empty CSS sentinel — the caller continues with two-side handling or falls
459+
// back to a dynamic style function.
460+
//
461+
// We intentionally do NOT handle the inverse `prop ? undefined : value`
462+
// here: `splitVariantsResolved*` treats variants whose `when` starts with
463+
// `!` as the unconditional default (applied directly to `styleObj`), so
464+
// emitting a single negated variant would silently drop the `!prop` gate
465+
// and apply the value unconditionally. Inverse forms continue to be lowered
466+
// by the dynamic style-function fallback, which is correct (just less
467+
// optimal).
468+
const buildOneSidedVariantResult = (args: {
469+
cons: Branch;
470+
alt: Branch;
471+
alternate: unknown;
472+
truthyWhen: string;
473+
}): HandlerResult | null => {
474+
const { cons, alt, alternate, truthyWhen } = args;
475+
if (!cons || alt || !isEmptyCssBranch(alternate)) {
476+
return null;
477+
}
478+
const variants = [
479+
{ nameHint: "truthy" as const, when: truthyWhen, expr: cons.expr, imports: cons.imports },
480+
];
481+
return cons.usage === "props"
482+
? { type: "splitVariantsResolvedStyles", variants }
483+
: { type: "splitVariantsResolvedValue", variants };
484+
};
485+
455486
// Helper: resolve a 4-branch compound ternary once both the outer prop and inner prop
456487
// have been identified. Returns null if leaf branches can't all be resolved as "create".
457488
const tryBuildDualBranchResult = (outerProp: string, innerProp: string): HandlerResult | null => {
@@ -739,6 +770,21 @@ export function tryResolveConditionalValue(
739770
}
740771
}
741772

773+
// Positive-only variant for `prop ? value : undefined/null/false/""` —
774+
// styled-components treats falsy interpolations as "omit this declaration",
775+
// so model it as a single-side variant bucket rather than emitting a
776+
// dynamic style function (which would clash with pseudo overrides on the
777+
// same property elsewhere in the rule).
778+
const oneSided = buildOneSidedVariantResult({
779+
cons,
780+
alt,
781+
alternate,
782+
truthyWhen: outerProp,
783+
});
784+
if (oneSided) {
785+
return oneSided;
786+
}
787+
742788
if (!cons || !alt) {
743789
return buildRuntimeCallResult();
744790
}
@@ -792,6 +838,15 @@ export function tryResolveConditionalValue(
792838
: { type: "splitVariantsResolvedValue", variants };
793839
}
794840
}
841+
const oneSided = buildOneSidedVariantResult({
842+
cons,
843+
alt,
844+
alternate,
845+
truthyWhen: destructuredProp,
846+
});
847+
if (oneSided) {
848+
return oneSided;
849+
}
795850
if (!cons || !alt) {
796851
return buildRuntimeCallResult();
797852
}
@@ -841,6 +896,15 @@ export function tryResolveConditionalValue(
841896
}
842897
}
843898

899+
const oneSided = buildOneSidedVariantResult({
900+
cons,
901+
alt,
902+
alternate,
903+
truthyWhen: resolvedProp,
904+
});
905+
if (oneSided) {
906+
return oneSided;
907+
}
844908
if (!cons || !alt) {
845909
return buildRuntimeCallResult();
846910
}

src/internal/css-vars.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,21 @@ export function rewriteCssVarsInString(args: {
1818
return rewriteCssVarsInStringImpl(args);
1919
}
2020

21-
type VarCall = {
21+
export function findCssVarCallsInString(raw: string): CssVarCall[] {
22+
return findCssVarCalls(raw);
23+
}
24+
25+
type ExpressionKind = Parameters<JSCodeshift["expressionStatement"]>[0];
26+
27+
type CssVarCall = {
2228
start: number;
2329
end: number;
2430
name: string;
2531
fallback?: string;
2632
};
2733

28-
function findCssVarCalls(raw: string): VarCall[] {
29-
const out: VarCall[] = [];
34+
function findCssVarCalls(raw: string): CssVarCall[] {
35+
const out: CssVarCall[] = [];
3036
let i = 0;
3137
while (i < raw.length) {
3238
const idx = raw.indexOf("var(", i);
@@ -184,5 +190,3 @@ function rewriteCssVarsInStringImpl(args: {
184190
quasis.push(j.templateElement({ raw: q, cooked: q }, true));
185191
return j.templateLiteral(quasis, exprs);
186192
}
187-
188-
type ExpressionKind = Parameters<JSCodeshift["expressionStatement"]>[0];

src/internal/keyframes.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,9 +488,13 @@ export function expandStaticAnimationShorthand(
488488
const jsName = nameMap?.get(cssName) ?? cssKeyframeNameToIdentifier(cssName);
489489
const remaining = tokens.filter((_, i) => i !== nameIdx);
490490

491+
const classified = classifyAnimationTokens(remaining);
492+
if (!classified) {
493+
return false;
494+
}
495+
491496
styleObj.animationName = j.identifier(jsName);
492497

493-
const classified = classifyAnimationTokens(remaining);
494498
if (classified.duration) {
495499
styleObj.animationDuration = classified.duration;
496500
}

src/internal/logger.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ export type WarningType =
111111
| "styled(ImportedComponent) wraps a component whose file contains internal styled-components — convert the base component's file first to avoid CSS cascade conflicts"
112112
| "Partial transform would have a StyleX leaf wrap a styled-components base — the extending component was transformed but its base was not, so the leaf's StyleX overrides cannot reliably beat the base's styled-components styles"
113113
| "Transient $-prefixed props renamed on exported component — update consumer call sites to use the new prop names"
114-
| "Shorthand property has an opaque value that StyleX will expand to longhands — use `directional` in resolveValue to return separate longhand tokens";
114+
| "Shorthand property has an opaque value that StyleX will expand to longhands — use `directional` in resolveValue to return separate longhand tokens"
115+
| "animation shorthand contains a var() with no classifiable fallback — its longhand position cannot be determined statically; bind the variable to a specific longhand (e.g. animation-duration: var(--x)) instead";
115116

116117
export interface WarningLog {
117118
severity: Severity;

0 commit comments

Comments
 (0)