From 032a59e01d89972de6ed4e347edb1a71d2e74fd3 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 08:03:04 +0900 Subject: [PATCH 1/2] fix(react): back compat theming with context and add ThemeConsumer and createTheme Refs #689 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_compat_theme.json | 8 ++ bindings/devup-ui-wasm/src/lib.rs | 8 +- libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/import_alias_visit.rs | 17 ++++ libs/extractor/src/lib.rs | 8 +- libs/extractor/src/tailwind.rs | 4 +- .../react/src/compat/__tests__/index.test.ts | 3 +- packages/react/src/compat/index.ts | 3 +- .../react/src/compat/styled-components.d.ts | 2 + .../react/src/components/ThemeProvider.tsx | 30 +++++- .../__tests__/ThemeProvider.browser.test.tsx | 31 +++++- .../use-styled-theme.browser.test.tsx | 25 +++++ .../hooks/__tests__/use-styled-theme.test.ts | 10 -- packages/react/src/hooks/use-styled-theme.ts | 15 +-- .../__tests__/create-theme.browser.test.tsx | 51 ++++++++++ .../src/utils/__tests__/theme-vars.test.ts | 24 +---- .../__tests__/with-theme.browser.test.tsx | 55 +++++++++-- packages/react/src/utils/create-theme.tsx | 99 +++++++++++++++++++ packages/react/src/utils/theme-context.ts | 14 +++ packages/react/src/utils/theme-vars.ts | 24 ----- packages/react/src/utils/with-theme.tsx | 43 ++++++-- 21 files changed, 384 insertions(+), 92 deletions(-) create mode 100644 .changepacks/changepack_log_compat_theme.json create mode 100644 packages/react/src/hooks/__tests__/use-styled-theme.browser.test.tsx delete mode 100644 packages/react/src/hooks/__tests__/use-styled-theme.test.ts create mode 100644 packages/react/src/utils/__tests__/create-theme.browser.test.tsx create mode 100644 packages/react/src/utils/create-theme.tsx create mode 100644 packages/react/src/utils/theme-context.ts diff --git a/.changepacks/changepack_log_compat_theme.json b/.changepacks/changepack_log_compat_theme.json new file mode 100644 index 000000000..e20892f02 --- /dev/null +++ b/.changepacks/changepack_log_compat_theme.json @@ -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" +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f3..0a2347ac2 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1551,7 +1551,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1951,7 +1951,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +1975,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2000,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb021..3b2346aed 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 158d0f420..33ab0ae54 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -215,6 +215,8 @@ fn devup_equivalent(source: &str, imported: &str) -> Option (_, "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, } } @@ -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( diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36c..96c6ca430 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -836,8 +836,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -13598,7 +13598,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -18778,7 +18778,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f5178..70643aa7c 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -284,7 +284,7 @@ pub struct TailwindClass { /// non-overlapping) but mutates the existing buffer instead of allocating a new /// `String`. `needle` must be non-empty. fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); + debug_assert_ne!(needle, ""); let mut search_from = 0; while let Some(rel) = haystack[search_from..].find(needle) { let at = search_from + rel; @@ -3934,7 +3934,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test] diff --git a/packages/react/src/compat/__tests__/index.test.ts b/packages/react/src/compat/__tests__/index.test.ts index ad6e86619..cc0747799 100644 --- a/packages/react/src/compat/__tests__/index.test.ts +++ b/packages/react/src/compat/__tests__/index.test.ts @@ -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), @@ -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)') }) }) diff --git a/packages/react/src/compat/index.ts b/packages/react/src/compat/index.ts index c22aa8eb4..cc350727e 100644 --- a/packages/react/src/compat/index.ts +++ b/packages/react/src/compat/index.ts @@ -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' diff --git a/packages/react/src/compat/styled-components.d.ts b/packages/react/src/compat/styled-components.d.ts index 39658dfcc..abf6a3f06 100644 --- a/packages/react/src/compat/styled-components.d.ts +++ b/packages/react/src/compat/styled-components.d.ts @@ -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, diff --git a/packages/react/src/components/ThemeProvider.tsx b/packages/react/src/components/ThemeProvider.tsx index 9df1ad710..50e0de760 100644 --- a/packages/react/src/components/ThemeProvider.tsx +++ b/packages/react/src/components/ThemeProvider.tsx @@ -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
{children}
+ const resolved = resolveTheme(theme, useContext(StyledThemeContext)) + return ( + +
{children}
+
+ ) } diff --git a/packages/react/src/components/__tests__/ThemeProvider.browser.test.tsx b/packages/react/src/components/__tests__/ThemeProvider.browser.test.tsx index 4c53b6134..79d2df959 100644 --- a/packages/react/src/components/__tests__/ThemeProvider.browser.test.tsx +++ b/packages/react/src/components/__tests__/ThemeProvider.browser.test.tsx @@ -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 {JSON.stringify(useStyledTheme())} +} describe('ThemeProvider', () => { it('should declare css variables without affecting layout', () => { @@ -21,4 +26,28 @@ describe('ThemeProvider', () => { ) expect(container).toMatchSnapshot() }) + + it('merges a nested theme object and calls a nested theme function', () => { + const { container } = render( + + + + + ({ ...outer, c: 'fn' })}> + + + , + ) + 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() + }) }) diff --git a/packages/react/src/hooks/__tests__/use-styled-theme.browser.test.tsx b/packages/react/src/hooks/__tests__/use-styled-theme.browser.test.tsx new file mode 100644 index 000000000..63de2895b --- /dev/null +++ b/packages/react/src/hooks/__tests__/use-styled-theme.browser.test.tsx @@ -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 {JSON.stringify(useStyledTheme())} +} + +describe('useStyledTheme', () => { + it('returns the theme object the nearest provider gives', () => { + const { container } = render( + + + , + ) + expect(container.textContent).toBe('{"colors":{"brand":"red"}}') + }) + + it('returns an empty object without a provider', () => { + const { container } = render() + expect(container.textContent).toBe('{}') + }) +}) diff --git a/packages/react/src/hooks/__tests__/use-styled-theme.test.ts b/packages/react/src/hooks/__tests__/use-styled-theme.test.ts deleted file mode 100644 index 1882a7cc1..000000000 --- a/packages/react/src/hooks/__tests__/use-styled-theme.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from 'bun:test' - -import { useStyledTheme } from '../use-styled-theme' - -describe('useStyledTheme', () => { - it('returns css variable references rather than a theme name', () => { - const theme = useStyledTheme<{ colors: { brand: string } }>() - expect(`${theme.colors.brand}`).toBe('var(--colors-brand)') - }) -}) diff --git a/packages/react/src/hooks/use-styled-theme.ts b/packages/react/src/hooks/use-styled-theme.ts index 4088e9215..9a57e7cae 100644 --- a/packages/react/src/hooks/use-styled-theme.ts +++ b/packages/react/src/hooks/use-styled-theme.ts @@ -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 { - return themeAccessor as T + return (useContext(StyledThemeContext) ?? {}) as T } diff --git a/packages/react/src/utils/__tests__/create-theme.browser.test.tsx b/packages/react/src/utils/__tests__/create-theme.browser.test.tsx new file mode 100644 index 000000000..e57daca1b --- /dev/null +++ b/packages/react/src/utils/__tests__/create-theme.browser.test.tsx @@ -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( + + + , + ) + expect(container.querySelector('style')?.textContent).toBe( + ':host{--ds-colors-primary:blue;}', + ) + const { container: bare } = render() + expect(bare.querySelector('style')?.textContent).toBe(':root{}') + const { container: flat } = render( + + + , + ) + 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) + }) +}) diff --git a/packages/react/src/utils/__tests__/theme-vars.test.ts b/packages/react/src/utils/__tests__/theme-vars.test.ts index 7fa4b1c85..e36e2c26a 100644 --- a/packages/react/src/utils/__tests__/theme-vars.test.ts +++ b/packages/react/src/utils/__tests__/theme-vars.test.ts @@ -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', () => { @@ -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.iterator], - ).toBeUndefined() - }) -}) diff --git a/packages/react/src/utils/__tests__/with-theme.browser.test.tsx b/packages/react/src/utils/__tests__/with-theme.browser.test.tsx index aa6fea8ac..5ce6f8693 100644 --- a/packages/react/src/utils/__tests__/with-theme.browser.test.tsx +++ b/packages/react/src/utils/__tests__/with-theme.browser.test.tsx @@ -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( + function Swatch({ theme }, ref) { + return
{String(theme?.brand)}
+ }, +) +Swatch.displayName = 'Swatch' describe('withTheme', () => { - it('injects the css-variable theme accessor', () => { - const Swatch = ({ theme }: { theme?: StyledTheme }) => ( -
{`${(theme as { brand: string }).brand}`}
+ it('injects the theme the nearest provider gives and forwards refs', () => { + const Themed = withTheme(Swatch) + const ref = createRef() + const { container } = render( + + + , ) + 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() - expect(container.textContent).toBe('var(--brand)') + expect( + render().container.textContent, + ).toBe('blue') + expect(render().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( + + {(theme) => String(theme.brand)} + , + ) + expect(container.textContent).toBe('green') + expect( + render({(theme) => JSON.stringify(theme)}) + .container.textContent, + ).toBe('{}') }) }) diff --git a/packages/react/src/utils/create-theme.tsx b/packages/react/src/utils/create-theme.tsx new file mode 100644 index 000000000..d2a0759a7 --- /dev/null +++ b/packages/react/src/utils/create-theme.tsx @@ -0,0 +1,99 @@ +'use client' + +import { useContext } from 'react' + +import { StyledThemeContext } from './theme-context' +import type { StyledTheme } from './theme-vars' + +interface CreateThemeOptions { + /** Prefix of the CSS variable names, `sc` by default */ + prefix?: string + /** Selector declaring the variables, `:root` by default */ + selector?: string +} + +type VarTheme = { + [K in keyof T]: T[K] extends StyledTheme ? VarTheme : string +} + +export type ThemeContract = VarTheme & { + GlobalStyle: () => React.ReactElement + raw: T + vars: VarTheme + resolve: (element?: Element) => T +} + +/** Each leaf of `theme` as `leaf(path, value)`, keeping its shape */ +function mapLeaves( + theme: StyledTheme, + leaf: (path: string, value: string | number) => string | number, + path = '', +): StyledTheme { + const result: StyledTheme = {} + for (const [key, value] of Object.entries(theme)) { + const next = path ? `${path}-${key}` : key + result[key] = + value !== null && typeof value === 'object' + ? mapLeaves(value, leaf, next) + : leaf(next, value) + } + return result +} + +/** The declarations of the leaves `shape` has, with the values `theme` gives */ +function declarations( + shape: StyledTheme, + theme: StyledTheme | undefined, + prefix: string, + path = '', +): string { + let css = '' + for (const [key, value] of Object.entries(shape)) { + const next = path ? `${path}-${key}` : key + const themed = theme?.[key] + if (value !== null && typeof value === 'object') { + if (themed !== null && typeof themed === 'object') { + css += declarations(value, themed, prefix, next) + } + } else if (themed !== undefined && typeof themed !== 'object') { + css += `--${prefix}${next}:${themed};` + } + } + return css +} + +/** + * styled-components' `createTheme`: the theme with each leaf a + * `var(--prefix-path, value)` reference, and a `GlobalStyle` declaring the + * variables from the theme the nearest `ThemeProvider` gives + */ +export function createTheme( + defaultTheme: T, + options?: CreateThemeOptions, +): ThemeContract { + const prefix = `${options?.prefix ?? 'sc'}-` + const selector = options?.selector ?? ':root' + const refs = mapLeaves( + defaultTheme, + (path, value) => `var(--${prefix}${path}, ${value})`, + ) + function GlobalStyle() { + const theme = useContext(StyledThemeContext) + return ( + + ) + } + return Object.assign(refs, { + GlobalStyle, + raw: defaultTheme, + vars: mapLeaves(defaultTheme, (path) => `--${prefix}${path}`), + resolve(element?: Element) { + const styles = getComputedStyle(element ?? document.documentElement) + return mapLeaves( + defaultTheme, + (path, value) => + styles.getPropertyValue(`--${prefix}${path}`).trim() || value, + ) + }, + }) as unknown as ThemeContract +} diff --git a/packages/react/src/utils/theme-context.ts b/packages/react/src/utils/theme-context.ts new file mode 100644 index 000000000..cf28c2425 --- /dev/null +++ b/packages/react/src/utils/theme-context.ts @@ -0,0 +1,14 @@ +'use client' + +import { createContext } from 'react' + +import type { StyledTheme } from './theme-vars' + +/** + * The theme object the nearest compat `ThemeProvider` gives, as + * styled-components and Emotion hand it to `useTheme`, `withTheme` and + * `ThemeConsumer`. Styles keep reading CSS variables; only JS reads this. + */ +export const StyledThemeContext = createContext( + undefined, +) diff --git a/packages/react/src/utils/theme-vars.ts b/packages/react/src/utils/theme-vars.ts index 88f54d7a2..1f6c6dbf2 100644 --- a/packages/react/src/utils/theme-vars.ts +++ b/packages/react/src/utils/theme-vars.ts @@ -33,27 +33,3 @@ export function themeToCssVariables(theme?: StyledTheme): CSSProperties { if (theme) walk(theme, []) return style as CSSProperties } - -/** - * Theme accessor backed purely by CSS variables. - * - * Reading any path yields the `var(--path)` reference for it, so a value read in - * JS and a value the extractor inlined at build time resolve to the same custom - * property. Nesting works because each read returns another accessor. - */ -export function createThemeAccessor( - path: readonly string[] = [], -): T { - const reference = path.length ? `var(${themeVariableName(path)})` : '' - return new Proxy( - {}, - { - get(_target, key) { - if (key === Symbol.toPrimitive) return () => reference - if (typeof key !== 'string') return undefined - if (key === 'toString' || key === 'valueOf') return () => reference - return createThemeAccessor([...path, key]) - }, - }, - ) as T -} diff --git a/packages/react/src/utils/with-theme.tsx b/packages/react/src/utils/with-theme.tsx index 14f25c2bb..3b0f307eb 100644 --- a/packages/react/src/utils/with-theme.tsx +++ b/packages/react/src/utils/with-theme.tsx @@ -1,12 +1,43 @@ -import type { ComponentType } from 'react' +'use client' -import { useStyledTheme } from '../hooks/use-styled-theme' +import type { ComponentType, ReactNode, Ref } from 'react' +import { forwardRef, useContext } from 'react' + +import { StyledThemeContext } from './theme-context' import type { StyledTheme } from './theme-vars' +/** + * Hands `Component` the theme the nearest `ThemeProvider` gives, unless the + * caller passes its own `theme`, forwarding refs as styled-components does + */ export function withTheme

( Component: ComponentType

, -): ComponentType> { - return function WithTheme(props: Omit) { - return - } +) { + const WithTheme = forwardRef(function WithTheme( + props: Omit & { theme?: StyledTheme }, + ref: Ref, + ) { + const context = useContext(StyledThemeContext) + return ( + + ) + }) + WithTheme.displayName = `WithTheme(${Component.displayName || Component.name || 'Component'})` + return WithTheme +} + +/** + * styled-components' `ThemeConsumer`: renders what `children` makes of the + * theme the nearest `ThemeProvider` gives + */ +export function ThemeConsumer({ + children, +}: { + children: (theme: StyledTheme) => ReactNode +}): ReactNode { + return children(useContext(StyledThemeContext) ?? {}) } From 81cbf6a32d0f52244845a93f76dcbbd9258e9c10 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 08:21:14 +0900 Subject: [PATCH 2/2] fix(react): type withTheme forwardRef props for the package build Refs #689 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/react/src/utils/with-theme.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/react/src/utils/with-theme.tsx b/packages/react/src/utils/with-theme.tsx index 3b0f307eb..8cd49393b 100644 --- a/packages/react/src/utils/with-theme.tsx +++ b/packages/react/src/utils/with-theme.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ComponentType, ReactNode, Ref } from 'react' +import type { ComponentType, PropsWithoutRef, ReactNode, Ref } from 'react' import { forwardRef, useContext } from 'react' import { StyledThemeContext } from './theme-context' @@ -14,15 +14,16 @@ export function withTheme

( Component: ComponentType

, ) { const WithTheme = forwardRef(function WithTheme( - props: Omit & { theme?: StyledTheme }, + props: PropsWithoutRef & { theme?: StyledTheme }>, ref: Ref, ) { const context = useContext(StyledThemeContext) + const own = (props as unknown as { theme?: StyledTheme }).theme return ( ) })