Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_compat_theme.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"changes": {
"packages/react/package.json": "Patch",
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "The styled-components and Emotion compat ThemeProvider now gives its theme through React context: useTheme, withTheme and the new ThemeConsumer return the real theme object (an object merged over the outer theme, or what a theme function makes of it) instead of an empty Proxy of var() references, withTheme forwards refs, lets a caller's theme win and sets a displayName. Styles keep reading the CSS variables the provider declares. styled-components v6.4 createTheme is available from the compat entry, and ThemeConsumer and createTheme imports are redirected there instead of staying a runtime dependency on styled-components",
"date": "2026-10-01T00:00:00.000Z"
}
17 changes: 17 additions & 0 deletions libs/extractor/src/import_alias_visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ fn devup_equivalent(source: &str, imported: &str) -> Option<DevupTarget<'static>
(_, "isStyledComponent") => Some(DevupTarget::Compat("isStyledComponent")),
(_, "withTheme") => Some(DevupTarget::Compat("withTheme")),
(_, "useTheme") => Some(DevupTarget::Compat("useTheme")),
("styled-components", "ThemeConsumer") => Some(DevupTarget::Compat("ThemeConsumer")),
("styled-components", "createTheme") => Some(DevupTarget::Compat("createTheme")),
_ => None,
}
}
Expand Down Expand Up @@ -728,6 +730,21 @@ mod tests {
));
}

#[test]
fn test_styled_components_theme_apis_redirect_to_compat() {
let code = transform_import_aliases(
"import { ThemeConsumer, createTheme } from 'styled-components'",
"test.tsx",
"@devup-ui/react",
&styled_components_alias(),
);
assert!(
code.contains("import { ThemeConsumer, createTheme } from '@devup-ui/react/compat';"),
"{code}"
);
assert!(!code.contains("from 'styled-components'"), "{code}");
}

