Skip to content

Commit

Permalink
[core] Make mergeReactProps work with non-native event handlers (#1440
Browse files Browse the repository at this point in the history
)
  • Loading branch information
michaldudak authored Feb 13, 2025
1 parent 53c3dc8 commit e896408
Show file tree
Hide file tree
Showing 2 changed files with 153 additions and 81 deletions.
57 changes: 38 additions & 19 deletions packages/react/src/utils/mergeReactProps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ describe('mergeReactProps', () => {
};
const mergedProps = mergeReactProps<'button'>(theirProps, ourProps);

mergedProps.onClick?.({} as any);
mergedProps.onKeyDown?.({} as any);
mergedProps.onPaste?.({} as any);
mergedProps.onClick?.({ nativeEvent: new MouseEvent('click') } as any);
mergedProps.onKeyDown?.({ nativeEvent: new KeyboardEvent('keydown') } as any);
mergedProps.onPaste?.({ nativeEvent: new Event('paste') } as any);

expect(theirProps.onClick.calledBefore(ourProps.onClick)).to.equal(true);
expect(theirProps.onClick.callCount).to.equal(1);
Expand Down Expand Up @@ -47,7 +47,7 @@ describe('mergeReactProps', () => {
},
);

mergedProps.onClick?.({} as any);
mergedProps.onClick?.({ nativeEvent: new MouseEvent('click') } as any);
expect(log).to.deep.equal(['1', '2', '3']);
});

