diff --git a/.changeset/extract-compiled-jsx-styles.md b/.changeset/extract-compiled-jsx-styles.md new file mode 100644 index 0000000000..5483c95e8e --- /dev/null +++ b/.changeset/extract-compiled-jsx-styles.md @@ -0,0 +1,10 @@ +--- +'@pandacss/extractor': patch +--- + +Fix static extraction of inline style objects from compiled JSX calls. + +Panda's extractor now recognizes compiled JSX factory calls (`jsx`, `_jsx`, `jsxs`, `_jsxs`, `createElement`) and +extracts inline style props from the second argument. Previously, styles passed as inline object literals in compiled +output (e.g. `jsx(Box, { css: { color: "red.900" } })`) were silently dropped because the extractor only handled +JSX element syntax, not the equivalent call expression form produced by bundlers. diff --git a/packages/extractor/__tests__/extract.test.ts b/packages/extractor/__tests__/extract.test.ts index ef7b541c97..621c7fa1ff 100644 --- a/packages/extractor/__tests__/extract.test.ts +++ b/packages/extractor/__tests__/extract.test.ts @@ -6411,3 +6411,126 @@ it.skip('extracts slots when spread', () => { } `) }) + +const compiledJsxConfig: Record = { + Box: ['css', 'color', 'bg'], + 'styled.div': ['bg', 'color'], +} + +const compiledJsxMatcher: ComponentMatchers = { + matchTag: ({ tagName }) => Boolean(compiledJsxConfig[tagName]), + matchProp: ({ tagName, propName }) => compiledJsxConfig[tagName]?.includes(propName) ?? false, +} + +it('compiled jsx - extracts from realistic compiled dist output', () => { + const result = extractFromCode( + ` + import { Fragment, jsx, jsxs } from 'react/jsx-runtime'; + + const App = () => { + return jsxs(Fragment, { + children: [ + jsx(Box, { + css: { + color: 'red.900', + backgroundColor: 'red.200', + }, + children: 'Box', + }), + ], + }); + }; + `, + { components: compiledJsxMatcher }, + ) + + expect(Object.keys(result)).toContain('Box') + expect(result.Box[0].raw).toHaveProperty('css') + expect(result.Box[0].raw).not.toHaveProperty('children') +}) + +it('compiled jsx - extracts inline object from _jsx(Component, { prop: value })', () => { + const result = extractFromCode( + ` + function render() { + return _jsx(Box, { css: { padding: "4" } }) + } + `, + { components: compiledJsxMatcher }, + ) + + expect(Object.keys(result)).toContain('Box') + expect(result.Box[0].raw).toHaveProperty('css') +}) + +it('compiled jsx - extracts style props from _jsx(Component, { bg: value })', () => { + const result = extractFromCode( + ` + function render() { + return _jsx(Box, { bg: "red.200", color: "blue.300" }) + } + `, + { components: compiledJsxMatcher }, + ) + + expect(Object.keys(result)).toContain('Box') + expect(result.Box[0].raw).toHaveProperty('bg') + expect(result.Box[0].raw).toHaveProperty('color') +}) + +it('compiled jsx - extracts from React.createElement(Component, { ... })', () => { + const result = extractFromCode( + ` + function render() { + return React.createElement(Box, { css: { padding: "4" } }) + } + `, + { components: compiledJsxMatcher }, + ) + + expect(Object.keys(result)).toContain('Box') + expect(result.Box[0].raw).toHaveProperty('css') +}) + +it('compiled jsx - skips unmatched tag name', () => { + const result = extractFromCode( + ` + function render() { + return _jsx("div", { css: { padding: "4" } }) + } + `, + { components: compiledJsxMatcher }, + ) + + expect(Object.keys(result)).toHaveLength(0) +}) + +it('compiled jsx - filters non-matching props', () => { + const result = extractFromCode( + ` + function render() { + return _jsx(Box, { css: { padding: "4" }, onClick: handleClick, className: "test" }) + } + `, + { components: compiledJsxMatcher }, + ) + + expect(Object.keys(result)).toContain('Box') + expect(result.Box[0].raw).toHaveProperty('css') + expect(result.Box[0].raw).not.toHaveProperty('onClick') + expect(result.Box[0].raw).not.toHaveProperty('className') +}) + +it('compiled jsx - extracts from _jsx(styled.div, { ... }) with property access component', () => { + const result = extractFromCode( + ` + function render() { + return _jsx(styled.div, { bg: "red.200" }) + } + `, + { components: compiledJsxMatcher }, + ) + + expect(Object.keys(result)).toContain('styled.div') + expect(result['styled.div'][0].raw).toHaveProperty('bg') +}) diff --git a/packages/extractor/src/extract.ts b/packages/extractor/src/extract.ts index fcb2d5d3b1..e185cfaa2d 100644 --- a/packages/extractor/src/extract.ts +++ b/packages/extractor/src/extract.ts @@ -16,7 +16,7 @@ import type { MatchFnPropArgs, MatchPropArgs, } from './types' -import { getComponentName } from './utils' +import { getComponentName, unwrapExpression } from './utils' import { maybeBoxNode } from './maybe-box-node' type JsxElement = JsxOpeningElement | JsxSelfClosingElement @@ -30,6 +30,25 @@ type ComponentMap = Map const isImportOrExport = (node: Node) => Node.isImportDeclaration(node) || Node.isExportDeclaration(node) const isJsxElement = (node: Node) => Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node) +const compiledJsxFnNames = new Set(['jsx', '_jsx', 'jsxs', '_jsxs', 'createElement']) + +// _jsx(Component, props), React.createElement(Component, props) +function getCompiledJsxCallName(expr: Node): string | undefined { + const unwrapped = unwrapExpression(expr) + + if (Node.isIdentifier(unwrapped)) { + const name = unwrapped.getText() + return compiledJsxFnNames.has(name) ? name : undefined + } + + if (Node.isPropertyAccessExpression(unwrapped)) { + const name = unwrapped.getName() + return compiledJsxFnNames.has(name) ? name : undefined + } + + return undefined +} + export const extract = ({ ast, ...ctx }: ExtractOptions) => { const { components, functions, taggedTemplates } = ctx @@ -149,6 +168,78 @@ export const extract = ({ ast, ...ctx }: ExtractOptions) => { component.props.set(propName, maybeBox) boxByProp.set(propName, (boxByProp.get(propName) ?? []).concat(maybeBox)) } + + // Handle compiled JSX: _jsx(Box, { css: { ... } }), React.createElement(Box, { ... }) + if (Node.isCallExpression(node)) { + const compiledJsxFn = getCompiledJsxCallName(node.getExpression()) + if (compiledJsxFn) { + const args = node.getArguments() + if (args.length >= 2) { + const tagArg = unwrapExpression(args[0]) + + let tagName: string | undefined + if (Node.isIdentifier(tagArg)) { + tagName = tagArg.getText() + } else if (Node.isPropertyAccessExpression(tagArg)) { + tagName = tagArg.getText() + } else if (Node.isStringLiteral(tagArg)) { + tagName = tagArg.getLiteralText() + } + + if (tagName && components.matchTag({ tagNode: node, tagName, isFactory: tagName.includes('.') })) { + if (!byName.has(tagName)) { + byName.set(tagName, { kind: 'component', nodesByProp: new Map(), queryList: [] }) + } + + const componentResult = byName.get(tagName)! as ExtractedComponentResult + const boxByProp = componentResult.nodesByProp + const props: MapTypeValue = new Map() + const conditionals: BoxNodeConditional[] = [] + + const propsArg = unwrapExpression(args[1]) + + const filterProp = (prop: MatchFnPropArgs) => + components.matchProp({ tagNode: node, tagName: tagName!, propName: prop.propName, propNode: undefined }) + + const propsBox = maybeBoxNode(propsArg, [node, propsArg], ctx, filterProp) + + if (propsBox && box.isMap(propsBox)) { + propsBox.value.forEach((value, propName) => { + props.set(propName, value) + boxByProp.set(propName, (boxByProp.get(propName) ?? []).concat(value)) + }) + + if (propsBox.spreadConditions?.length) { + conditionals.push(...propsBox.spreadConditions) + } + } else if (propsBox && box.isObject(propsBox)) { + objectLikeToMap(propsBox, node).forEach((value, propName) => { + if (filterProp({ propName, propNode: propsArg as any })) { + props.set(propName, value) + boxByProp.set(propName, (boxByProp.get(propName) ?? []).concat(value)) + } + }) + } + + if (props.size > 0 || conditionals.length > 0) { + const query = { + name: tagName, + box: box.map(props, node, []), + } as ExtractedComponentInstance + + if (conditionals.length) { + query.box.spreadConditions = conditionals + } + + componentResult.queryList.push(query) + } + + // Compiled JSX matched this component — skip the functions block + return + } + } + } + } } if (functions && Node.isCallExpression(node)) { diff --git a/packages/extractor/src/types.ts b/packages/extractor/src/types.ts index 02b14c5405..e5be70b551 100644 --- a/packages/extractor/src/types.ts +++ b/packages/extractor/src/types.ts @@ -67,7 +67,7 @@ export type ListOrAll = 'all' | string[] export interface MatchTagArgs { tagName: string - tagNode: JsxOpeningElement | JsxSelfClosingElement + tagNode: JsxOpeningElement | JsxSelfClosingElement | CallExpression isFactory: boolean } export interface MatchPropArgs { diff --git a/packages/parser/__tests__/jsx.test.ts b/packages/parser/__tests__/jsx.test.ts index 1ce18d6986..ed33d5b316 100644 --- a/packages/parser/__tests__/jsx.test.ts +++ b/packages/parser/__tests__/jsx.test.ts @@ -687,4 +687,119 @@ describe('jsx', () => { expect(item.data[0]).toHaveProperty('inputCss') expect(item.data[0].inputCss).toEqual({ bg: 'red.200' }) }) + + test('compiled jsx - should extract from realistic compiled dist output', () => { + const code = ` + import { Fragment, jsx, jsxs } from "react/jsx-runtime" + + function App() { + return jsxs(Fragment, { + children: [ + jsx(Box, { + css: { + color: "red.900", + backgroundColor: "red.200", + }, + children: "Box", + }), + ], + }) + } + ` + + const result = parseAndExtract(code) + + expect(result.css).toContain('var(--colors-red-900)') + expect(result.css).toContain('var(--colors-red-200)') + }) + + test('compiled jsx - should extract inline css prop from _jsxs() call', () => { + const code = ` + import { jsxs as _jsxs } from "react/jsx-runtime" + + function MyComponent() { + return _jsxs(Box, { css: { color: "blue.300" }, children: ["hello"] }) + } + ` + + const result = parseAndExtract(code) + + expect(result.css).toContain('c_blue') + expect(result.css).toContain('var(--colors-blue-300)') + }) + + test('compiled jsx - should extract from React.createElement() call', () => { + const code = ` + import React from "react" + + function MyComponent() { + return React.createElement(Box, { css: { padding: "4" } }) + } + ` + + const result = parseAndExtract(code) + + expect(result.css).toContain('p_4') + expect(result.css).toContain('var(--spacing-4)') + }) + + test('compiled jsx - should extract style props in all mode', () => { + const code = ` + import { jsx as _jsx } from "react/jsx-runtime" + + function MyComponent() { + return _jsx(Box, { bg: "red.200", color: "blue.300" }) + } + ` + + const result = parseAndExtract(code) + + expect(result.css).toContain('bg_red') + expect(result.css).toContain('c_blue') + }) + + test('compiled jsx - should respect minimal mode', () => { + const code = ` + import { jsx as _jsx } from "react/jsx-runtime" + + function MyComponent() { + return _jsx(Box, { css: { padding: "4" }, color: "blue" }) + } + ` + + const result = parseAndExtract(code, { jsxStyleProps: 'minimal' }) + + expect(result.css).toContain('p_4') + expect(result.css).not.toContain('c_blue') + }) + + test('compiled jsx - should not extract with lowercase element', () => { + const code = ` + import { jsx as _jsx } from "react/jsx-runtime" + + function MyComponent() { + return _jsx("div", { css: { bg: "red.200" } }) + } + ` + + const result = parseAndExtract(code) + + expect(result.css).toBe('') + }) + + test('compiled jsx - should extract from factory component', () => { + const code = ` + import { jsx as _jsx } from "react/jsx-runtime" + import { styled } from "styled-system/jsx" + + function MyComponent() { + return _jsx(styled.div, { bg: "red.200" }) + } + ` + + const result = parseAndExtract(code) + + expect(result.css).toContain('bg_red') + expect(result.css).toContain('var(--colors-red-200)') + }) })