Skip to content

Commit 61eb691

Browse files
committed
fix: prevent dual theme bindings and bail for dotted defaults
- Return propName: null for bare "theme" in parsePropRef to avoid both `const { theme } = props` and `const theme = useTheme()` - Bail when dotted prop names (e.g., "config.enabled") have root binding defaults that can't be propagated to the emitted wrapper - Add bare theme truthiness test case (ThemeTruthyText) https://claude.ai/code/session_01DchMcXQzMb1tdBUmKf6Hek
1 parent 54be93e commit 61eb691

File tree

4 files changed

+49
-17
lines changed

4 files changed

+49
-17
lines changed

src/internal/builtin-handlers.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -344,19 +344,22 @@ function tryResolveArrowFnCallWithConditionalArgs(
344344
// Support destructured defaults when we can statically determine their truthiness.
345345
// Destructuring defaults only apply when the prop is `undefined`, so we must
346346
// preserve that distinction in the emitted condition.
347-
// Skip for dotted prop names (e.g., "theme.isDark") since the default applies
348-
// to the root binding, not the member access — truthiness would be semantically wrong.
349-
if (
350-
bindings.kind === "destructured" &&
351-
bindings.defaults &&
352-
!propName.includes(".") &&
353-
bindings.defaults.has(propName)
354-
) {
355-
const defaultValue = extractStaticLiteralValue(bindings.defaults.get(propName));
356-
if (defaultValue === undefined) {
357-
return null;
347+
if (bindings.kind === "destructured" && bindings.defaults) {
348+
if (propName.includes(".")) {
349+
// For dotted prop names (e.g., "config.enabled"), bail if the root binding
350+
// has a destructuring default — the emitted wrapper won't propagate the
351+
// default, causing a runtime regression (TypeError on undefined member access).
352+
const rootBinding = propName.split(".")[0]!;
353+
if (bindings.defaults.has(rootBinding)) {
354+
return null;
355+
}
356+
} else if (bindings.defaults.has(propName)) {
357+
const defaultValue = extractStaticLiteralValue(bindings.defaults.get(propName));
358+
if (defaultValue === undefined) {
359+
return null;
360+
}
361+
conditionalDefaultTruthy = Boolean(defaultValue);
358362
}
359-
conditionalDefaultTruthy = Boolean(defaultValue);
360363
}
361364

362365
// Both branches must be static literals (use extractStaticLiteralValue

src/internal/emit-wrappers/variant-condition.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ export function parseVariantWhenToAst(
9191
}
9292
return { propName: null, expr: j.identifier(trimmedRaw) };
9393
}
94-
return { propName: trimmedRaw, expr: j.identifier(trimmedRaw) };
94+
// Bare "theme" is resolved via useTheme(), not from component props — same
95+
// treatment as dotted theme refs (line 90) to avoid dual-binding conflicts.
96+
return { propName: trimmedRaw === "theme" ? null : trimmedRaw, expr: j.identifier(trimmedRaw) };
9597
};
9698

9799
const trimmed = String(when ?? "").trim();

test-cases/mixin-dynamicArgTheme.input.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,21 @@
22
import styled from "styled-components";
33
import { truncateMultiline } from "./lib/helpers";
44

5-
// Theme-dependent mixin: theme.isDark controls the argument to the helper
5+
// Dotted theme access: theme.isDark controls the argument
66
const ThemeText = styled.div`
77
line-height: 1rem;
88
${({ theme }) => truncateMultiline(theme.isDark ? 1 : 2)};
99
`;
1010

11+
// Bare theme truthiness check (theme object as condition)
12+
const ThemeTruthyText = styled.div`
13+
line-height: 1rem;
14+
${({ theme }) => truncateMultiline(theme ? 1 : 2)};
15+
`;
16+
1117
export const App = () => (
1218
<div style={{ display: "flex", flexDirection: "column", gap: "8px", padding: "16px" }}>
13-
<ThemeText>Theme-dependent truncation</ThemeText>
19+
<ThemeText>Dotted theme condition</ThemeText>
20+
<ThemeTruthyText>Bare theme condition</ThemeTruthyText>
1421
</div>
1522
);

test-cases/mixin-dynamicArgTheme.output.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as stylex from "@stylexjs/stylex";
33
import { useTheme } from "styled-components";
44
import { helpers } from "./lib/helpers.stylex";
55

6-
// Theme-dependent mixin: theme.isDark controls the argument to the helper
6+
// Dotted theme access: theme.isDark controls the argument
77
function ThemeText(props: React.PropsWithChildren<{}>) {
88
const theme = useTheme();
99

@@ -19,14 +19,34 @@ function ThemeText(props: React.PropsWithChildren<{}>) {
1919
);
2020
}
2121

22+
// Bare theme truthiness check (theme object as condition)
23+
function ThemeTruthyText(props: React.PropsWithChildren<{}>) {
24+
const theme = useTheme();
25+
26+
return (
27+
<div
28+
sx={[
29+
styles.themeTruthyText,
30+
theme ? helpers.truncateMultiline(1) : helpers.truncateMultiline(2),
31+
]}
32+
>
33+
{props.children}
34+
</div>
35+
);
36+
}
37+
2238
export const App = () => (
2339
<div style={{ display: "flex", flexDirection: "column", gap: "8px", padding: "16px" }}>
24-
<ThemeText>Theme-dependent truncation</ThemeText>
40+
<ThemeText>Dotted theme condition</ThemeText>
41+
<ThemeTruthyText>Bare theme condition</ThemeTruthyText>
2542
</div>
2643
);
2744

2845
const styles = stylex.create({
2946
themeText: {
3047
lineHeight: "1rem",
3148
},
49+
themeTruthyText: {
50+
lineHeight: "1rem",
51+
},
3252
});

0 commit comments

Comments
 (0)