Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/extract-compiled-jsx-styles.md
Original file line number Diff line number Diff line change
@@ -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.
123 changes: 123 additions & 0 deletions packages/extractor/__tests__/extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6411,3 +6411,126 @@ it.skip('extracts slots when spread', () => {
}
`)
})

const compiledJsxConfig: Record<string, string[]> = {
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')
})
93 changes: 92 additions & 1 deletion packages/extractor/src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +30,25 @@ type ComponentMap = Map<JsxElement, Component>
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

Expand Down Expand Up @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion packages/extractor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
115 changes: 115 additions & 0 deletions packages/parser/__tests__/jsx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)')
})
})