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
5 changes: 5 additions & 0 deletions packages/styled-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
"peerDependencies": {
"styled-components": ">=6.0.0"
},
"devDependencies": {
"@testing-library/react": "*",
"react": "*",
"vitest": "*"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
Expand Down
110 changes: 110 additions & 0 deletions packages/styled-tools/src/lib/mkProxy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { ExecutionContext } from "styled-components"
import { describe, expect, it } from "vitest"
import { mkProxy, Value } from "./mkProxy"

// Builds a minimal ExecutionContext carrying the provided theme value.
function ctx(theme: Value): ExecutionContext {
return { theme } as unknown as ExecutionContext
}

describe("mkProxy", () => {
it("returns the accessor result when invoked directly with a context", () => {
const proxy = mkProxy(c => c.theme as Value)

expect((proxy as unknown as (c: ExecutionContext) => Value)(ctx("root-value"))).toBe("root-value")
})

it("traverses nested object properties lazily until invoked", () => {
const theme = { colors: { primary: "#fff", secondary: "#000" } }
const proxy = mkProxy(c => c.theme as Value) as any

expect(proxy.colors.primary(ctx(theme))).toBe("#fff")
expect(proxy.colors.secondary(ctx(theme))).toBe("#000")
})

it("resolves deeply nested values across multiple levels", () => {
const theme = { a: { b: { c: { d: 42 } } } }
const proxy = mkProxy(c => c.theme as Value) as any

expect(proxy.a.b.c.d(ctx(theme))).toBe(42)
})

it("returns undefined when a property is accessed on a non-object (string) value", () => {
const theme = { colors: "not-an-object" }
const proxy = mkProxy(c => c.theme as Value) as any

// colors resolves to a string; reaching into it must hit the guard and return undefined
expect(proxy.colors.primary(ctx(theme))).toBeUndefined()
})

it("returns undefined when a property is accessed on a number value", () => {
const theme = { spacing: 8 }
const proxy = mkProxy(c => c.theme as Value) as any

expect(proxy.spacing.small(ctx(theme))).toBeUndefined()
})

it("returns undefined when the accessor itself yields undefined", () => {
const emptyValue: Value = undefined
const proxy = mkProxy(() => emptyValue) as any

expect(proxy(ctx(emptyValue))).toBeUndefined()
expect(proxy.anything(ctx(emptyValue))).toBeUndefined()
})

it("returns undefined for a missing property on an object value", () => {
const theme = { colors: { primary: "#fff" } }
const proxy = mkProxy(c => c.theme as Value) as any

expect(proxy.colors.missing(ctx(theme))).toBeUndefined()
})

it("exposes Function.prototype for the special 'prototype' property", () => {
const proxy = mkProxy(c => c.theme as Value) as any

expect(proxy.prototype).toBe(Function.prototype)
})

it("caches nested proxies so repeated access returns the same reference", () => {
const proxy = mkProxy(c => c.theme as Value) as any

const first = proxy.colors
const second = proxy.colors

expect(first).toBe(second)
})

it("creates distinct proxies for distinct properties", () => {
const proxy = mkProxy(c => c.theme as Value) as any

expect(proxy.colors).not.toBe(proxy.spacing)
})

it("re-evaluates the accessor against the context on every invocation", () => {
const proxy = mkProxy(c => c.theme as Value) as any

const themeA = { colors: { primary: "#aaa" } }
const themeB = { colors: { primary: "#bbb" } }

// Same cached proxy node, different contexts -> different resolved values
const accessor = proxy.colors.primary
expect(accessor(ctx(themeA))).toBe("#aaa")
expect(accessor(ctx(themeB))).toBe("#bbb")
})

it("supports symbol-keyed property access", () => {
const key = Symbol("token")
const theme = { [key]: "symbol-value" }
const proxy = mkProxy(c => c.theme as Value) as any

expect(proxy[key](ctx(theme))).toBe("symbol-value")
})

it("treats null as an object access (typeof null === 'object') and returns undefined for its props", () => {
// guard() uses typeof === "object"; null passes the guard but optional chaining yields undefined
const theme = { colors: null as unknown as Value }
const proxy = mkProxy(c => c.theme as Value) as any

expect(proxy.colors.primary(ctx(theme))).toBeUndefined()
})
})
89 changes: 89 additions & 0 deletions packages/styled-tools/src/lib/mkTheme.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { type ReactNode } from "react"
import { renderHook } from "@testing-library/react"
import { type ExecutionContext, ThemeProvider } from "styled-components"
import { describe, expect, it } from "vitest"
import { mkTheme } from "./mkTheme"

type AppTheme = {
colors: { primary: string; secondary: string }
spacing: { small: string; large: string }
}

const appTheme: AppTheme = {
colors: { primary: "#ff0000", secondary: "#00ff00" },
spacing: { small: "4px", large: "16px" },
}

// Resolves a styled-components interpolation against an ExecutionContext carrying the theme.
function withTheme(theme: AppTheme): ExecutionContext {
return { theme } as unknown as ExecutionContext
}

describe("mkTheme", () => {
it("returns an object exposing both a theme proxy and a useTheme hook", () => {
const tools = mkTheme<AppTheme>()

expect(tools).toHaveProperty("theme")
expect(tools).toHaveProperty("useTheme")
expect(typeof tools.useTheme).toBe("function")
})

describe("theme proxy", () => {
it("reads top-level nested values from the execution context theme", () => {
const { theme } = mkTheme<AppTheme>()

const primary = theme.colors.primary as unknown as (c: ExecutionContext) => string
const small = theme.spacing.small as unknown as (c: ExecutionContext) => string

expect(primary(withTheme(appTheme))).toBe("#ff0000")
expect(small(withTheme(appTheme))).toBe("4px")
})

it("resolves the latest theme on each invocation (theme switching)", () => {
const { theme } = mkTheme<AppTheme>()

const darkTheme: AppTheme = {
colors: { primary: "#111111", secondary: "#222222" },
spacing: { small: "2px", large: "8px" },
}

const primary = theme.colors.primary as unknown as (c: ExecutionContext) => string

expect(primary(withTheme(appTheme))).toBe("#ff0000")
expect(primary(withTheme(darkTheme))).toBe("#111111")
})
})

describe("useTheme hook", () => {
it("returns the theme provided by styled-components ThemeProvider", () => {
const { useTheme } = mkTheme<AppTheme>()

const wrapper = ({ children }: { children: ReactNode }) => (
<ThemeProvider theme={appTheme}>{children}</ThemeProvider>
)

const { result } = renderHook(() => useTheme(), { wrapper })

expect(result.current).toEqual(appTheme)
expect(result.current.colors.primary).toBe("#ff0000")
expect(result.current.spacing.large).toBe("16px")
})

it("reflects the active provider theme value", () => {
const { useTheme } = mkTheme<AppTheme>()

const otherTheme: AppTheme = {
colors: { primary: "#abcdef", secondary: "#fedcba" },
spacing: { small: "1px", large: "2px" },
}

const wrapper = ({ children }: { children: ReactNode }) => (
<ThemeProvider theme={otherTheme}>{children}</ThemeProvider>
)

const { result } = renderHook(() => useTheme(), { wrapper })

expect(result.current).toEqual(otherTheme)
})
})
})