#[test]
fn test_no_matching_alias() {
assert_snapshot!(transform_import_aliases(
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/compat/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ describe('compat entry', () => {
expect({ ...compat }).toEqual({
Global: expect.any(Function),
ThemeProvider: expect.any(Function),
ThemeConsumer: expect.any(Function),

createGlobalStyle: expect.any(Function),
createTheme: expect.any(Function),
useTheme: expect.any(Function),
withTheme: expect.any(Function),

Expand All @@ -23,6 +25,5 @@ describe('compat entry', () => {
const { useTheme: devupUseTheme } = await import('../../index')

expect(compatUseTheme).not.toBe(devupUseTheme)
expect(`${compatUseTheme<{ brand: string }>().brand}`).toBe('var(--brand)')
})
})
3 changes: 2 additions & 1 deletion packages/react/src/compat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ export { Global } from '../components/Global'
export { ThemeProvider } from '../components/ThemeProvider'
export { useStyledTheme as useTheme } from '../hooks/use-styled-theme'
export { createGlobalStyle } from '../utils/create-global-style'
export { createTheme } from '../utils/create-theme'
export {
isStyledComponent,
ServerStyleSheet,
StyleSheetManager,
} from '../utils/styled-compat'
export type { StyledTheme } from '../utils/theme-vars'
export { withTheme } from '../utils/with-theme'
export { ThemeConsumer, withTheme } from '../utils/with-theme'
2 changes: 2 additions & 0 deletions packages/react/src/compat/styled-components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ declare module 'styled-components' {
export type { StyledTheme as DefaultTheme } from '@devup-ui/react/compat'
export {
createGlobalStyle,
createTheme,
isStyledComponent,
ServerStyleSheet,
StyleSheetManager,
ThemeConsumer,
ThemeProvider,
useTheme,
withTheme,
Expand Down
30 changes: 28 additions & 2 deletions packages/react/src/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,39 @@
'use client'

import type { ReactNode } from 'react'
import { useContext } from 'react'

import { StyledThemeContext } from '../utils/theme-context'
import type { StyledTheme } from '../utils/theme-vars'
import { themeToCssVariables } from '../utils/theme-vars'

export type ThemeArgument =
StyledTheme | ((outer: StyledTheme | undefined) => StyledTheme)

interface ThemeProviderProps {
theme?: StyledTheme
theme?: ThemeArgument
children?: ReactNode
}

/**
* The theme a provider gives: a function receives the outer theme and
* returns the new one, an object merges over the outer theme, as
* styled-components and Emotion resolve nested providers
*/
export function resolveTheme(
theme: ThemeArgument | undefined,
outer: StyledTheme | undefined,
): StyledTheme | undefined {
if (typeof theme === 'function') return theme(outer)
if (!theme) return outer
return outer ? { ...outer, ...theme } : theme
}

export function ThemeProvider({ theme, children }: ThemeProviderProps) {
return <div style={themeToCssVariables(theme)}>{children}</div>
const resolved = resolveTheme(theme, useContext(StyledThemeContext))

Check warning on line 33 in packages/react/src/components/ThemeProvider.tsx

View workflow job for this annotation

GitHub Actions / publish

In React 19, 'use' is preferred over 'useContext' because it is more flexible
return (
<StyledThemeContext.Provider value={resolved}>

Check warning on line 35 in packages/react/src/components/ThemeProvider.tsx

View workflow job for this annotation

GitHub Actions / publish

In React 19, you can render '<Context>' as a provider instead of '<Context.Provider>'
<div style={themeToCssVariables(resolved)}>{children}</div>
</StyledThemeContext.Provider>
)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, expect, it } from 'bun:test'
import { render } from 'bun-test-env-dom'

import { ThemeProvider } from '../ThemeProvider'
import { useStyledTheme } from '../../hooks/use-styled-theme'
import { resolveTheme, ThemeProvider } from '../ThemeProvider'

function Read() {
return <span>{JSON.stringify(useStyledTheme())}</span>
}

describe('ThemeProvider', () => {
it('should declare css variables without affecting layout', () => {
Expand All @@ -21,4 +26,28 @@ describe('ThemeProvider', () => {
)
expect(container).toMatchSnapshot()
})

it('merges a nested theme object and calls a nested theme function', () => {
const { container } = render(
<ThemeProvider theme={{ a: 'outer', b: 'outer' }}>
<ThemeProvider theme={{ b: 'inner' }}>
<Read />
</ThemeProvider>
<ThemeProvider theme={(outer) => ({ ...outer, c: 'fn' })}>
<Read />
</ThemeProvider>
</ThemeProvider>,
)
expect(container.textContent).toBe(
'{"a":"outer","b":"inner"}{"a":"outer","b":"outer","c":"fn"}',
)
})
})

describe('resolveTheme', () => {
it('keeps the outer theme without its own', () => {
expect(resolveTheme(undefined, { a: 1 })).toEqual({ a: 1 })
expect(resolveTheme({ a: 1 }, undefined)).toEqual({ a: 1 })
expect(resolveTheme(undefined, undefined)).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'bun:test'
import { render } from 'bun-test-env-dom'

import { ThemeProvider } from '../../components/ThemeProvider'
import { useStyledTheme } from '../use-styled-theme'

function Read() {
return <span>{JSON.stringify(useStyledTheme())}</span>
}

describe('useStyledTheme', () => {
it('returns the theme object the nearest provider gives', () => {
const { container } = render(
<ThemeProvider theme={{ colors: { brand: 'red' } }}>
<Read />
</ThemeProvider>,
)
expect(container.textContent).toBe('{"colors":{"brand":"red"}}')
})

it('returns an empty object without a provider', () => {
const { container } = render(<Read />)
expect(container.textContent).toBe('{}')
})
})
10 changes: 0 additions & 10 deletions packages/react/src/hooks/__tests__/use-styled-theme.test.ts

This file was deleted.

15 changes: 8 additions & 7 deletions packages/react/src/hooks/use-styled-theme.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type { StyledTheme } from '../utils/theme-vars'
import { createThemeAccessor } from '../utils/theme-vars'
'use client'

import { useContext } from 'react'

const themeAccessor = createThemeAccessor()
import { StyledThemeContext } from '../utils/theme-context'
import type { StyledTheme } from '../utils/theme-vars'

/**
* styled-components compatible `useTheme`.
* styled-components and Emotion compatible `useTheme`: the theme object the
* nearest `ThemeProvider` gives, or an empty object without one.
*
* Distinct from devup-ui's own `useTheme`, which reports the active theme name.
* This one hands back CSS-variable references, matching what `ThemeProvider`
* declares and what the extractor inlines into styles.
*/
export function useStyledTheme<T extends StyledTheme = StyledTheme>(): T {
return themeAccessor as T
return (useContext(StyledThemeContext) ?? {}) as T

Check warning on line 15 in packages/react/src/hooks/use-styled-theme.ts

View workflow job for this annotation

GitHub Actions / publish

In React 19, 'use' is preferred over 'useContext' because it is more flexible
}
51 changes: 51 additions & 0 deletions packages/react/src/utils/__tests__/create-theme.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'bun:test'
import { render } from 'bun-test-env-dom'

import { ThemeProvider } from '../../components/ThemeProvider'
import { createTheme } from '../create-theme'

describe('createTheme', () => {
const theme = createTheme({ colors: { primary: '#0070f3' }, space: 4 })

it('reads each leaf as a variable with its value as fallback', () => {
expect(theme.colors.primary).toBe('var(--sc-colors-primary, #0070f3)')
expect(theme.space).toBe('var(--sc-space, 4)')
expect(theme.vars.colors.primary).toBe('--sc-colors-primary')
expect(theme.raw.space).toBe(4)
})

it('declares the variables from the provider theme', () => {
const custom = createTheme(
{ colors: { primary: 'red' }, space: 4 },
{ prefix: 'ds', selector: ':host' },
)
const { container } = render(
<ThemeProvider theme={{ colors: { primary: 'blue' }, space: { n: 1 } }}>
<custom.GlobalStyle />
</ThemeProvider>,
)
expect(container.querySelector('style')?.textContent).toBe(
':host{--ds-colors-primary:blue;}',
)
const { container: bare } = render(<theme.GlobalStyle />)
expect(bare.querySelector('style')?.textContent).toBe(':root{}')
const { container: flat } = render(
<ThemeProvider theme={{ colors: 'x', space: 8 }}>
<theme.GlobalStyle />
</ThemeProvider>,
)
expect(flat.querySelector('style')?.textContent).toBe(
':root{--sc-space:8;}',
)
})

it('resolves the declared values in the browser', () => {
document.documentElement.style.setProperty('--sc-space', '12')
expect(theme.resolve()).toEqual({
colors: { primary: '#0070f3' },
space: '12',
})
document.documentElement.style.removeProperty('--sc-space')
expect(theme.resolve(document.body).space).toBe(4)
})
})
24 changes: 1 addition & 23 deletions packages/react/src/utils/__tests__/theme-vars.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { describe, expect, it } from 'bun:test'

import {
createThemeAccessor,
themeToCssVariables,
themeVariableName,
} from '../theme-vars'
import { themeToCssVariables, themeVariableName } from '../theme-vars'

describe('themeVariableName', () => {
it('joins a path into a custom property', () => {
Expand Down Expand Up @@ -44,21 +40,3 @@ describe('themeToCssVariables', () => {
})
})
})

describe('createThemeAccessor', () => {
it('resolves reads to css variable references', () => {
const theme = createThemeAccessor<{ colors: { brand: string } }>()
expect(`${theme.colors.brand}`).toBe('var(--colors-brand)')
expect(String(theme.colors.brand)).toBe('var(--colors-brand)')
expect(theme.colors.brand.valueOf()).toBe('var(--colors-brand)')
expect(theme.colors.brand.toString()).toBe('var(--colors-brand)')
})

it('has no reference at the root and ignores symbol keys', () => {
const theme = createThemeAccessor()
expect(`${theme}`).toBe('')
expect(
(theme as unknown as Record<symbol, unknown>)[Symbol.iterator],
).toBeUndefined()
})
})
55 changes: 49 additions & 6 deletions packages/react/src/utils/__tests__/with-theme.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,59 @@
import { describe, expect, it } from 'bun:test'
import { render } from 'bun-test-env-dom'
import { createRef, forwardRef } from 'react'

import { ThemeProvider } from '../../components/ThemeProvider'
import type { StyledTheme } from '../theme-vars'
import { withTheme } from '../with-theme'
import { ThemeConsumer, withTheme } from '../with-theme'

const Swatch = forwardRef<HTMLDivElement, { theme?: StyledTheme }>(

Check warning on line 9 in packages/react/src/utils/__tests__/with-theme.browser.test.tsx

View workflow job for this annotation

GitHub Actions / publish

In React 19, 'forwardRef' is no longer necessary. Pass 'ref' as a prop instead
function Swatch({ theme }, ref) {
return <div ref={ref}>{String(theme?.brand)}</div>
},
)
Swatch.displayName = 'Swatch'

describe('withTheme', () => {
it('injects the css-variable theme accessor', () => {
const Swatch = ({ theme }: { theme?: StyledTheme }) => (
<div>{`${(theme as { brand: string }).brand}`}</div>
it('injects the theme the nearest provider gives and forwards refs', () => {
const Themed = withTheme(Swatch)
const ref = createRef<HTMLDivElement>()
const { container } = render(
<ThemeProvider theme={{ brand: 'red' }}>
<Themed ref={ref} />
</ThemeProvider>,
)
expect(container.textContent).toBe('red')
expect(ref.current?.tagName).toBe('DIV')
expect(Themed.displayName).toBe('WithTheme(Swatch)')
})

it('lets the caller theme win and falls back to an empty theme', () => {
const Themed = withTheme(Swatch)
const { container } = render(<Themed />)
expect(container.textContent).toBe('var(--brand)')
expect(
render(<Themed theme={{ brand: 'blue' }} />).container.textContent,
).toBe('blue')
expect(render(<Themed />).container.textContent).toBe('undefined')
})

it('names anonymous components', () => {
const Anonymous = withTheme(() => null)
expect(Anonymous.displayName).toBe('WithTheme(Component)')
const Named = Object.assign(() => null, { displayName: 'Named' })
expect(withTheme(Named).displayName).toBe('WithTheme(Named)')
})
})

describe('ThemeConsumer', () => {
it('renders what children make of the theme', () => {
const { container } = render(
<ThemeProvider theme={{ brand: 'green' }}>
<ThemeConsumer>{(theme) => String(theme.brand)}</ThemeConsumer>
</ThemeProvider>,
)
expect(container.textContent).toBe('green')
expect(
render(<ThemeConsumer>{(theme) => JSON.stringify(theme)}</ThemeConsumer>)
.container.textContent,
).toBe('{}')
})
})
Loading
Loading