Skip to content
Draft
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
84 changes: 84 additions & 0 deletions packages/react/src/utils/__tests__/mergeProps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {test, expect, vi} from 'vitest'
import {mergeProps} from '../mergeProps'

test('merges className', () => {
const propsA = {className: 'foo'}
const propsB = {className: 'bar'}
const merged = mergeProps(propsA, propsB)

expect(merged.className).toBe('foo bar')
})

test('merges event handlers', () => {
const onClickA = vi.fn()
const onClickB = vi.fn()
const propsA = {onClick: onClickA}
const propsB = {onClick: onClickB}
const merged = mergeProps(propsA, propsB)
merged.onClick(new MouseEvent('click'))

expect(onClickA).toHaveBeenCalled()
expect(onClickB).toHaveBeenCalled()
})

test('does not call second event handler if first prevents default', () => {
const onClickA = vi.fn(event => event.preventDefault())
const onClickB = vi.fn()
const propsA = {onClick: onClickA}
const propsB = {onClick: onClickB}
const merged = mergeProps(propsA, propsB)
merged.onClick(new MouseEvent('click', {cancelable: true}))

expect(onClickA).toHaveBeenCalled()
expect(onClickB).not.toHaveBeenCalled()
})

test('overrides event handler props when the existing value is not a function', () => {
const onClick = vi.fn()
const propsA = {onClick: undefined}
const propsB = {onClick}
const merged = mergeProps(propsA, propsB)

merged.onClick(new MouseEvent('click'))

expect(onClick).toHaveBeenCalled()
})

test('merges style', () => {
const propsA = {style: {color: 'red'}}
const propsB = {style: {backgroundColor: 'blue'}}
const merged = mergeProps(propsA, propsB)

expect(merged.style).toEqual({color: 'red', backgroundColor: 'blue'})
})

test('overrides non-mergeable props', () => {
const propsA = {id: 'foo'}
const propsB = {id: 'bar'}
const merged = mergeProps(propsA, propsB)

expect(merged.id).toBe('bar')
})

test('adds new props', () => {
const propsA = {foo: 'foo'}
const propsB = {bar: 'bar'}
const merged = mergeProps(propsA, propsB)

expect(merged.foo).toBe('foo')
expect(merged.bar).toBe('bar')
})

test('merges multiple props', () => {
const onClickA = vi.fn()
const onClickB = vi.fn()
const propsA = {className: 'foo', onClick: onClickA, style: {color: 'red'}}
const propsB = {className: 'bar', onClick: onClickB, style: {backgroundColor: 'blue'}}
const merged = mergeProps(propsA, propsB)
merged.onClick(new MouseEvent('click'))

expect(merged.className).toBe('foo bar')
expect(merged.style).toEqual({color: 'red', backgroundColor: 'blue'})
expect(onClickA).toHaveBeenCalled()
expect(onClickB).toHaveBeenCalled()
})
22 changes: 22 additions & 0 deletions packages/react/src/utils/__tests__/mergeProps.types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {mergeProps} from '../mergeProps'

export function mergePropsKeepsPropsFromBothObjects() {
const merged = mergeProps({foo: 'foo'}, {bar: 1})

return merged satisfies {foo: string; bar: number}
}

export function mergePropsUsesSecondObjectForOverlappingProps() {
const merged = mergeProps({value: 'one'}, {value: 1})

return merged satisfies {value: number}
}

export function mergePropsDoesNotIntersectOverlappingProps() {
const merged = mergeProps({value: 'one'}, {value: 1})

// @ts-expect-error overlapping props should use the second object type instead of an impossible intersection
const value: never = merged.value

return value
}
60 changes: 60 additions & 0 deletions packages/react/src/utils/mergeProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {clsx} from 'clsx'
import type {Merge} from './types'

type EventHandler = (event: {defaultPrevented?: boolean}) => void
type ClassValue = Parameters<typeof clsx>[number]

function mergeProps<A extends object, B extends object = A>(a: A, b: B): Merge<A, B> {
const merged = {...a} as Record<string, unknown>

for (const [key, value] of Object.entries(b)) {
if (key in merged) {
const existing = merged[key]

if (key === 'className') {
merged[key] = clsx(existing as ClassValue, value as ClassValue)
} else if (isEventHandlerKey(key) && isEventHandler(existing) && isEventHandler(value)) {
merged[key] = composeEventHandlers(existing, value)
} else if (key === 'style') {
merged[key] = mergeStyle(existing, value)
} else {
merged[key] = value
}
} else {
merged[key] = value
}
}

return merged as Merge<A, B>
}

function composeEventHandlers(a: EventHandler, b: EventHandler) {
return (event: Parameters<EventHandler>[0]) => {
a(event)

if (!event.defaultPrevented) {
b(event)
}
}
}

function isEventHandlerKey(key: string) {
return key.startsWith('on')
}

function isEventHandler(value: unknown): value is EventHandler {
return typeof value === 'function'
}

function mergeStyle(a: unknown, b: unknown) {
return {
...(isObject(a) ? a : {}),
...(isObject(b) ? b : {}),
}
}

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

export {mergeProps}
Loading