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
19 changes: 19 additions & 0 deletions .changeset/mergeprops-generics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@dunky.dev/state-machine-utils': minor
'@dunky.dev/react-state-machine': minor
'@dunky.dev/native-state-machine': minor
'@dunky.dev/opentui-state-machine': minor
---

`mergeProps` is generic over the consumer's props: a framework prop type (an
interface without an index signature — `PressableProps`, `ComponentProps<'div'>`)
now passes in and comes back out cast-free. Behavior is unchanged; the merged
bag still carries the library's bindings, typed as the consumer's props (the
`Object.assign` convention), so the JSX spread stays clean.

```tsx
// before
const merged = mergeProps(props as Record<string, unknown>, bindings) as PressableProps
// after
const merged = mergeProps(props, bindings)
```
16 changes: 10 additions & 6 deletions packages/native/src/merge-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils'

type AnyProps = Record<string, unknown>

export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
const merged = baseMergeProps(consumer, library)
if (!consumer) return merged
export function mergeProps<Props extends object = AnyProps>(
consumer: Props | undefined,
library: AnyProps,
): Props & AnyProps {
const merged: AnyProps = baseMergeProps(consumer as AnyProps | undefined, library)
if (!consumer) return merged as Props & AnyProps
const own = consumer as AnyProps

if (consumer.style != null && library.style != null) {
merged.style = [consumer.style, library.style]
if (own.style != null && library.style != null) {
merged.style = [own.style, library.style]
}

return merged
return merged as Props & AnyProps
}
15 changes: 14 additions & 1 deletion packages/native/tests/merge-props.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { mergeProps } from '@dunky.dev/native-state-machine'

describe('mergeProps', () => {
Expand Down Expand Up @@ -43,3 +43,16 @@ describe('mergeProps', () => {
expect(out.className).toBe('b')
})
})

describe('mergeProps typing', () => {
it('preserves a typed consumer through the style merge', () => {
interface PressableLikeProps {
style?: unknown
onPress?: () => void
}
const consumer: PressableLikeProps = { style: { opacity: 1 } }
const out = mergeProps(consumer, { style: { opacity: 0.5 } })
expectTypeOf(out).toExtend<PressableLikeProps>()
expect(out.style).toEqual([{ opacity: 1 }, { opacity: 0.5 }])
})
})
13 changes: 8 additions & 5 deletions packages/opentui/src/merge-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ type AnyProps = Record<string, unknown>

// OpenTUI style is a plain object (not array-mergeable like RN), so overlapping styles spread
// into one object with library winning on conflicts. No className in a terminal.
export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
const merged = baseMergeProps(consumer, library)
if (!consumer) return merged
export function mergeProps<Props extends object = AnyProps>(
consumer: Props | undefined,
library: AnyProps,
): Props & AnyProps {
const merged: AnyProps = baseMergeProps(consumer as AnyProps | undefined, library)
if (!consumer) return merged as Props & AnyProps

const consumerStyle = consumer.style
const consumerStyle = (consumer as AnyProps).style
const libraryStyle = library.style
if (isStyleObject(consumerStyle) && isStyleObject(libraryStyle)) {
merged.style = { ...consumerStyle, ...libraryStyle }
}

return merged
return merged as Props & AnyProps
}

function isStyleObject(value: unknown): value is Record<string, unknown> {
Expand Down
20 changes: 12 additions & 8 deletions packages/react/src/merge-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ import { mergeProps as baseMergeProps } from '@dunky.dev/state-machine-utils'

type AnyProps = Record<string, unknown>

export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
const merged = baseMergeProps(consumer, library)
if (!consumer) return merged
export function mergeProps<Props extends object = AnyProps>(
consumer: Props | undefined,
library: AnyProps,
): Props & AnyProps {
const merged: AnyProps = baseMergeProps(consumer as AnyProps | undefined, library)
if (!consumer) return merged as Props & AnyProps
const own = consumer as AnyProps

if (consumer.style != null && library.style != null) {
merged.style = [consumer.style, library.style]
if (own.style != null && library.style != null) {
merged.style = [own.style, library.style]
}
if (typeof consumer.className === 'string' && typeof library.className === 'string') {
merged.className = `${consumer.className} ${library.className}`.trim()
if (typeof own.className === 'string' && typeof library.className === 'string') {
merged.className = `${own.className} ${library.className}`.trim()
}

return merged
return merged as Props & AnyProps
}
17 changes: 12 additions & 5 deletions packages/shared/utils/src/utils/merge-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@ function compose(consumer: AnyHandler, library: AnyHandler): AnyHandler {
}
}

export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): AnyProps {
if (!consumer) return library
const out: AnyProps = { ...consumer }
// Generic over the consumer's props so framework prop types (interfaces
// without an index signature) pass in and come back out cast-free. The return
// is the Object.assign-style intersection: assignable to the consumer's props
// (the JSX spread needs no cast) while the library's bindings stay reachable.
export function mergeProps<Props extends object = AnyProps>(
consumer: Props | undefined,
library: AnyProps,
): Props & AnyProps {
if (!consumer) return library as Props & AnyProps
const out: AnyProps = { ...(consumer as AnyProps) }

for (const [key, libValue] of Object.entries(library)) {
const consumerValue = consumer[key]
const consumerValue = (consumer as AnyProps)[key]

if (isEventHandlerKey(key) && isFn(consumerValue) && isFn(libValue)) {
out[key] = compose(consumerValue, libValue)
Expand All @@ -34,5 +41,5 @@ export function mergeProps(consumer: AnyProps | undefined, library: AnyProps): A
out[key] = libValue
}

return out
return out as Props & AnyProps
}
17 changes: 16 additions & 1 deletion packages/shared/utils/tests/merge-props.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { mergeProps } from '@dunky.dev/state-machine-utils'

describe('mergeProps', () => {
Expand Down Expand Up @@ -85,3 +85,18 @@ describe('mergeProps', () => {
expect(consumer.id).toBe('a')
})
})

describe('mergeProps typing', () => {
it('accepts a typed consumer interface and preserves its type', () => {
// An interface without an index signature — the shape every framework's
// prop types have. Must be accepted and returned as-is, cast-free.
interface ButtonLikeProps {
id?: string
onClick?: (event: { defaultPrevented: boolean }) => void
}
const consumer: ButtonLikeProps = { id: 'mine' }
const out = mergeProps(consumer, { role: 'button' })
expectTypeOf(out).toExtend<ButtonLikeProps>()
expect(out.id).toBe('mine')
})
})
Loading