Expand All @@ -70,9 +70,8 @@ describe('mergeReactProps', () => {
const theirProps = {
style: { color: 'red' },
};
const ourProps = {
style: undefined,
};
const ourProps = {};

const mergedProps = mergeReactProps<'button'>(theirProps, ourProps);

expect(mergedProps.style).to.deep.equal({
Expand All @@ -81,12 +80,8 @@ describe('mergeReactProps', () => {
});

it('does not merge styles if both are undefined', () => {
const theirProps = {
style: undefined,
};
const ourProps = {
style: undefined,
};
const theirProps = {};
const ourProps = {};
const mergedProps = mergeReactProps<'button'>(theirProps, ourProps);

expect(mergedProps.style).to.equal(undefined);
Expand All @@ -106,7 +101,7 @@ describe('mergeReactProps', () => {
},
);

mergedProps.onClick?.({} as any);
mergedProps.onClick?.({ nativeEvent: new MouseEvent('click') } as any);

expect(ran).to.equal(true);
});
Expand All @@ -116,23 +111,24 @@ describe('mergeReactProps', () => {

const mergedProps = mergeReactProps<'button'>(
{
onClick(event) {
onClick: function onClick1(event) {
event.preventBaseUIHandler();
},
},
{
onClick() {
onClick: function onClick2() {
ran = true;
},
},
{
onClick() {
onClick: function onClick3() {
ran = true;
},
},
);

mergedProps.onClick?.({} as any);
const event = { nativeEvent: new MouseEvent('click') } as any;
mergedProps.onClick?.(event);

expect(ran).to.equal(false);
});
Expand All @@ -159,11 +155,34 @@ describe('mergeReactProps', () => {
},
);

mergedProps.onClick?.({} as any);
mergedProps.onClick?.({ nativeEvent: new MouseEvent('click') });

expect(log).to.deep.equal(['0', '1']);
});

[true, 13, 'newValue', { key: 'value' }, ['value'], () => 'value'].forEach((eventArgument) => {
it('handles non-standard event handlers without error', () => {
const log: string[] = [];

const mergedProps = mergeReactProps(
{
onValueChange() {
log.push('0');
},
},
{
onValueChange() {
log.push('1');
},
},
);

mergedProps.onValueChange(eventArgument);

expect(log).to.deep.equal(['0', '1']);
});
});

it('merges internal props so that the ones defined first override the ones defined later', () => {
const mergedProps = mergeReactProps<'button'>(
{},
Expand Down
177 changes: 115 additions & 62 deletions packages/react/src/utils/mergeReactProps.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,140 @@
import type * as React from 'react';
import * as React from 'react';
import type { BaseUIEvent, WithBaseUIEvent } from './types';

/**
* Merges multiple sets of React props such that their event handlers are called in sequence (the user's
* before our internal ones), and allows the user to prevent the internal event handlers from being
* executed by attaching a `preventBaseUIHandler` method. It also merges the `style` prop, whereby
* the user's styles overwrite the internal ones.
* @important **`className` and `ref` are not merged.**
* @param externalProps the user's external props.
* @param internalProps our own internal props.
* Merges multiple sets of React props such that their event handlers are called in sequence
* (the leftmost one being called first), and allows the user to prevent the subsequent event handlers from being
* executed by attaching a `preventBaseUIHandler` method.
* It also merges the `className` and `style` props, whereby the classes are concatenated
* and the leftmost styles overwrite the subsequent ones.
* @important **`ref` is not merged.**
* @param props props to merge.
* @returns the merged props.
*/
export function mergeReactProps<T extends React.ElementType>(
externalProps: WithBaseUIEvent<React.ComponentPropsWithRef<T>> | undefined,
...internalProps: React.ComponentPropsWithRef<T>[]
...props: (WithBaseUIEvent<React.ComponentPropsWithRef<T>> | undefined)[]
): WithBaseUIEvent<React.ComponentPropsWithRef<T>> {
let mergedInternalProps: WithBaseUIEvent<React.ComponentPropsWithRef<T>> = internalProps[0];
for (let i = 1; i < internalProps.length; i += 1) {
mergedInternalProps = merge(mergedInternalProps, internalProps[i]);
if (props.length === 0) {
return {} as WithBaseUIEvent<React.ComponentPropsWithRef<T>>;
}

if (props.length === 1) {
return props[0] ?? ({} as WithBaseUIEvent<React.ComponentPropsWithRef<T>>);
}

return merge(externalProps, mergedInternalProps as React.ComponentPropsWithRef<T>);
let merged = merge(props[props.length - 2], props[props.length - 1]);

for (let i = props.length - 3; i >= 0; i -= 1) {
merged = merge(props[i], merged);
}

return merged ?? ({} as WithBaseUIEvent<React.ComponentPropsWithRef<T>>);
}

/**
* Merges two sets of props. In case of conflicts, the external props take precedence.
*/
function merge<T extends React.ElementType>(
externalProps: WithBaseUIEvent<React.ComponentPropsWithRef<T>> | undefined,
internalProps: React.ComponentPropsWithRef<T>,
internalProps: WithBaseUIEvent<React.ComponentPropsWithRef<T>> | undefined,
): WithBaseUIEvent<React.ComponentPropsWithRef<T>> {
if (!externalProps) {
if (!internalProps) {
return {} as WithBaseUIEvent<React.ComponentPropsWithRef<T>>;
}

return internalProps;
}

if (!internalProps) {
return externalProps;
}

return Object.entries(externalProps).reduce(
(acc, [key, value]) => {
if (
// This approach is more efficient than using a regex.
key[0] === 'o' &&
key[1] === 'n' &&
key.charCodeAt(2) >= 65 /* A */ &&
key.charCodeAt(2) <= 90 /* Z */ &&
typeof value === 'function'
) {
acc[key] = (event: React.SyntheticEvent) => {
let isPrevented = false;

const theirHandler = value;
const ourHandler = internalProps[key];

const baseUIEvent = event as BaseUIEvent<typeof event>;

baseUIEvent.preventBaseUIHandler = () => {
isPrevented = true;
};

const result = theirHandler(baseUIEvent);

if (!isPrevented) {
ourHandler?.(baseUIEvent);
}

return result;
};
} else if (key === 'style') {
if (value || internalProps.style) {
acc[key] = { ...internalProps.style, ...(value || {}) };
}
} else if (key === 'className') {
if (value) {
if (internalProps.className) {
// eslint-disable-next-line prefer-template
acc[key] = value + ' ' + internalProps.className;
} else {
acc[key] = value;
}
} else {
acc[key] = internalProps.className;
}
(mergedProps, [propName, externalPropValue]) => {
if (isEventHandler(propName, externalPropValue)) {
mergedProps[propName] = mergeEventHandlers(externalPropValue, internalProps[propName]);
} else if (propName === 'style') {
mergedProps[propName] = mergeStyles(
externalPropValue as React.CSSProperties,
internalProps.style,
);
} else if (propName === 'className') {
mergedProps[propName] = mergeClassNames(
externalPropValue as string,
internalProps.className,
);
} else {
acc[key] = value;
mergedProps[propName] = externalPropValue;
}

return acc;
return mergedProps;
},
{ ...internalProps },
{ ...internalProps } as React.ComponentPropsWithRef<T>,
);
}

function isEventHandler(key: string, value: unknown) {
// This approach is more efficient than using a regex.
const thirdCharCode = key.charCodeAt(2);
return (
key[0] === 'o' &&
key[1] === 'n' &&
thirdCharCode >= 65 /* A */ &&
thirdCharCode <= 90 /* Z */ &&
typeof value === 'function'
);
}

function mergeEventHandlers(theirHandler: Function, ourHandler: Function) {
return (event: unknown) => {
if (isSyntheticEvent(event)) {
let isPrevented = false;
const baseUIEvent = event as BaseUIEvent<typeof event>;

baseUIEvent.preventBaseUIHandler = () => {
isPrevented = true;
};

const result = theirHandler(baseUIEvent);

if (!isPrevented) {
ourHandler?.(baseUIEvent);
}

return result;
}

const result = theirHandler(event);
ourHandler?.(event);
return result;
};
}

function mergeStyles(
theirStyle: React.CSSProperties | undefined,
ourStyle: React.CSSProperties | undefined,
) {
if (theirStyle || ourStyle) {
return { ...ourStyle, ...theirStyle };
}

return undefined;
}

function mergeClassNames(theirClassName: string | undefined, ourClassName: string | undefined) {
if (theirClassName) {
if (ourClassName) {
// eslint-disable-next-line prefer-template
return theirClassName + ' ' + ourClassName;
}

return theirClassName;
}

return ourClassName;
}

function isSyntheticEvent(event: unknown): event is React.SyntheticEvent {
return event != null && typeof event === 'object' && 'nativeEvent' in event;
}

0 comments on commit e896408

Please sign in to comment.